neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
Log in to mark this repository.
Add a Plus reading of GTFS data, and the realtime it was hiding
9c789e8 · Gabriel Morell · 2026-09-01 17:17
Message
{commit_body(@commit)}
Files changed
modified
apps/room_gtfs/lib/room_gtfs/feed_tester.ex
+207
−1
| 80 | 80 | result |
| 81 | 81 | |> Map.put(:entities, counts) |
| 82 | 82 | |> Map.put(:total, length(feed.entity)) |
| 83 | + |> Map.put(:capabilities, capabilities(feed)) | |
| 83 | 84 | |> check_empty(feed, counts, kinds) |
| 85 | + |> check_incrementality(feed) | |
| 86 | + |> check_stop_identification(feed) | |
| 84 | 87 | |> check_age(feed) |
| 85 | 88 | |> check_extensions(feed) |
| 86 | 89 | |> check_agency(feed, source) |
| 88 | 91 | |> finish() |
| 89 | 92 | end |
| 90 | 93 | |
| 91 | − defp check_empty(result, _feed, counts, kinds) do | |
| 94 | + defp check_empty(result, feed, counts, kinds) do | |
| 92 | 95 | cond do |
| 96 | + # A differential feed saying nothing has changed since the last poll is a | |
| 97 | + # feed working correctly, not an empty one. | |
| 98 | + result.total == 0 and differential?(feed) -> | |
| 99 | + add(result, :info, "no changes in this delta") | |
| 100 | + | |
| 93 | 101 | result.total == 0 -> |
| 94 | 102 | add(result, :warn, "decoded, but carries no entities at all right now") |
| 95 | 103 |
| 112 | 120 | end |
| 113 | 121 | end |
| 114 | 122 | |
| 123 | + # A differential feed is a stream of edits to a feed held open, not a | |
| 124 | + # snapshot -- so the message in front of you is not the feed, and its entity | |
| 125 | + # count says nothing about how much this source is carrying. Worth saying | |
| 126 | + # out loud, because every other number on this page is read as a total. | |
| 127 | + @doc false | |
| 128 | + def check_incrementality(result, feed) do | |
| 129 | + deleted = Enum.count(feed.entity, & &1.is_deleted) | |
| 130 | + | |
| 131 | + case feed.header && feed.header.incrementality do | |
| 132 | + :DIFFERENTIAL -> | |
| 133 | + add( | |
| 134 | + result, | |
| 135 | + :info, | |
| 136 | + "differential: each message carries only what changed, and entities are held " <> | |
| 137 | + "between polls rather than replaced. What is listed here is this delta" <> | |
| 138 | + deleted_note(deleted) <> ", not the whole feed" | |
| 139 | + ) | |
| 140 | + | |
| 141 | + _otherwise when deleted > 0 -> | |
| 142 | + add( | |
| 143 | + result, | |
| 144 | + :warn, | |
| 145 | + "#{deleted} entities are marked deleted in a feed that calls itself a full " <> | |
| 146 | + "dataset -- a full dataset is already the whole truth, so these say nothing " <> | |
| 147 | + "and are being kept" | |
| 148 | + ) | |
| 149 | + | |
| 150 | + _otherwise -> | |
| 151 | + result | |
| 152 | + end | |
| 153 | + end | |
| 154 | + | |
| 155 | + defp deleted_note(0), do: "" | |
| 156 | + defp deleted_note(1), do: ", one of them a deletion" | |
| 157 | + defp deleted_note(n), do: ", #{n} of them deletions" | |
| 158 | + | |
| 159 | + # GTFS-RT lets a stop update name its stop by id, by position in the trip, or | |
| 160 | + # both. Live arrivals are matched by stop id alone, so a feed that only counts | |
| 161 | + # positions matches nothing at all: no live times, no error, no clue. The | |
| 162 | + # static schedule is what would have to be consulted to turn a sequence back | |
| 163 | + # into a stop, and nothing does that. | |
| 164 | + @doc false | |
| 165 | + def check_stop_identification(result, feed) do | |
| 166 | + stus = | |
| 167 | + feed.entity | |
| 168 | + |> Enum.filter(& &1.trip_update) | |
| 169 | + |> Enum.flat_map(& &1.trip_update.stop_time_update) | |
| 170 | + | |
| 171 | + total = length(stus) | |
| 172 | + missing = Enum.count(stus, &(&1.stop_id in [nil, ""])) | |
| 173 | + | |
| 174 | + cond do | |
| 175 | + total == 0 or missing == 0 -> | |
| 176 | + result | |
| 177 | + | |
| 178 | + missing == total -> | |
| 179 | + add( | |
| 180 | + result, | |
| 181 | + :error, | |
| 182 | + "identifies stops only by their position in the trip, never by stop id -- " <> | |
| 183 | + "arrivals are matched on stop id, so nothing in this feed will ever match" | |
| 184 | + ) | |
| 185 | + | |
| 186 | + true -> | |
| 187 | + add( | |
| 188 | + result, | |
| 189 | + :warn, | |
| 190 | + "#{missing} of #{total} stop updates carry no stop id, only a position in the " <> | |
| 191 | + "trip -- those cannot be matched to a stop and are skipped" | |
| 192 | + ) | |
| 193 | + end | |
| 194 | + end | |
| 195 | + | |
| 196 | + defp differential?(feed) do | |
| 197 | + feed.header && feed.header.incrementality == :DIFFERENTIAL | |
| 198 | + end | |
| 199 | + | |
| 115 | 200 | defp check_age(result, feed) do |
| 116 | 201 | case feed.header && feed.header.timestamp do |
| 117 | 202 | nil -> |
| 394 | 479 | Regex.replace(~r/([?&](?:api_key|apikey|key|token)=)([^&]{4})[^&]*/i, url, "\\1\\2****") |
| 395 | 480 | end |
| 396 | 481 | |
| 482 | + @doc """ | |
| 483 | + Which optional GTFS-RT fields this feed actually fills in. | |
| 484 | + | |
| 485 | + Everything past a trip id and a time is optional in GTFS-RT, and feeds vary | |
| 486 | + enormously in how much of it they send -- MBTA populates per-carriage | |
| 487 | + occupancy and skipped stops but never a delay or a track assignment; another | |
| 488 | + agency does the reverse. Nothing announces this. You find out by building a | |
| 489 | + view against a field and seeing an empty column, which is a slow way to learn | |
| 490 | + that the feed was never going to say. | |
| 491 | + | |
| 492 | + Reported as `%{field => %{present: n, of: total}}` per feed kind, counted over | |
| 493 | + what actually arrived rather than declared anywhere. A field nothing reports | |
| 494 | + is still listed, with a zero: "this feed does not do that" is the answer | |
| 495 | + worth having. | |
| 496 | + """ | |
| 497 | + def capabilities(feed) do | |
| 498 | + tus = feed.entity |> Enum.filter(& &1.trip_update) |> Enum.map(& &1.trip_update) | |
| 499 | + vps = feed.entity |> Enum.filter(& &1.vehicle) |> Enum.map(& &1.vehicle) | |
| 500 | + alerts = feed.entity |> Enum.filter(& &1.alert) |> Enum.map(& &1.alert) | |
| 501 | + stus = Enum.flat_map(tus, & &1.stop_time_update) | |
| 502 | + events = stus |> Enum.map(&(&1.arrival || &1.departure)) |> Enum.reject(&is_nil/1) | |
| 503 | + carriages = Enum.flat_map(vps, &(&1.multi_carriage_details || [])) | |
| 504 | + informed = Enum.flat_map(alerts, &(&1.informed_entity || [])) | |
| 505 | + | |
| 506 | + %{} | |
| 507 | + |> put_caps(stus, [ | |
| 508 | + {"live occupancy (per stop)", &set?(&1.departure_occupancy_status)}, | |
| 509 | + {"skipped stops", &(&1.schedule_relationship == :SKIPPED)}, | |
| 510 | + {"track assignment", &(&1.stop_time_properties && set?(&1.stop_time_properties.assigned_stop_id))}, | |
| 511 | + {"per-stop headsign", &(&1.stop_time_properties && set?(&1.stop_time_properties.stop_headsign))} | |
| 512 | + ]) | |
| 513 | + |> put_caps(events, [ | |
| 514 | + {"delay", &(&1.delay != nil)}, | |
| 515 | + {"uncertainty", &(&1.uncertainty != nil)} | |
| 516 | + ]) | |
| 517 | + |> put_caps(tus, [ | |
| 518 | + {"cancelled / added trips", &(&1.trip.schedule_relationship not in [nil, :SCHEDULED])} | |
| 519 | + ]) | |
| 520 | + |> put_caps(vps, [ | |
| 521 | + {"occupancy", &set?(&1.occupancy_status)}, | |
| 522 | + {"occupancy %", &(&1.occupancy_percentage != nil and &1.occupancy_percentage >= 0)}, | |
| 523 | + {"per-carriage occupancy", &(&1.multi_carriage_details != [])}, | |
| 524 | + {"congestion level", &set?(&1.congestion_level)}, | |
| 525 | + {"position", &(&1.position != nil)}, | |
| 526 | + {"bearing", &(&1.position && &1.position.bearing != nil)}, | |
| 527 | + {"vehicle label", &(&1.vehicle && set?(&1.vehicle.label))} | |
| 528 | + ]) | |
| 529 | + |> put_caps(carriages, [ | |
| 530 | + {"carriage occupancy", &set?(&1.occupancy_status)} | |
| 531 | + ]) | |
| 532 | + |> put_caps(alerts, [ | |
| 533 | + {"alert headline", &said?(&1.header_text)}, | |
| 534 | + {"alert detail", &said?(&1.description_text)}, | |
| 535 | + {"alert effect", &set?(&1.effect)}, | |
| 536 | + {"alert cause", &set?(&1.cause)}, | |
| 537 | + {"alert severity", &set?(&1.severity_level)}, | |
| 538 | + {"alert period", &(&1.active_period != [])}, | |
| 539 | + {"alert link", &said?(&1.url)}, | |
| 540 | + {"alert image", &(&1.image != nil)}, | |
| 541 | + {"alert audio text", &said?(&1.tts_header_text)} | |
| 542 | + ]) | |
| 543 | + # An alert only reaches a query if it names something the query knows | |
| 544 | + # about, and `query_alerts/3` matches on stop, route, or the whole agency. | |
| 545 | + # A feed that targets only by route will never raise an alert on a query | |
| 546 | + # that is watching one stop of it -- which is a property of the feed, not a | |
| 547 | + # bug, and is invisible until you go looking for the alert that never came. | |
| 548 | + |> put_caps(informed, [ | |
| 549 | + {"targets stops", &said_id?(&1.stop_id)}, | |
| 550 | + {"targets routes", &said_id?(&1.route_id)}, | |
| 551 | + {"targets trips", &(&1.trip != nil)}, | |
| 552 | + {"agency-wide", &agency_wide?/1} | |
| 553 | + ]) | |
| 554 | + end | |
| 555 | + | |
| 556 | + # An entity naming nothing but an agency -- or naming nothing at all -- is an | |
| 557 | + # alert about everything the source carries. | |
| 558 | + defp agency_wide?(entity) do | |
| 559 | + not said_id?(entity.stop_id) and not said_id?(entity.route_id) and entity.trip == nil and | |
| 560 | + entity.route_type == nil | |
| 561 | + end | |
| 562 | + | |
| 563 | + defp said_id?(id), do: id not in [nil, ""] | |
| 564 | + | |
| 565 | + # A translated string is a list of translations, so "present" means it | |
| 566 | + # actually carries one with something in it -- an empty envelope is a field | |
| 567 | + # the publisher wired up and never filled. | |
| 568 | + defp said?(nil), do: false | |
| 569 | + | |
| 570 | + defp said?(%{translation: translations}) do | |
| 571 | + Enum.any?(translations, &(String.trim(&1.text || "") != "")) | |
| 572 | + end | |
| 573 | + | |
| 574 | + defp said?(_other), do: false | |
| 575 | + | |
| 576 | + # A field only counts as reported when it says something. NO_DATA_AVAILABLE | |
| 577 | + # is the feed declining to answer, which is what an absent field means too. | |
| 578 | + defp set?(nil), do: false | |
| 579 | + defp set?(""), do: false | |
| 580 | + defp set?(:NO_DATA_AVAILABLE), do: false | |
| 581 | + # The proto defaults for an alert's cause, effect and severity. A field left | |
| 582 | + # alone decodes to these, so they mean the publisher said nothing, not that | |
| 583 | + # the answer is "unknown". | |
| 584 | + defp set?(:UNKNOWN_CAUSE), do: false | |
| 585 | + defp set?(:UNKNOWN_EFFECT), do: false | |
| 586 | + defp set?(:UNKNOWN_SEVERITY), do: false | |
| 587 | + defp set?(_), do: true | |
| 588 | + | |
| 589 | + # Counted only where there was something to count: a feed with no vehicles in | |
| 590 | + # it has nothing to say about bearings, and listing them all as 0 of 0 would | |
| 591 | + # read as a feed that refuses to give them. | |
| 592 | + defp put_caps(acc, [], _checks), do: acc | |
| 593 | + | |
| 594 | + defp put_caps(acc, items, checks) do | |
| 595 | + total = length(items) | |
| 596 | + | |
| 597 | + Enum.reduce(checks, acc, fn {label, check}, inner -> | |
| 598 | + present = Enum.count(items, fn item -> check.(item) == true end) | |
| 599 | + Map.put(inner, label, %{present: present, of: total}) | |
| 600 | + end) | |
| 601 | + end | |
| 602 | + | |
| 397 | 603 | defp add(result, severity, message), |
| 398 | 604 | do: Map.update!(result, :findings, &(&1 ++ [{severity, message}])) |
| 399 | 605 |
modified
apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+296
−1
| 156 | 156 | res |
| 157 | 157 | |
| 158 | 158 | rtvals -> |
| 159 | + # How full the vehicle is may be reported per stop in the trip | |
| 160 | + # update, or only by the vehicle's own position report -- so the | |
| 161 | + # positions are fetched once here to fall back on. | |
| 162 | + vehicles = occupancy_vehicles(id, inst.config, trips) | |
| 163 | + | |
| 159 | 164 | res |
| 160 | 165 | |> Enum.map(fn x -> |
| 161 | 166 | case Enum.find(rtvals, fn v -> |
| 167 | 172 | val -> |
| 168 | 173 | stu = val.trip_update.stop_time_update |
| 169 | 174 | time_event = stu && (stu.arrival || stu.departure) |
| 175 | + | |
| 176 | + x = | |
| 177 | + x | |
| 178 | + |> merge_occupancy(occupancy_for(inst.config, x.trip_id, stu, vehicles)) | |
| 179 | + |> merge_schedule_relationship(val.trip_update, stu) | |
| 180 | + |> merge_stop_properties(stu) | |
| 181 | + | |
| 170 | 182 | case time_event do |
| 171 | 183 | nil -> |
| 172 | 184 | x |
| 179 | 191 | end |
| 180 | 192 | end |
| 181 | 193 | end) |
| 194 | + |> resolve_assigned_platforms(id) | |
| 195 | + end | |
| 196 | + end | |
| 197 | + end | |
| 198 | + | |
| 199 | + # The vehicle positions for the trips calling at a stop, or [] for a source | |
| 200 | + # that publishes none. Occupancy is the only thing wanted from them here, and | |
| 201 | + # a source with no vehicle feed still gets whatever the trip updates carry. | |
| 202 | + defp occupancy_vehicles(id, config, trips) do | |
| 203 | + case RoomGtfs.Worker.RT.rt_url_for(config, :vp) do | |
| 204 | + nil -> | |
| 205 | + [] | |
| 206 | + | |
| 207 | + _val -> | |
| 208 | + try do | |
| 209 | + get_current_vehicle_positions(id, trips) | |
| 210 | + catch | |
| 211 | + :exit, _ -> [] | |
| 182 | 212 | end |
| 183 | 213 | end |
| 184 | 214 | end |
| 185 | 215 | |
| 216 | + # An arrival says nothing about how full it is unless the feed did. | |
| 217 | + defp merge_occupancy(arrival, nil), do: arrival | |
| 218 | + | |
| 219 | + defp merge_occupancy(arrival, %{status: status, pct: pct, carriages: carriages}) do | |
| 220 | + arrival | |
| 221 | + |> maybe_put(:occupancy, status) | |
| 222 | + |> maybe_put(:occupancy_pct, pct) | |
| 223 | + |> maybe_put(:carriages, presence(carriages)) | |
| 224 | + end | |
| 225 | + | |
| 226 | + # A key nothing was reported for is left off rather than set to nil, so that | |
| 227 | + # "the feed did not say" and "the feed said nothing is there" stay | |
| 228 | + # distinguishable further down. | |
| 229 | + defp maybe_put(map, _key, nil), do: map | |
| 230 | + defp maybe_put(map, key, value), do: Map.put(map, key, value) | |
| 231 | + | |
| 232 | + defp presence([]), do: nil | |
| 233 | + defp presence(""), do: nil | |
| 234 | + defp presence(value), do: value | |
| 235 | + | |
| 236 | + @doc false | |
| 237 | + # How full a vehicle is, as a GTFS-RT OccupancyStatus atom and, where the | |
| 238 | + # feed says so, a percentage. The trip update may report the status per stop; | |
| 239 | + # otherwise the vehicle's own position report says it for the whole trip, and | |
| 240 | + # only that carries a percentage. NO_DATA_AVAILABLE is the feed declining to | |
| 241 | + # answer, so it reads the same as an absent field -- NOT_ACCEPTING_PASSENGERS | |
| 242 | + # is a real answer and is kept. | |
| 243 | + def occupancy_for(config, scheduled_trip_id, stu, vehicles) do | |
| 244 | + vehicle = | |
| 245 | + Enum.find(vehicles, fn v -> | |
| 246 | + v.trip_id && trip_id_match?(config, scheduled_trip_id, v.trip_id) | |
| 247 | + end) | |
| 248 | + | |
| 249 | + status = | |
| 250 | + occupancy_status(stu && stu.departure_occupancy_status) || | |
| 251 | + occupancy_status(vehicle && Map.get(vehicle, :occupancy_status)) | |
| 252 | + | |
| 253 | + carriages = carriage_occupancy(vehicle) | |
| 254 | + | |
| 255 | + case {status, carriages} do | |
| 256 | + {nil, []} -> | |
| 257 | + nil | |
| 258 | + | |
| 259 | + {status, carriages} -> | |
| 260 | + %{ | |
| 261 | + status: status, | |
| 262 | + pct: occupancy_pct(vehicle && Map.get(vehicle, :occupancy_percentage)), | |
| 263 | + carriages: carriages | |
| 264 | + } | |
| 265 | + end | |
| 266 | + end | |
| 267 | + | |
| 268 | + @doc false | |
| 269 | + # A train reporting each carriage separately is the difference between "the | |
| 270 | + # train is full" and "walk to the back". Kept in the sequence the feed gives, | |
| 271 | + # because that is the order they are coupled in and the whole point is which | |
| 272 | + # end of the platform to stand at. A carriage the feed said nothing about | |
| 273 | + # stays in the list -- a gap in the middle of a train is itself worth | |
| 274 | + # drawing -- it simply has no occupancy. | |
| 275 | + def carriage_occupancy(nil), do: [] | |
| 276 | + | |
| 277 | + def carriage_occupancy(vehicle) do | |
| 278 | + vehicle | |
| 279 | + |> Map.get(:carriages) | |
| 280 | + |> List.wrap() | |
| 281 | + |> Enum.sort_by(&(&1.sequence || 0)) | |
| 282 | + |> Enum.map(fn c -> | |
| 283 | + %{ | |
| 284 | + id: c.id, | |
| 285 | + label: presence(c.label), | |
| 286 | + sequence: c.sequence, | |
| 287 | + occupancy: occupancy_status(c.occupancy_status), | |
| 288 | + occupancy_pct: occupancy_pct(c.occupancy_percentage) | |
| 289 | + } | |
| 290 | + end) | |
| 291 | + end | |
| 292 | + | |
| 293 | + # -1 is the protobuf default standing in for "not reported". | |
| 294 | + defp occupancy_pct(pct) when is_integer(pct) and pct >= 0, do: pct | |
| 295 | + defp occupancy_pct(_), do: nil | |
| 296 | + | |
| 297 | + @doc false | |
| 298 | + # Two different ways an arrival is not going to happen, and they are not the | |
| 299 | + # same thing: the trip may be cancelled outright, or it may be running fine | |
| 300 | + # and simply not calling here. Both used to draw as an ordinary arrival, | |
| 301 | + # which is the one wrong thing a departure board can do. | |
| 302 | + # | |
| 303 | + # SCHEDULED is the proto default and means "as published", so it is left off | |
| 304 | + # rather than recorded -- only a departure from the schedule is news. | |
| 305 | + def merge_schedule_relationship(arrival, trip_update, stu) do | |
| 306 | + trip = trip_update && trip_update.trip | |
| 307 | + | |
| 308 | + arrival | |
| 309 | + |> maybe_put(:trip_status, relationship(trip && trip.schedule_relationship)) | |
| 310 | + |> maybe_put(:stop_status, relationship(stu && stu.schedule_relationship)) | |
| 311 | + end | |
| 312 | + | |
| 313 | + defp relationship(nil), do: nil | |
| 314 | + defp relationship(:SCHEDULED), do: nil | |
| 315 | + defp relationship(rel) when is_atom(rel), do: rel | |
| 316 | + # A feed may send a number outside the enum this was generated against. | |
| 317 | + defp relationship(_), do: nil | |
| 318 | + | |
| 319 | + @doc false | |
| 320 | + # What the feed says about this call specifically, rather than about the trip: | |
| 321 | + # the track it has been given, and a headsign that overrides the trip's own. | |
| 322 | + # A short-turn announces itself here -- the trip still says "Downtown" while | |
| 323 | + # this particular call says it terminates early. | |
| 324 | + def merge_stop_properties(arrival, nil), do: arrival | |
| 325 | + | |
| 326 | + def merge_stop_properties(arrival, stu) do | |
| 327 | + props = Map.get(stu, :stop_time_properties) | |
| 328 | + | |
| 329 | + arrival | |
| 330 | + |> maybe_put(:assigned_stop_id, props && presence(props.assigned_stop_id)) | |
| 331 | + |> maybe_put(:stop_headsign, props && presence(props.stop_headsign)) | |
| 332 | + end | |
| 333 | + | |
| 334 | + # A track is announced as a stop id, which is an internal string nobody wants | |
| 335 | + # to read -- "127N" rather than "Track 3". The static feed knows what that | |
| 336 | + # stop is called, so the ids are resolved together in one query rather than | |
| 337 | + # one per arrival, and an id the static feed has never heard of is shown as | |
| 338 | + # itself rather than dropped. | |
| 339 | + defp resolve_assigned_platforms(arrivals, source_id) do | |
| 340 | + ids = | |
| 341 | + arrivals | |
| 342 | + |> Enum.map(&Map.get(&1, :assigned_stop_id)) | |
| 343 | + |> Enum.reject(&is_nil/1) | |
| 344 | + |> Enum.uniq() | |
| 345 | + | |
| 346 | + case ids do | |
| 347 | + [] -> | |
| 348 | + arrivals | |
| 349 | + | |
| 350 | + ids -> | |
| 351 | + stops = Storage.get_stops_by_ids(source_id, ids) | |
| 352 | + | |
| 353 | + Enum.map(arrivals, fn a -> | |
| 354 | + case Map.get(a, :assigned_stop_id) do | |
| 355 | + nil -> a | |
| 356 | + id -> Map.put(a, :assigned_platform, platform_label(Map.get(stops, id)) || id) | |
| 357 | + end | |
| 358 | + end) | |
| 359 | + end | |
| 360 | + end | |
| 361 | + | |
| 362 | + defp platform_label(nil), do: nil | |
| 363 | + | |
| 364 | + defp platform_label(stop) do | |
| 365 | + presence(stop.platform_code) || presence(stop.platform_name) || presence(stop.stop_name) | |
| 366 | + end | |
| 367 | + | |
| 368 | + defp occupancy_status(nil), do: nil | |
| 369 | + defp occupancy_status(:NO_DATA_AVAILABLE), do: nil | |
| 370 | + defp occupancy_status(status) when is_atom(status), do: status | |
| 371 | + | |
| 372 | + # A feed may send a number outside the enum the generated module knows; that | |
| 373 | + # is not an occupancy anyone can draw, so it reads as no answer. | |
| 374 | + defp occupancy_status(status) when is_integer(status) do | |
| 375 | + try do | |
| 376 | + TransitRealtime.VehiclePosition.OccupancyStatus.key(status) |> occupancy_status() | |
| 377 | + rescue | |
| 378 | + _ -> nil | |
| 379 | + end | |
| 380 | + end | |
| 381 | + | |
| 186 | 382 | # etc |
| 187 | 383 | def init(opts) do |
| 188 | 384 | Periodic.start_link( |
| 473 | 669 | end) |
| 474 | 670 | |> Enum.map(fn alert -> |
| 475 | 671 | route_id = Enum.find_value(alert.informed_entity, fn e -> e.route_id end) |
| 672 | + | |
| 476 | 673 | %{ |
| 477 | 674 | effect: alert.effect |> to_string(), |
| 478 | 675 | cause: alert.cause |> to_string(), |
| 479 | 676 | header: get_translation(alert.header_text), |
| 480 | 677 | description: get_translation(alert.description_text), |
| 481 | 678 | route_id: route_id, |
| 679 | + severity: alert.severity_level |> to_string(), | |
| 680 | + # Whether the alert names this stop, as opposed to reaching it by | |
| 681 | + # naming the line it is on. "Elevator out here" and "delays on the | |
| 682 | + # Red Line" are both true of the stop and are not the same news. | |
| 683 | + stop_specific: | |
| 684 | + Enum.any?(alert.informed_entity, fn e -> e.stop_id != nil and e.stop_id == stop end) | |
| 482 | 685 | } |
| 483 | 686 | end) |
| 484 | 687 | end |
| 605 | 808 | |
| 606 | 809 | def vehicle_positions_from(_feed, _stops), do: [] |
| 607 | 810 | |
| 811 | + # The occupancy of each carriage, as the feed sent it -- the enum values are | |
| 812 | + # left raw here, the same as the vehicle's own occupancy_status, and are | |
| 813 | + # normalised once where they are read. | |
| 814 | + defp carriage_details(v) do | |
| 815 | + v | |
| 816 | + |> Map.get(:multi_carriage_details) | |
| 817 | + |> List.wrap() | |
| 818 | + |> Enum.map(fn c -> | |
| 819 | + %{ | |
| 820 | + id: c.id, | |
| 821 | + label: c.label, | |
| 822 | + sequence: c.carriage_sequence, | |
| 823 | + occupancy_status: c.occupancy_status, | |
| 824 | + occupancy_percentage: c.occupancy_percentage | |
| 825 | + } | |
| 826 | + end) | |
| 827 | + end | |
| 828 | + | |
| 608 | 829 | defp place_vehicle(v, stops) do |
| 609 | 830 | base = %{ |
| 610 | 831 | vehicle_id: v.vehicle && v.vehicle.id, |
| 612 | 833 | route_id: v.trip && v.trip.route_id, |
| 613 | 834 | stop_id: v.stop_id, |
| 614 | 835 | current_status: v.current_status, |
| 836 | + occupancy_status: v.occupancy_status, | |
| 837 | + occupancy_percentage: v.occupancy_percentage, | |
| 838 | + carriages: carriage_details(v), | |
| 615 | 839 | timestamp: v.timestamp |
| 616 | 840 | } |
| 617 | 841 |
| 901 | 1125 | # that means "the same URL is in all three fields by mistake", and |
| 902 | 1126 | # would say it about a correct configuration. |
| 903 | 1127 | sliced = slice(feed, entity_field(kind)) |
| 1128 | + | |
| 1129 | + # Health records the delta, not the running total: what this poll | |
| 1130 | + # actually received is the honest reading of whether the feed is | |
| 1131 | + # alive, and a differential feed reporting "nothing changed" has | |
| 1132 | + # genuinely received nothing. | |
| 904 | 1133 | record_health(acc, kind, url, {:ok, sliced}, took) |
| 905 | − store_feed(acc, kind, sliced) | |
| 1134 | + store_feed(acc, kind, merge_feed(current_feed(acc, kind), sliced)) | |
| 906 | 1135 | end) |
| 907 | 1136 | |
| 908 | 1137 | {:error, error} -> |
| 980 | 1209 | %{feed | entity: Enum.filter(feed.entity, &Map.get(&1, field))} |
| 981 | 1210 | end |
| 982 | 1211 | |
| 1212 | + defp current_feed(state, :sa), do: state.rt_sa | |
| 1213 | + defp current_feed(state, :tu), do: state.rt_tu | |
| 1214 | + defp current_feed(state, :vp), do: state.rt_vp | |
| 1215 | + | |
| 1216 | + @doc """ | |
| 1217 | + What to hold after a poll, given what was held before it. | |
| 1218 | + | |
| 1219 | + GTFS-RT publishes in one of two modes, and they mean opposite things by the | |
| 1220 | + same message. A **full dataset** is a snapshot: what arrived is the whole | |
| 1221 | + truth and replaces whatever was held. A **differential** feed is a stream of | |
| 1222 | + edits against a feed held open -- each message carries only what changed, | |
| 1223 | + entities are keyed by `FeedEntity.id`, and `is_deleted` means drop this one. | |
| 1224 | + | |
| 1225 | + Replacing on every poll, which is what used to happen, is right for the first | |
| 1226 | + and silently wrong for the second: everything not restated in the newest | |
| 1227 | + delta disappears, so a differential source would show a handful of vehicles | |
| 1228 | + and no explanation. | |
| 1229 | + | |
| 1230 | + Order is kept stable -- entities already held stay where they were and new | |
| 1231 | + ones join the end -- so that a feed does not appear to reshuffle itself on | |
| 1232 | + every poll. | |
| 1233 | + | |
| 1234 | + A differential message with nothing held yet is applied to an empty feed, | |
| 1235 | + which is the only sensible reading: there is no earlier state to edit, so | |
| 1236 | + what arrives is what there is, minus anything it deletes. | |
| 1237 | + """ | |
| 1238 | + def merge_feed(previous, incoming) do | |
| 1239 | + if differential?(incoming) do | |
| 1240 | + %{incoming | entity: merge_entities(entities(previous), incoming.entity)} | |
| 1241 | + else | |
| 1242 | + incoming | |
| 1243 | + end | |
| 1244 | + end | |
| 1245 | + | |
| 1246 | + defp differential?(feed) do | |
| 1247 | + feed.header && feed.header.incrementality == :DIFFERENTIAL | |
| 1248 | + end | |
| 1249 | + | |
| 1250 | + defp entities(nil), do: [] | |
| 1251 | + defp entities(feed), do: feed.entity | |
| 1252 | + | |
| 1253 | + defp merge_entities(held, delta) do | |
| 1254 | + initial = {held |> Enum.map(& &1.id) |> Enum.reverse(), Map.new(held, &{&1.id, &1})} | |
| 1255 | + | |
| 1256 | + # Folded in order, so a message that deletes an entity and then sends it | |
| 1257 | + # again -- or sends it twice -- ends up with what it said last. | |
| 1258 | + {order, current} = | |
| 1259 | + Enum.reduce(delta, initial, fn entity, {order, current} -> | |
| 1260 | + cond do | |
| 1261 | + entity.is_deleted -> {order, Map.delete(current, entity.id)} | |
| 1262 | + Map.has_key?(current, entity.id) -> {order, Map.put(current, entity.id, entity)} | |
| 1263 | + true -> {[entity.id | order], Map.put(current, entity.id, entity)} | |
| 1264 | + end | |
| 1265 | + end) | |
| 1266 | + | |
| 1267 | + order | |
| 1268 | + |> Enum.reverse() | |
| 1269 | + |> Enum.uniq() | |
| 1270 | + |> Enum.flat_map(fn id -> | |
| 1271 | + case Map.fetch(current, id) do | |
| 1272 | + {:ok, entity} -> [entity] | |
| 1273 | + :error -> [] | |
| 1274 | + end | |
| 1275 | + end) | |
| 1276 | + end | |
| 1277 | + | |
| 983 | 1278 | |
| 984 | 1279 | def handle_call({:query_realtime, trips, stop}, _from, state) do |
| 985 | 1280 | # IO.inspect(trips) |
added
apps/room_gtfs/test/differential_feed_test.exs
+145
−0
| 1 | +defmodule RoomGtfs.DifferentialFeedTest do | |
| 2 | + @moduledoc """ | |
| 3 | + Holding a differential feed open. | |
| 4 | + | |
| 5 | + GTFS-RT publishes in two modes that mean opposite things by the same message. | |
| 6 | + A full dataset is a snapshot and replaces what was held. A differential feed | |
| 7 | + is a stream of edits: each message carries only what changed, keyed by entity | |
| 8 | + id, and `is_deleted` means drop it. Replacing on every poll -- correct for the | |
| 9 | + first -- silently discards everything the newest delta did not restate. | |
| 10 | + """ | |
| 11 | + use ExUnit.Case, async: true | |
| 12 | + | |
| 13 | + alias RoomGtfs.Worker.RT | |
| 14 | + alias TransitRealtime, as: T | |
| 15 | + | |
| 16 | + defp feed(entities, incrementality) do | |
| 17 | + %T.FeedMessage{ | |
| 18 | + header: %T.FeedHeader{gtfs_realtime_version: "2.0", incrementality: incrementality}, | |
| 19 | + entity: entities | |
| 20 | + } | |
| 21 | + end | |
| 22 | + | |
| 23 | + defp full(entities), do: feed(entities, :FULL_DATASET) | |
| 24 | + defp delta(entities), do: feed(entities, :DIFFERENTIAL) | |
| 25 | + | |
| 26 | + # The label rides along on the trip id so an updated entity is telling apart | |
| 27 | + # from the one it replaced. | |
| 28 | + defp vehicle(id, label \\ "a") do | |
| 29 | + %T.FeedEntity{ | |
| 30 | + id: id, | |
| 31 | + vehicle: %T.VehiclePosition{trip: %T.TripDescriptor{trip_id: label}} | |
| 32 | + } | |
| 33 | + end | |
| 34 | + | |
| 35 | + defp deletion(id), do: %T.FeedEntity{id: id, is_deleted: true} | |
| 36 | + | |
| 37 | + defp ids(feed), do: Enum.map(feed.entity, & &1.id) | |
| 38 | + defp label(feed, id), do: Enum.find(feed.entity, &(&1.id == id)).vehicle.trip.trip_id | |
| 39 | + | |
| 40 | + describe "a full dataset" do | |
| 41 | + test "replaces whatever was held" do | |
| 42 | + held = full([vehicle("a"), vehicle("b")]) | |
| 43 | + | |
| 44 | + assert RT.merge_feed(held, full([vehicle("c")])) |> ids() == ["c"] | |
| 45 | + end | |
| 46 | + | |
| 47 | + test "replaces a differential history too, because it is the whole truth" do | |
| 48 | + held = delta([vehicle("a"), vehicle("b")]) | |
| 49 | + | |
| 50 | + assert RT.merge_feed(held, full([vehicle("c")])) |> ids() == ["c"] | |
| 51 | + end | |
| 52 | + end | |
| 53 | + | |
| 54 | + describe "a differential message" do | |
| 55 | + test "adds to what is held rather than replacing it" do | |
| 56 | + held = delta([vehicle("a"), vehicle("b")]) | |
| 57 | + | |
| 58 | + assert RT.merge_feed(held, delta([vehicle("c")])) |> ids() == ["a", "b", "c"] | |
| 59 | + end | |
| 60 | + | |
| 61 | + test "updates an entity in place, keeping its position" do | |
| 62 | + held = delta([vehicle("a", "old"), vehicle("b", "old")]) | |
| 63 | + | |
| 64 | + merged = RT.merge_feed(held, delta([vehicle("a", "new")])) | |
| 65 | + | |
| 66 | + assert ids(merged) == ["a", "b"] | |
| 67 | + assert label(merged, "a") == "new" | |
| 68 | + assert label(merged, "b") == "old" | |
| 69 | + end | |
| 70 | + | |
| 71 | + test "a deletion drops the entity" do | |
| 72 | + held = delta([vehicle("a"), vehicle("b"), vehicle("c")]) | |
| 73 | + | |
| 74 | + assert RT.merge_feed(held, delta([deletion("b")])) |> ids() == ["a", "c"] | |
| 75 | + end | |
| 76 | + | |
| 77 | + test "deletions are not themselves kept as entities" do | |
| 78 | + merged = RT.merge_feed(delta([vehicle("a")]), delta([deletion("z")])) | |
| 79 | + | |
| 80 | + assert ids(merged) == ["a"] | |
| 81 | + end | |
| 82 | + | |
| 83 | + test "nothing changed is not the feed emptying itself" do | |
| 84 | + held = delta([vehicle("a"), vehicle("b")]) | |
| 85 | + | |
| 86 | + assert RT.merge_feed(held, delta([])) |> ids() == ["a", "b"] | |
| 87 | + end | |
| 88 | + | |
| 89 | + test "the newest header wins, so freshness is the delta's" do | |
| 90 | + held = delta([vehicle("a")]) | |
| 91 | + incoming = %{delta([]) | header: %T.FeedHeader{gtfs_realtime_version: "2.0", timestamp: 99}} | |
| 92 | + | |
| 93 | + assert RT.merge_feed(held, incoming).header.timestamp == 99 | |
| 94 | + end | |
| 95 | + end | |
| 96 | + | |
| 97 | + describe "within one message" do | |
| 98 | + test "the last word wins for a repeated entity" do | |
| 99 | + merged = RT.merge_feed(delta([]), delta([vehicle("a", "first"), vehicle("a", "second")])) | |
| 100 | + | |
| 101 | + assert label(merged, "a") == "second" | |
| 102 | + end | |
| 103 | + | |
| 104 | + test "deleted and then sent again means sent" do | |
| 105 | + held = delta([vehicle("a", "old")]) | |
| 106 | + | |
| 107 | + merged = RT.merge_feed(held, delta([deletion("a"), vehicle("a", "back")])) | |
| 108 | + | |
| 109 | + assert ids(merged) == ["a"] | |
| 110 | + assert label(merged, "a") == "back" | |
| 111 | + end | |
| 112 | + | |
| 113 | + test "sent and then deleted means deleted" do | |
| 114 | + merged = RT.merge_feed(delta([]), delta([vehicle("a"), deletion("a")])) | |
| 115 | + | |
| 116 | + assert ids(merged) == [] | |
| 117 | + end | |
| 118 | + end | |
| 119 | + | |
| 120 | + describe "nothing held yet" do | |
| 121 | + test "the first delta is applied to an empty feed" do | |
| 122 | + assert RT.merge_feed(nil, delta([vehicle("a")])) |> ids() == ["a"] | |
| 123 | + end | |
| 124 | + | |
| 125 | + test "a first delta deleting something it never sent drops it" do | |
| 126 | + assert RT.merge_feed(nil, delta([vehicle("a"), deletion("b")])) |> ids() == ["a"] | |
| 127 | + end | |
| 128 | + | |
| 129 | + test "a first full dataset is simply the feed" do | |
| 130 | + assert RT.merge_feed(nil, full([vehicle("a")])) |> ids() == ["a"] | |
| 131 | + end | |
| 132 | + end | |
| 133 | + | |
| 134 | + test "several polls accumulate the way the feed intends" do | |
| 135 | + merged = | |
| 136 | + nil | |
| 137 | + |> RT.merge_feed(delta([vehicle("a"), vehicle("b")])) | |
| 138 | + |> RT.merge_feed(delta([vehicle("c")])) | |
| 139 | + |> RT.merge_feed(delta([deletion("a"), vehicle("b", "moved")])) | |
| 140 | + |> RT.merge_feed(delta([])) | |
| 141 | + | |
| 142 | + assert ids(merged) == ["b", "c"] | |
| 143 | + assert label(merged, "b") == "moved" | |
| 144 | + end | |
| 145 | +end |
added
apps/room_gtfs/test/feed_capabilities_test.exs
+282
−0
| 1 | +defmodule RoomGtfs.FeedCapabilitiesTest do | |
| 2 | + @moduledoc """ | |
| 3 | + What a realtime feed actually fills in. | |
| 4 | + | |
| 5 | + Everything past a trip id and a time is optional in GTFS-RT, and a feed says | |
| 6 | + nothing about which parts it bothers with. Two agencies both "support | |
| 7 | + GTFS-RT" and share almost no optional fields. | |
| 8 | + """ | |
| 9 | + use ExUnit.Case, async: true | |
| 10 | + | |
| 11 | + alias RoomGtfs.FeedTester | |
| 12 | + alias TransitRealtime, as: T | |
| 13 | + | |
| 14 | + defp feed(entities) do | |
| 15 | + %T.FeedMessage{header: %T.FeedHeader{gtfs_realtime_version: "2.0"}, entity: entities} | |
| 16 | + end | |
| 17 | + | |
| 18 | + defp vehicle(attrs) do | |
| 19 | + %T.FeedEntity{ | |
| 20 | + id: "e", | |
| 21 | + vehicle: struct(%T.VehiclePosition{trip: %T.TripDescriptor{trip_id: "t1"}}, attrs) | |
| 22 | + } | |
| 23 | + end | |
| 24 | + | |
| 25 | + defp trip_update(stus, trip_attrs \\ []) do | |
| 26 | + %T.FeedEntity{ | |
| 27 | + id: "e", | |
| 28 | + trip_update: %T.TripUpdate{ | |
| 29 | + trip: struct(%T.TripDescriptor{trip_id: "t1"}, trip_attrs), | |
| 30 | + stop_time_update: stus | |
| 31 | + } | |
| 32 | + } | |
| 33 | + end | |
| 34 | + | |
| 35 | + defp stu(attrs) do | |
| 36 | + struct(%T.TripUpdate.StopTimeUpdate{stop_id: "s1", schedule_relationship: :SCHEDULED}, attrs) | |
| 37 | + end | |
| 38 | + | |
| 39 | + describe "vehicle positions" do | |
| 40 | + test "a field the feed fills in is counted" do | |
| 41 | + caps = | |
| 42 | + FeedTester.capabilities( | |
| 43 | + feed([ | |
| 44 | + vehicle(occupancy_status: :FULL), | |
| 45 | + vehicle(occupancy_status: :EMPTY), | |
| 46 | + vehicle(occupancy_status: nil) | |
| 47 | + ]) | |
| 48 | + ) | |
| 49 | + | |
| 50 | + assert caps["occupancy"] == %{present: 2, of: 3} | |
| 51 | + end | |
| 52 | + | |
| 53 | + test "a field nothing reports is still listed, at zero" do | |
| 54 | + caps = FeedTester.capabilities(feed([vehicle(occupancy_status: :FULL)])) | |
| 55 | + | |
| 56 | + # The answer worth having: this feed does not do congestion. | |
| 57 | + assert caps["congestion level"] == %{present: 0, of: 1} | |
| 58 | + end | |
| 59 | + | |
| 60 | + test "NO_DATA_AVAILABLE is the feed declining, not reporting" do | |
| 61 | + caps = FeedTester.capabilities(feed([vehicle(occupancy_status: :NO_DATA_AVAILABLE)])) | |
| 62 | + | |
| 63 | + assert caps["occupancy"] == %{present: 0, of: 1} | |
| 64 | + end | |
| 65 | + | |
| 66 | + test "-1 is the occupancy percentage default, not a percentage" do | |
| 67 | + caps = | |
| 68 | + FeedTester.capabilities( | |
| 69 | + feed([vehicle(occupancy_percentage: -1), vehicle(occupancy_percentage: 40)]) | |
| 70 | + ) | |
| 71 | + | |
| 72 | + assert caps["occupancy %"] == %{present: 1, of: 2} | |
| 73 | + end | |
| 74 | + | |
| 75 | + test "carriages are counted as carriages, not as vehicles" do | |
| 76 | + car = fn status -> | |
| 77 | + %T.VehiclePosition.CarriageDetails{carriage_sequence: 1, occupancy_status: status} | |
| 78 | + end | |
| 79 | + | |
| 80 | + caps = | |
| 81 | + FeedTester.capabilities( | |
| 82 | + feed([ | |
| 83 | + vehicle(multi_carriage_details: [car.(:EMPTY), car.(:NO_DATA_AVAILABLE)]), | |
| 84 | + vehicle(multi_carriage_details: []) | |
| 85 | + ]) | |
| 86 | + ) | |
| 87 | + | |
| 88 | + assert caps["per-carriage occupancy"] == %{present: 1, of: 2} | |
| 89 | + assert caps["carriage occupancy"] == %{present: 1, of: 2} | |
| 90 | + end | |
| 91 | + | |
| 92 | + test "a bearing is counted against the vehicles, not the positions" do | |
| 93 | + pos = %T.Position{latitude: 1.0, longitude: 2.0} | |
| 94 | + | |
| 95 | + caps = | |
| 96 | + FeedTester.capabilities( | |
| 97 | + feed([ | |
| 98 | + vehicle(position: %{pos | bearing: 90.0}), | |
| 99 | + vehicle(position: pos), | |
| 100 | + vehicle(position: nil) | |
| 101 | + ]) | |
| 102 | + ) | |
| 103 | + | |
| 104 | + assert caps["position"] == %{present: 2, of: 3} | |
| 105 | + assert caps["bearing"] == %{present: 1, of: 3} | |
| 106 | + end | |
| 107 | + end | |
| 108 | + | |
| 109 | + describe "trip updates" do | |
| 110 | + test "skipped stops are counted over stops, cancellations over trips" do | |
| 111 | + caps = | |
| 112 | + FeedTester.capabilities( | |
| 113 | + feed([ | |
| 114 | + trip_update([stu(schedule_relationship: :SKIPPED), stu([])], | |
| 115 | + schedule_relationship: :SCHEDULED | |
| 116 | + ), | |
| 117 | + trip_update([stu([])], schedule_relationship: :CANCELED) | |
| 118 | + ]) | |
| 119 | + ) | |
| 120 | + | |
| 121 | + assert caps["skipped stops"] == %{present: 1, of: 3} | |
| 122 | + assert caps["cancelled / added trips"] == %{present: 1, of: 2} | |
| 123 | + end | |
| 124 | + | |
| 125 | + test "a trip saying nothing about its relationship is running as published" do | |
| 126 | + caps = FeedTester.capabilities(feed([trip_update([stu([])], schedule_relationship: nil)])) | |
| 127 | + | |
| 128 | + assert caps["cancelled / added trips"] == %{present: 0, of: 1} | |
| 129 | + end | |
| 130 | + | |
| 131 | + test "delay and uncertainty are counted over the time events" do | |
| 132 | + with_delay = stu(arrival: %T.TripUpdate.StopTimeEvent{time: 1, delay: 60}) | |
| 133 | + with_unc = stu(departure: %T.TripUpdate.StopTimeEvent{time: 1, uncertainty: 30}) | |
| 134 | + bare = stu(arrival: %T.TripUpdate.StopTimeEvent{time: 1}) | |
| 135 | + | |
| 136 | + caps = FeedTester.capabilities(feed([trip_update([with_delay, with_unc, bare])])) | |
| 137 | + | |
| 138 | + assert caps["delay"] == %{present: 1, of: 3} | |
| 139 | + assert caps["uncertainty"] == %{present: 1, of: 3} | |
| 140 | + end | |
| 141 | + | |
| 142 | + test "a track assignment and a per-stop headsign" do | |
| 143 | + props = %T.TripUpdate.StopTimeUpdate.StopTimeProperties{ | |
| 144 | + assigned_stop_id: "127N", | |
| 145 | + stop_headsign: "" | |
| 146 | + } | |
| 147 | + | |
| 148 | + caps = | |
| 149 | + FeedTester.capabilities( | |
| 150 | + feed([trip_update([stu(stop_time_properties: props), stu([])])]) | |
| 151 | + ) | |
| 152 | + | |
| 153 | + assert caps["track assignment"] == %{present: 1, of: 2} | |
| 154 | + # An empty string is not a headsign. | |
| 155 | + assert caps["per-stop headsign"] == %{present: 0, of: 2} | |
| 156 | + end | |
| 157 | + end | |
| 158 | + | |
| 159 | + describe "alerts" do | |
| 160 | + defp translated(text), do: %T.TranslatedString{translation: [%T.TranslatedString.Translation{text: text, language: "en"}]} | |
| 161 | + | |
| 162 | + defp alert(attrs) do | |
| 163 | + %T.FeedEntity{id: "e", alert: struct(%T.Alert{}, attrs)} | |
| 164 | + end | |
| 165 | + | |
| 166 | + defp selector(attrs), do: struct(%T.EntitySelector{}, attrs) | |
| 167 | + | |
| 168 | + test "a headline the publisher actually filled in" do | |
| 169 | + caps = | |
| 170 | + FeedTester.capabilities( | |
| 171 | + feed([ | |
| 172 | + alert(header_text: translated("Red Line delays")), | |
| 173 | + alert(header_text: nil) | |
| 174 | + ]) | |
| 175 | + ) | |
| 176 | + | |
| 177 | + assert caps["alert headline"] == %{present: 1, of: 2} | |
| 178 | + end | |
| 179 | + | |
| 180 | + test "an empty envelope is not a headline" do | |
| 181 | + caps = | |
| 182 | + FeedTester.capabilities( | |
| 183 | + feed([ | |
| 184 | + alert(header_text: %T.TranslatedString{translation: []}), | |
| 185 | + alert(header_text: translated(" ")) | |
| 186 | + ]) | |
| 187 | + ) | |
| 188 | + | |
| 189 | + # A field wired up and never filled reads the same as one never wired up. | |
| 190 | + assert caps["alert headline"] == %{present: 0, of: 2} | |
| 191 | + end | |
| 192 | + | |
| 193 | + test "the proto defaults for cause, effect and severity mean nothing was said" do | |
| 194 | + caps = | |
| 195 | + FeedTester.capabilities( | |
| 196 | + feed([ | |
| 197 | + alert(cause: :UNKNOWN_CAUSE, effect: :UNKNOWN_EFFECT, severity_level: :UNKNOWN_SEVERITY), | |
| 198 | + alert(cause: :STRIKE, effect: :DETOUR, severity_level: :SEVERE) | |
| 199 | + ]) | |
| 200 | + ) | |
| 201 | + | |
| 202 | + assert caps["alert cause"] == %{present: 1, of: 2} | |
| 203 | + assert caps["alert effect"] == %{present: 1, of: 2} | |
| 204 | + assert caps["alert severity"] == %{present: 1, of: 2} | |
| 205 | + end | |
| 206 | + | |
| 207 | + test "periods, links and images" do | |
| 208 | + caps = | |
| 209 | + FeedTester.capabilities( | |
| 210 | + feed([ | |
| 211 | + alert( | |
| 212 | + active_period: [%T.TimeRange{start: 1}], | |
| 213 | + url: translated("https://example.test/a"), | |
| 214 | + image: %T.TranslatedImage{localized_image: []} | |
| 215 | + ), | |
| 216 | + alert([]) | |
| 217 | + ]) | |
| 218 | + ) | |
| 219 | + | |
| 220 | + assert caps["alert period"] == %{present: 1, of: 2} | |
| 221 | + assert caps["alert link"] == %{present: 1, of: 2} | |
| 222 | + assert caps["alert image"] == %{present: 1, of: 2} | |
| 223 | + end | |
| 224 | + | |
| 225 | + test "what an alert is aimed at, counted over the things it names" do | |
| 226 | + caps = | |
| 227 | + FeedTester.capabilities( | |
| 228 | + feed([ | |
| 229 | + alert( | |
| 230 | + informed_entity: [ | |
| 231 | + selector(stop_id: "s1", route_id: "Red"), | |
| 232 | + selector(route_id: "Orange"), | |
| 233 | + selector(trip: %T.TripDescriptor{trip_id: "t1"}), | |
| 234 | + selector(agency_id: "1") | |
| 235 | + ] | |
| 236 | + ) | |
| 237 | + ]) | |
| 238 | + ) | |
| 239 | + | |
| 240 | + assert caps["targets stops"] == %{present: 1, of: 4} | |
| 241 | + assert caps["targets routes"] == %{present: 2, of: 4} | |
| 242 | + assert caps["targets trips"] == %{present: 1, of: 4} | |
| 243 | + # Naming an agency and nothing else is an alert about everything. | |
| 244 | + assert caps["agency-wide"] == %{present: 1, of: 4} | |
| 245 | + end | |
| 246 | + | |
| 247 | + test "a selector naming nothing at all is agency-wide too" do | |
| 248 | + caps = FeedTester.capabilities(feed([alert(informed_entity: [selector([])])])) | |
| 249 | + | |
| 250 | + assert caps["agency-wide"] == %{present: 1, of: 1} | |
| 251 | + end | |
| 252 | + | |
| 253 | + test "an alert naming nothing is not asked what it targets" do | |
| 254 | + caps = FeedTester.capabilities(feed([alert(header_text: translated("x"))])) | |
| 255 | + | |
| 256 | + assert Map.has_key?(caps, "alert headline") | |
| 257 | + refute Map.has_key?(caps, "targets stops") | |
| 258 | + end | |
| 259 | + end | |
| 260 | + | |
| 261 | + describe "what is not there" do | |
| 262 | + test "a feed with no alerts says nothing about alerts" do | |
| 263 | + caps = FeedTester.capabilities(feed([trip_update([stu([])])])) | |
| 264 | + | |
| 265 | + refute Map.has_key?(caps, "alert headline") | |
| 266 | + end | |
| 267 | + | |
| 268 | + test "a feed with no vehicles says nothing about vehicles" do | |
| 269 | + caps = FeedTester.capabilities(feed([trip_update([stu([])])])) | |
| 270 | + | |
| 271 | + # Rather than 0 of 0 everywhere, which would read as a feed refusing to | |
| 272 | + # give them. | |
| 273 | + refute Map.has_key?(caps, "occupancy") | |
| 274 | + refute Map.has_key?(caps, "congestion level") | |
| 275 | + assert Map.has_key?(caps, "skipped stops") | |
| 276 | + end | |
| 277 | + | |
| 278 | + test "an empty feed reports nothing at all" do | |
| 279 | + assert FeedTester.capabilities(feed([])) == %{} | |
| 280 | + end | |
| 281 | + end | |
| 282 | +end |
added
apps/room_gtfs/test/feed_tester_findings_test.exs
+117
−0
| 1 | +defmodule RoomGtfs.FeedTesterFindingsTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The two ways a realtime feed can be wrong without saying so. | |
| 4 | + | |
| 5 | + Both decode cleanly, report plausible numbers, and quietly match nothing -- | |
| 6 | + which is the failure this tester exists to name. | |
| 7 | + """ | |
| 8 | + use ExUnit.Case, async: true | |
| 9 | + | |
| 10 | + alias RoomGtfs.FeedTester | |
| 11 | + alias TransitRealtime, as: T | |
| 12 | + | |
| 13 | + defp feed(entities, header_attrs \\ []) do | |
| 14 | + %T.FeedMessage{ | |
| 15 | + header: struct(%T.FeedHeader{gtfs_realtime_version: "2.0"}, header_attrs), | |
| 16 | + entity: entities | |
| 17 | + } | |
| 18 | + end | |
| 19 | + | |
| 20 | + defp trip_update(stus, attrs \\ []) do | |
| 21 | + struct( | |
| 22 | + %T.FeedEntity{ | |
| 23 | + id: "e", | |
| 24 | + trip_update: %T.TripUpdate{ | |
| 25 | + trip: %T.TripDescriptor{trip_id: "t1"}, | |
| 26 | + stop_time_update: stus | |
| 27 | + } | |
| 28 | + }, | |
| 29 | + attrs | |
| 30 | + ) | |
| 31 | + end | |
| 32 | + | |
| 33 | + defp stu(attrs), do: struct(%T.TripUpdate.StopTimeUpdate{}, attrs) | |
| 34 | + | |
| 35 | + defp messages(result), do: Enum.map(result.findings, fn {_sev, m} -> m end) |> Enum.join(" | ") | |
| 36 | + defp severities(result), do: Enum.map(result.findings, fn {sev, _m} -> sev end) | |
| 37 | + | |
| 38 | + # The checks run over a decoded feed; fetching it and comparing it to the | |
| 39 | + # static schedule are separate concerns, so these are exercised on their own. | |
| 40 | + defp check(feed) do | |
| 41 | + %{findings: []} | |
| 42 | + |> FeedTester.check_incrementality(feed) | |
| 43 | + |> FeedTester.check_stop_identification(feed) | |
| 44 | + end | |
| 45 | + | |
| 46 | + describe "differential feeds" do | |
| 47 | + test "a full dataset says nothing about it" do | |
| 48 | + result = check(feed([trip_update([stu(stop_id: "s1")])], incrementality: :FULL_DATASET)) | |
| 49 | + | |
| 50 | + refute messages(result) =~ "DIFFERENTIAL" | |
| 51 | + end | |
| 52 | + | |
| 53 | + test "a differential feed is handled, so it is a note rather than a fault" do | |
| 54 | + result = check(feed([trip_update([stu(stop_id: "s1")])], incrementality: :DIFFERENTIAL)) | |
| 55 | + | |
| 56 | + assert messages(result) =~ "differential" | |
| 57 | + assert messages(result) =~ "held between polls" | |
| 58 | + assert :info in severities(result) | |
| 59 | + refute :error in severities(result) | |
| 60 | + end | |
| 61 | + | |
| 62 | + test "it says the count on the page is a delta, not a total" do | |
| 63 | + entities = [ | |
| 64 | + trip_update([stu(stop_id: "s1")], is_deleted: true), | |
| 65 | + trip_update([stu(stop_id: "s1")]) | |
| 66 | + ] | |
| 67 | + | |
| 68 | + result = check(feed(entities, incrementality: :DIFFERENTIAL)) | |
| 69 | + | |
| 70 | + assert messages(result) =~ "one of them a deletion" | |
| 71 | + assert messages(result) =~ "not the whole feed" | |
| 72 | + end | |
| 73 | + | |
| 74 | + test "deletions in a feed calling itself whole are still worth flagging" do | |
| 75 | + entities = [trip_update([stu(stop_id: "s1")], is_deleted: true)] | |
| 76 | + | |
| 77 | + result = check(feed(entities, incrementality: :FULL_DATASET)) | |
| 78 | + | |
| 79 | + assert messages(result) =~ "marked deleted" | |
| 80 | + assert :warn in severities(result) | |
| 81 | + end | |
| 82 | + end | |
| 83 | + | |
| 84 | + describe "stops named only by position" do | |
| 85 | + test "a feed carrying stop ids is fine" do | |
| 86 | + result = check(feed([trip_update([stu(stop_id: "s1", stop_sequence: 4)])])) | |
| 87 | + | |
| 88 | + refute messages(result) =~ "position in the trip" | |
| 89 | + end | |
| 90 | + | |
| 91 | + test "no stop ids anywhere is an error: nothing will ever match" do | |
| 92 | + result = check(feed([trip_update([stu(stop_sequence: 4), stu(stop_sequence: 5)])])) | |
| 93 | + | |
| 94 | + assert messages(result) =~ "never by stop id" | |
| 95 | + assert :error in severities(result) | |
| 96 | + end | |
| 97 | + | |
| 98 | + test "some missing is a warning naming how many" do | |
| 99 | + result = check(feed([trip_update([stu(stop_id: "s1"), stu(stop_sequence: 5)])])) | |
| 100 | + | |
| 101 | + assert messages(result) =~ "1 of 2 stop updates carry no stop id" | |
| 102 | + assert :warn in severities(result) | |
| 103 | + end | |
| 104 | + | |
| 105 | + test "an empty string is not a stop id" do | |
| 106 | + result = check(feed([trip_update([stu(stop_id: "", stop_sequence: 5)])])) | |
| 107 | + | |
| 108 | + assert messages(result) =~ "never by stop id" | |
| 109 | + end | |
| 110 | + | |
| 111 | + test "a feed with no trip updates is not asked about stops" do | |
| 112 | + vehicle = %T.FeedEntity{id: "e", vehicle: %T.VehiclePosition{}} | |
| 113 | + | |
| 114 | + refute messages(check(feed([vehicle]))) =~ "position in the trip" | |
| 115 | + end | |
| 116 | + end | |
| 117 | +end |
added
apps/room_gtfs/test/occupancy_test.exs
+169
−0
Click to load diff…
modified
apps/room_sanctum/assets/js/hooks/leaflet_map.js
+10
−4
Click to load diff…
modified
apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+26
−4
Click to load diff…
modified
apps/room_sanctum/assets/js/leaflet/leaflet-marker.js
+1
−1
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+42
−0
Click to load diff…
added
apps/room_sanctum/lib/room_sanctum/condensers/plus_mqtt.ex
+141
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum/configuration.ex
+17
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum/storage.ex
+42
−6
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+7
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/public.ex
+6
−1
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/public.html.heex
+6
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/show.ex
+20
−3
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/show.html.heex
+46
−12
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+19
−3
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+27
−11
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+30
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+21
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/show.ex
+129
−5
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/show.html.heex
+90
−15
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/helpers/map_data.ex
+1
−1
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/live_preview.ex
+356
−4
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/router.ex
+5
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum/basic_condenser_test.exs
+113
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum/plus_condenser_test.exs
+194
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum_web/components/occupancy_test.exs
+210
−0
Click to load diff…
modified
apps/room_sanctum/test/room_sanctum_web/components/vehicle_popup_test.exs
+41
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum_web/live/add_vision_to_pythiae_test.exs
+176
−0
Click to load diff…
modified
apps/room_sanctum/test/room_sanctum_web/live/keryx_live_test.exs
+5
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum_web/live/pythiae_public_plus_test.exs
+66
−0
Click to load diff…
modified
apps/room_sanctum/test/room_sanctum_web/live/vision_preview_test.exs
+62
−3
Click to load diff…
Parents: ec561a7