GTFS: optional fleet marks, so new trains show up in the Plus feed

5ac303a · Gabriel Morell · 2026-10-14 23:08

23 files +390 -6
Message
{commit_body(@commit)}

Files changed

added apps/room_gtfs/lib/room_gtfs/fleet.ex
+80 −0
@@ -0,0 +1,80 @@
1 +defmodule RoomGtfs.Fleet do
2 + @moduledoc """
3 + Whether a vehicle is one of the ones a source has asked to have called out.
4 +
5 + The rules are a source's `fleet_marks`: route id to inclusive car-number
6 + ranges. A train is marked when any of its cars is in range for the route it
7 + is running on, which is how the new-train-tracker reads a mixed consist --
8 + one new car is a new train to someone looking for it.
9 +
10 + Car numbers come from the feed's own per-carriage labels where it sends
11 + them, and otherwise from the vehicle's label, which the MBTA writes as the
12 + cars joined by hyphens: "1877-1876". A label that is not a number is no car
13 + of anyone's fleet and simply never matches.
14 + """
15 +
16 + @doc """
17 + The rules for a route, or nil where there are none -- so a source that set
18 + none, or a route it did not mention, costs nothing further.
19 + """
20 + def rules_for(config, route_id) when is_binary(route_id) do
21 + case config && Map.get(config, :fleet_marks) do
22 + marks when is_map(marks) and map_size(marks) > 0 ->
23 + case Map.get(marks, route_id) do
24 + ranges when is_list(ranges) and ranges != [] -> ranges
25 + _ -> nil
26 + end
27 +
28 + _ ->
29 + nil
30 + end
31 + end
32 +
33 + def rules_for(_config, _route_id), do: nil
34 +
35 + @doc "Whether a car label falls in any of the ranges."
36 + def car_marked?(label, ranges) do
37 + case car_number(label) do
38 + nil -> false
39 + n -> Enum.any?(ranges, fn [low, high] -> n >= low and n <= high end)
40 + end
41 + end
42 +
43 + @doc """
44 + The car labels of a vehicle as `place_vehicle/2` records it: its carriages'
45 + labels if it reported any, else its own label split into cars.
46 + """
47 + def car_labels(vehicle) do
48 + carriage_labels =
49 + vehicle
50 + |> Map.get(:carriages)
51 + |> List.wrap()
52 + |> Enum.map(& &1.label)
53 + |> Enum.filter(&(is_binary(&1) and &1 != ""))
54 +
55 + case carriage_labels do
56 + [] ->
57 + case Map.get(vehicle, :label) do
58 + label when is_binary(label) -> label |> String.split("-") |> Enum.map(&String.trim/1)
59 + _ -> []
60 + end
61 +
62 + labels ->
63 + labels
64 + end
65 + end
66 +
67 + @doc "Whether any of a vehicle's cars is in range."
68 + def vehicle_marked?(vehicle, ranges) do
69 + vehicle |> car_labels() |> Enum.any?(&car_marked?(&1, ranges))
70 + end
71 +
72 + defp car_number(label) when is_binary(label) do
73 + case Integer.parse(String.trim(label)) do
74 + {n, ""} -> n
75 + _ -> nil
76 + end
77 + end
78 +
79 + defp car_number(_label), do: nil
80 +end
modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+39 −4
@@ -320,6 +320,7 @@ defmodule RoomGtfs.Worker do
320 320 x =
321 321 x
322 322 |> merge_occupancy(occupancy_for(config, x.trip_id, stu, vehicles))
323 + |> merge_fleet_mark(config, vehicles)
323 324 |> merge_schedule_relationship(val.schedule_relationship, stu)
324 325 |> merge_stop_properties(stu)
325 326
@@ -402,10 +403,7 @@ defmodule RoomGtfs.Worker do
402 403 # answer, so it reads the same as an absent field -- NOT_ACCEPTING_PASSENGERS
403 404 # is a real answer and is kept.
404 405 def occupancy_for(config, scheduled_trip_id, stu, vehicles) do
405 − vehicle =
406 − Enum.find(vehicles, fn v ->
407 − v.trip_id && trip_id_match?(config, scheduled_trip_id, v.trip_id)
408 − end)
406 + vehicle = vehicle_for(config, scheduled_trip_id, vehicles)
409 407
410 408 status =
411 409 occupancy_status(stu && stu.departure_occupancy_status) ||
@@ -426,6 +424,41 @@ defmodule RoomGtfs.Worker do
426 424 end
427 425 end
428 426
427 + defp vehicle_for(config, scheduled_trip_id, vehicles) do
428 + Enum.find(vehicles, fn v ->
429 + v.trip_id && trip_id_match?(config, scheduled_trip_id, v.trip_id)
430 + end)
431 + end
432 +
433 + @doc false
434 + # Whether this train is one the source asked to have called out -- see
435 + # RoomGtfs.Fleet. Only a source with rules for this route gets the keys at
436 + # all, and only once a vehicle is running the trip: before then there is no
437 + # telling which train it will be, which is not the same as "not a new one".
438 + def merge_fleet_mark(arrival, config, vehicles) do
439 + trip = Map.get(arrival, :trip)
440 + route_id = trip && Map.get(trip, :route_id)
441 +
442 + with ranges when ranges != nil <- RoomGtfs.Fleet.rules_for(config, route_id),
443 + vehicle when vehicle != nil <- vehicle_for(config, arrival.trip_id, vehicles) do
444 + arrival = Map.put(arrival, :marked, RoomGtfs.Fleet.vehicle_marked?(vehicle, ranges))
445 +
446 + case Map.get(arrival, :carriages) do
447 + nil ->
448 + arrival
449 +
450 + carriages ->
451 + Map.put(
452 + arrival,
453 + :carriages,
454 + Enum.map(carriages, &Map.put(&1, :marked, RoomGtfs.Fleet.car_marked?(&1.label, ranges)))
455 + )
456 + end
457 + else
458 + _ -> arrival
459 + end
460 + end
461 +
429 462 @doc false
430 463 # A train reporting each carriage separately is the difference between "the
431 464 # train is full" and "walk to the back". Kept in the sequence the feed gives,
@@ -1101,6 +1134,8 @@ defmodule RoomGtfs.Worker.RT do
1101 1134 defp place_vehicle(v, stops) do
1102 1135 base = %{
1103 1136 vehicle_id: v.vehicle && v.vehicle.id,
1137 + # What is painted on it, which for the MBTA is the car numbers.
1138 + label: v.vehicle && v.vehicle.label,
1104 1139 trip_id: v.trip && v.trip.trip_id,
1105 1140 route_id: v.trip && v.trip.route_id,
1106 1141 stop_id: v.stop_id,
added apps/room_gtfs/test/fleet_test.exs
+61 −0
@@ -0,0 +1,61 @@
1 +defmodule RoomGtfs.FleetTest do
2 + use ExUnit.Case, async: true
3 +
4 + alias RoomGtfs.{Fleet, Worker}
5 +
6 + @mbta %{
7 + fleet_marks: %{
8 + "Red" => [[1900, 2151]],
9 + "Green-D" => [[3900, 3923], [4001, 4102]]
10 + }
11 + }
12 +
13 + defp arrival(route_id, attrs \\ []) do
14 + Map.merge(%{trip_id: "t1", trip: %{route_id: route_id}}, Map.new(attrs))
15 + end
16 +
17 + defp vehicle(attrs) do
18 + Map.merge(%{trip_id: "t1", label: nil, carriages: []}, Map.new(attrs))
19 + end
20 +
21 + test "one new car makes a new train" do
22 + v = vehicle(label: "1877-1910")
23 +
24 + assert %{marked: true} = Worker.merge_fleet_mark(arrival("Red"), @mbta, [v])
25 + end
26 +
27 + test "an old train is marked false, not left unsaid" do
28 + v = vehicle(label: "1500-1501")
29 +
30 + assert %{marked: false} = Worker.merge_fleet_mark(arrival("Red"), @mbta, [v])
31 + end
32 +
33 + test "carriage labels are preferred and each is marked" do
34 + v =
35 + vehicle(
36 + label: "1877",
37 + carriages: [%{label: "1877"}, %{label: "4010"}]
38 + )
39 +
40 + a =
41 + arrival("Green-D", carriages: [%{label: "1877"}, %{label: "4010"}])
42 + |> Worker.merge_fleet_mark(@mbta, [v])
43 +
44 + assert a.marked
45 + assert [%{marked: false}, %{marked: true}] = a.carriages
46 + end
47 +
48 + test "a route without rules, a source without rules, and no vehicle all say nothing" do
49 + v = vehicle(label: "1910")
50 +
51 + refute Map.has_key?(Worker.merge_fleet_mark(arrival("Blue"), @mbta, [v]), :marked)
52 + refute Map.has_key?(Worker.merge_fleet_mark(arrival("Red"), %{}, [v]), :marked)
53 + refute Map.has_key?(Worker.merge_fleet_mark(arrival("Red"), @mbta, []), :marked)
54 + end
55 +
56 + test "a label that is not a number matches nothing" do
57 + refute Fleet.car_marked?("19xx", [[1900, 2151]])
58 + refute Fleet.car_marked?(nil, [[1900, 2151]])
59 + assert Fleet.car_marked?(" 2151 ", [[1900, 2151]])
60 + end
61 +end
modified apps/room_sanctum/lib/room_sanctum/condensers/plus_mqtt.ex
+4 −0
@@ -39,6 +39,10 @@ defmodule RoomSanctum.Condenser.PlusMQTT do
39 39 name: f.trip.trip_short_name,
40 40 bikes: f.trip.bikes_allowed,
41 41 carriages: Map.get(f, :carriages, []),
42 + # One of the vehicles the source's fleet marks call out -- true,
43 + # false, or nil where the source set none for this route or no
44 + # vehicle has been seen on the trip yet.
45 + marked: Map.get(f, :marked),
42 46 # Not running, or running but not calling here -- kept apart,
43 47 # because they are different news to a rider.
44 48 trip_status: Map.get(f, :trip_status),
modified apps/room_sanctum/lib/room_sanctum/configuration/configs/gtfs.ex
+131 −2
@@ -62,23 +62,152 @@ defmodule RoomSanctum.Configuration.Configs.GTFS do
62 62 # What the form edits: one `Name: value` per line. The stored shape is the
63 63 # map; this is the shape a person can type and read back.
64 64 field :headers_raw, :string, virtual: true
65 +
66 + # Vehicles worth calling out, by route and car number: route id to a list
67 + # of inclusive [low, high] ranges. An arrival whose train has any car in
68 + # range is marked in the Plus feed, and so is each such car. Written for
69 + # the MBTA's new fleets -- see mbta_new_trains_text/0 -- but nothing in it
70 + # is specific to them.
71 + #
72 + # Empty, which is every source that has not asked, marks nothing.
73 + field :fleet_marks, :map, default: %{}
74 +
75 + # The form's shape of the same: one `Route, Route: low-high, low-high` per
76 + # line, as with the headers.
77 + field :fleet_marks_raw, :string, virtual: true
65 78 end
66 79
80 + # The rules transitmatters' new-train-tracker uses, in its server's
81 + # chalicelib/fleet.py: CRRC cars on Red and Orange, Type 9 and 10 on the
82 + # Green Line, battery buses on the Silver Line. The tracker splits Red into
83 + # its two branches; the MBTA's own feed calls both of them Red.
84 + @mbta_new_trains """
85 + Red: 1900-2151
86 + Orange: 1400-1551
87 + Green-B, Green-C, Green-D, Green-E: 3900-3923, 4001-4102
88 + 741, 742, 743, 746, 749, 751: 1294-1299
89 + """
90 +
67 91 def changeset(source, params) do
68 92 source
69 93 |> cast(
70 94 params,
71 95 ~w(url url_rt_sa url_rt_tu url_rt_vp url_rt_shared tz rt_agency
72 − rt_trip_id_suffix rt_period_tu rt_period_vp rt_period_sa headers)a
96 + rt_trip_id_suffix rt_period_tu rt_period_vp rt_period_sa headers fleet_marks)a
73 97 )
74 98 # Its own cast, with "" kept as a value rather than treated as nothing.
75 99 # Ecto's default empty_values turns an emptied textarea into no change at
76 100 # all, so clearing the box left the old headers in place -- which means an
77 101 # API key could be added through this form and never taken away again.
78 − |> cast(params, [:headers_raw], empty_values: [])
102 + |> cast(params, [:headers_raw, :fleet_marks_raw], empty_values: [])
79 103 |> validate_required([:url, :tz])
80 104 |> validate_periods()
81 105 |> put_headers()
106 + |> put_fleet_marks()
107 + end
108 +
109 + @doc "The MBTA's new-train rules, as the form's text."
110 + def mbta_new_trains_text, do: String.trim_trailing(@mbta_new_trains)
111 +
112 + @doc """
113 + The stored fleet marks as the text the form edits.
114 +
115 + Routes sharing the same ranges are written on one line, so the MBTA rules
116 + read back as the four lines they went in as rather than eleven.
117 + """
118 + def fleet_marks_text(%{fleet_marks: marks}) when is_map(marks) and map_size(marks) > 0 do
119 + marks
120 + |> Enum.group_by(fn {_route, ranges} -> ranges end, fn {route, _} -> route end)
121 + |> Enum.map(fn {ranges, routes} -> {Enum.sort(routes), ranges} end)
122 + |> Enum.sort()
123 + |> Enum.map_join("\n", fn {routes, ranges} ->
124 + Enum.join(routes, ", ") <> ": " <> Enum.map_join(ranges, ", ", &range_text/1)
125 + end)
126 + end
127 +
128 + def fleet_marks_text(_config), do: ""
129 +
130 + defp range_text([low, low]), do: to_string(low)
131 + defp range_text([low, high]), do: "#{low}-#{high}"
132 +
133 + @doc """
134 + Parse the fleet marks textarea into route id => [[low, high], ...], or say
135 + which line is wrong.
136 +
137 + A line naming a route already named adds to it rather than replacing it.
138 + """
139 + def parse_fleet_marks(text) when is_binary(text) do
140 + text
141 + |> String.split(~r/\r?\n/)
142 + |> Enum.map(&String.trim/1)
143 + |> Enum.reject(&(&1 == ""))
144 + |> Enum.reduce_while({:ok, %{}}, fn line, {:ok, acc} ->
145 + with [routes, ranges] <- String.split(line, ":", parts: 2),
146 + routes = split_list(routes),
147 + true <- routes != [],
148 + {:ok, ranges} <- parse_ranges(ranges) do
149 + acc =
150 + Enum.reduce(routes, acc, fn route, acc ->
151 + Map.update(acc, route, ranges, &Enum.uniq(&1 ++ ranges))
152 + end)
153 +
154 + {:cont, {:ok, acc}}
155 + else
156 + _ -> {:halt, {:error, line}}
157 + end
158 + end)
159 + end
160 +
161 + def parse_fleet_marks(_text), do: {:ok, %{}}
162 +
163 + defp split_list(text) do
164 + text |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == ""))
165 + end
166 +
167 + defp parse_ranges(text) do
168 + case split_list(text) do
169 + [] ->
170 + :error
171 +
172 + parts ->
173 + Enum.reduce_while(parts, {:ok, []}, fn part, {:ok, acc} ->
174 + case parse_range(part) do
175 + {:ok, range} -> {:cont, {:ok, acc ++ [range]}}
176 + :error -> {:halt, :error}
177 + end
178 + end)
179 + end
180 + end
181 +
182 + defp parse_range(part) do
183 + case Regex.run(~r/^(\d+)\s*(?:-\s*(\d+))?$/, part) do
184 + [_, low] -> {:ok, [String.to_integer(low), String.to_integer(low)]}
185 + [_, low, high] -> ordered(String.to_integer(low), String.to_integer(high))
186 + _ -> :error
187 + end
188 + end
189 +
190 + defp ordered(low, high) when low <= high, do: {:ok, [low, high]}
191 + defp ordered(_low, _high), do: :error
192 +
193 + defp put_fleet_marks(changeset) do
194 + case fetch_change(changeset, :fleet_marks_raw) do
195 + :error ->
196 + changeset
197 +
198 + {:ok, text} ->
199 + case parse_fleet_marks(text) do
200 + {:ok, marks} ->
201 + put_change(changeset, :fleet_marks, marks)
202 +
203 + {:error, line} ->
204 + add_error(
205 + changeset,
206 + :fleet_marks_raw,
207 + "expected \"Route, Route: low-high, low-high\", got: #{line}"
208 + )
209 + end
210 + end
82 211 end
83 212
84 213 @doc """
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/form_component.html.heex
+20 −0
@@ -153,6 +153,26 @@
153 153 subway sends <code>098600_5..S03R</code> where the schedule says
154 154 <code>ASP26GEN-1038-Sunday-00_098600_5..S03R</code>. Leave off unless the
155 155 feed tester says otherwise; matching on a suffix is looser than matching
156 + <div class="form-control">
157 + <.input
158 + field={gtfs_form[:fleet_marks_raw]}
159 + type="textarea"
160 + label="Fleet marks"
161 + rows="4"
162 + value={gtfs_form[:fleet_marks_raw].value ||
163 + RoomSanctum.Configuration.Configs.GTFS.fleet_marks_text(gtfs_form.data)}
164 + placeholder={RoomSanctum.Configuration.Configs.GTFS.mbta_new_trains_text()}
165 + />
166 + <label class="label">
167 + <span class="label-text-alt opacity-70">
168 + Optional. One <code>Route, Route: low-high, low-high</code> per line, by
169 + route id and car number. An arrival whose train has any car in range is
170 + marked <code>marked: true</code> in the Plus feed, as is each such car.
171 + Needs a vehicle positions feed. The placeholder is the MBTA's new
172 + trains, as transitmatters' new-train-tracker counts them.
173 + </span>
174 + </label>
175 + </div>
156 176 exactly.
157 177 </span>
158 178 </label>
added apps/room_sanctum/test/room_sanctum/gtfs_fleet_marks_test.exs
+55 −0
@@ -0,0 +1,55 @@
1 +defmodule RoomSanctum.GtfsFleetMarksTest do
2 + use ExUnit.Case, async: true
3 +
4 + alias Ecto.Changeset
5 + alias RoomSanctum.Configuration.Configs.GTFS
6 +
7 + defp change(attrs) do
8 + GTFS.changeset(%GTFS{}, Map.merge(%{"url" => "http://x/gtfs.zip", "tz" => "UTC"}, attrs))
9 + end
10 +
11 + test "the MBTA preset parses, a route list expanding to each route" do
12 + assert {:ok, marks} = GTFS.parse_fleet_marks(GTFS.mbta_new_trains_text())
13 +
14 + assert marks["Red"] == [[1900, 2151]]
15 + assert marks["Green-E"] == [[3900, 3923], [4001, 4102]]
16 + assert marks["749"] == [[1294, 1299]]
17 + assert map_size(marks) == 12
18 + end
19 +
20 + test "it reads back as the lines it went in as" do
21 + {:ok, marks} = GTFS.parse_fleet_marks(GTFS.mbta_new_trains_text())
22 + text = GTFS.fleet_marks_text(%{fleet_marks: marks})
23 +
24 + assert {:ok, ^marks} = GTFS.parse_fleet_marks(text)
25 + assert length(String.split(text, "\n")) == 4
26 + end
27 +
28 + test "a single car number is a range of one" do
29 + assert {:ok, %{"Red" => [[1901, 1901]]}} = GTFS.parse_fleet_marks("Red: 1901")
30 + assert GTFS.fleet_marks_text(%{fleet_marks: %{"Red" => [[1901, 1901]]}}) == "Red: 1901"
31 + end
32 +
33 + test "malformed lines are refused" do
34 + for line <- ["Red", "Red:", ": 1-2", "Red: 9-1", "Red: abc"] do
35 + assert {:error, ^line} = GTFS.parse_fleet_marks(line)
36 + end
37 + end
38 +
39 + test "the changeset stores the map, refuses a bad line, and leaves it alone when unmentioned" do
40 + assert %{"Red" => [[1, 2]]} =
41 + change(%{"fleet_marks_raw" => "Red: 1-2"}) |> Changeset.get_field(:fleet_marks)
42 +
43 + refute change(%{"fleet_marks_raw" => "nope"}).valid?
44 +
45 + existing = %GTFS{fleet_marks: %{"Red" => [[1, 2]]}}
46 +
47 + assert %{"Red" => [[1, 2]]} =
48 + GTFS.changeset(existing, %{"url" => "http://x", "tz" => "UTC"})
49 + |> Changeset.get_field(:fleet_marks)
50 +
51 + assert %{} ==
52 + GTFS.changeset(existing, %{"url" => "http://x", "tz" => "UTC", "fleet_marks_raw" => ""})
53 + |> Changeset.get_field(:fleet_marks)
54 + end
55 +end

Parents: edb5c66