From 6f99840d662f5ecba995cb292302117c62b6b35e Mon Sep 17 00:00:00 2001 From: sloane Date: Thu, 19 Sep 2024 10:13:01 -0400 Subject: [PATCH] feat: track last train of the day (#2191) Tracks trips marked as `last_trip: true` and recent departures from all route-direction-stop tuples. Fetches TripUpdates and VehiclePositions data from concentrate S3 bucket to populate two caches (one for last trips and one for a list of recent departures from a stop on a given route going a direction). Both caches have TTLs of one hour so old trips and departures will fall off over time. Recent departures are a special case because they are tracked as a list of recent departures. Departures (tuples of trip_id and departure time in unix epoch) are upserted into the cache key for a given RDS. During update departures more than one hour in the past are removed from the list for that RDS. Caches are reset every day after 3:30am EST. > [!NOTE] > The realtime_signs code uses `If-Modified-Since` and `Last-Modified` > headers presumably in an attempt to reduce unnecessary API requests. I > added support for that behavior into this flow as well but because the > requested data changes so frequently it almost _always_ had to fetch > all of the data. I've removed that functionality to keep the code > simpler given that there is almost no benefit to tracking those time > stamps / previous responses. **Asana Task:** [Start tracking LTOTD state for use by screens][task] [task]: https://app.asana.com/0/1185117109217413/1207574972639078/f --- .envrc.template | 6 + config/config.exs | 4 + config/runtime.exs | 12 +- config/test.exs | 4 + lib/screens/application.ex | 1 + lib/screens/last_trip.ex | 41 + lib/screens/last_trip/cache.ex | 84 ++ lib/screens/last_trip/cache/last_trips.ex | 11 + .../last_trip/cache/recent_departures.ex | 13 + lib/screens/last_trip/parser.ex | 53 ++ lib/screens/last_trip/poller.ex | 86 ++ lib/screens/last_trip/trip_updates.ex | 10 + lib/screens/last_trip/trip_updates/gtfs.ex | 17 + lib/screens/last_trip/trip_updates/noop.ex | 9 + lib/screens/last_trip/vehicle_positions.ex | 10 + .../last_trip/vehicle_positions/gtfs.ex | 17 + .../last_trip/vehicle_positions/noop.ex | 9 + test/fixtures/TripUpdates_enhanced.json | 799 ++++++++++++++++++ test/fixtures/VehiclePositions_enhanced.json | 69 ++ test/screens/last_trip/cache_test.exs | 25 + test/screens/last_trip/parser_test.exs | 37 + 21 files changed, 1316 insertions(+), 1 deletion(-) create mode 100644 lib/screens/last_trip.ex create mode 100644 lib/screens/last_trip/cache.ex create mode 100644 lib/screens/last_trip/cache/last_trips.ex create mode 100644 lib/screens/last_trip/cache/recent_departures.ex create mode 100644 lib/screens/last_trip/parser.ex create mode 100644 lib/screens/last_trip/poller.ex create mode 100644 lib/screens/last_trip/trip_updates.ex create mode 100644 lib/screens/last_trip/trip_updates/gtfs.ex create mode 100644 lib/screens/last_trip/trip_updates/noop.ex create mode 100644 lib/screens/last_trip/vehicle_positions.ex create mode 100644 lib/screens/last_trip/vehicle_positions/gtfs.ex create mode 100644 lib/screens/last_trip/vehicle_positions/noop.ex create mode 100644 test/fixtures/TripUpdates_enhanced.json create mode 100644 test/fixtures/VehiclePositions_enhanced.json create mode 100644 test/screens/last_trip/cache_test.exs create mode 100644 test/screens/last_trip/parser_test.exs diff --git a/.envrc.template b/.envrc.template index f75aaa670..b4a717608 100644 --- a/.envrc.template +++ b/.envrc.template @@ -9,3 +9,9 @@ export API_V3_URL="https://api-v3.mbta.com" # export AWS_SECRET_ACCESS_KEY= export SECRET_KEY_BASE="local_secret_key_base_at_least_64_bytes_________________________________" + +# # Should point to the Enhanced JSON feeds. Default values are provided, only +# # set these if you need something different. +# # https://github.com/mbta/gtfs-documentation/blob/master/reference/gtfs-realtime.md#enhanced-json-feeds +# export TRIP_UPDATES_URL= +# export VEHICLE_POSITIONS_URL= diff --git a/config/config.exs b/config/config.exs index 2a45747ea..02930f5e0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -483,6 +483,10 @@ config :screens, Screens.ScreenApiResponseCache, gc_interval: :timer.hours(1), allocated_memory: 250_000_000 +config :screens, Screens.LastTrip, + trip_updates_adapter: Screens.LastTrip.TripUpdates.GTFS, + vehicle_positions_adapter: Screens.LastTrip.VehiclePositions.GTFS + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/runtime.exs b/config/runtime.exs index 5c1fec053..941643e4e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -7,7 +7,17 @@ import Config unless config_env() == :test do config :screens, api_v3_url: System.get_env("API_V3_URL", "https://api-v3.mbta.com/"), - api_v3_key: System.get_env("API_V3_KEY") + api_v3_key: System.get_env("API_V3_KEY"), + trip_updates_url: + System.get_env( + "TRIP_UPDATES_URL", + "https://cdn.mbta.com/realtime/TripUpdates_enhanced.json" + ), + vehicle_positions_url: + System.get_env( + "VEHICLE_POSITIONS_URL", + "https://cdn.mbta.com/realtime/VehiclePositions_enhanced.json" + ) end if config_env() == :prod do diff --git a/config/test.exs b/config/test.exs index 21d6a15c9..a301b550a 100644 --- a/config/test.exs +++ b/config/test.exs @@ -164,3 +164,7 @@ config :screens, Screens.V2.ScreenData, parameters_module: Screens.V2.ScreenData.MockParameters config :screens, Screens.V2.CandidateGenerator.DupNew, stop_module: Screens.Stops.MockStop + +config :screens, Screens.LastTrip, + trip_updates_adapter: Screens.LastTrip.TripUpdates.Noop, + vehicle_positions_adapter: Screens.LastTrip.VehiclePositions.Noop diff --git a/lib/screens/application.ex b/lib/screens/application.ex index 1aba7fb38..5b77aab5e 100644 --- a/lib/screens/application.ex +++ b/lib/screens/application.ex @@ -29,6 +29,7 @@ defmodule Screens.Application do # Task supervisor for parallel running of candidate generator variants {Task.Supervisor, name: Screens.V2.ScreenData.ParallelRunSupervisor}, {Screens.ScreenApiResponseCache, []}, + Screens.LastTrip, {Phoenix.PubSub, name: ScreensWeb.PubSub}, ScreensWeb.Endpoint ] diff --git a/lib/screens/last_trip.ex b/lib/screens/last_trip.ex new file mode 100644 index 000000000..4bca30343 --- /dev/null +++ b/lib/screens/last_trip.ex @@ -0,0 +1,41 @@ +defmodule Screens.LastTrip do + @moduledoc """ + Supervisor and public interface for fetching information about the last trips + of the day (AKA Last Train of the Day, LTOTD). + """ + alias Screens.LastTrip.Cache + alias Screens.LastTrip.Poller + use Supervisor + + @spec start_link(any()) :: Supervisor.on_start() + def start_link(_) do + Supervisor.start_link(__MODULE__, [], name: __MODULE__) + end + + @impl true + def init(_) do + children = [ + Cache.LastTrips, + Cache.RecentDepartures, + Poller + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + @spec last_trip?(trip_id :: String.t()) :: boolean() + defdelegate last_trip?(trip_id), to: Cache + + @spec service_ended_for_rds?(Cache.rds()) :: boolean() + def service_ended_for_rds?({_r, _d, _s} = rds, now_fn \\ &DateTime.utc_now/0) do + now_unix = now_fn.() |> DateTime.to_unix() + + rds + |> Cache.get_recent_departures() + |> Enum.any?(fn {trip_id, departure_time_unix} -> + seconds_since_departure = now_unix - departure_time_unix + + seconds_since_departure > 3 and last_trip?(trip_id) + end) + end +end diff --git a/lib/screens/last_trip/cache.ex b/lib/screens/last_trip/cache.ex new file mode 100644 index 000000000..72f0ebce1 --- /dev/null +++ b/lib/screens/last_trip/cache.ex @@ -0,0 +1,84 @@ +defmodule Screens.LastTrip.Cache do + @moduledoc """ + Public interface into the caches that back last trip (LTOTD) tracking + """ + alias Screens.LastTrip.Cache.LastTrips + alias Screens.LastTrip.Cache.RecentDepartures + + @type rds :: {route_id :: String.t(), direction_id :: 0 | 1, stop_id :: String.t()} + @type departing_trip :: {trip_id :: String.t(), departure_time_unix :: integer()} + + @last_trips_ttl :timer.hours(1) + @recent_departures_ttl :timer.hours(1) + + @spec update_last_trips( + last_trip_entries :: [{trip_id :: LastTrips.key(), last_trip? :: LastTrips.value()}] + ) :: + :ok + def update_last_trips(last_trips) do + LastTrips.put_all(last_trips, ttl: @last_trips_ttl) + + :ok + end + + @spec update_recent_departures(recent_departures :: %{rds() => [departing_trip()]}) :: :ok + def update_recent_departures(recent_departures, now_fn \\ &DateTime.utc_now/0) do + expiration = now_fn.() |> DateTime.add(-1, :hour) |> DateTime.to_unix() + + for {rds, departures} <- recent_departures do + RecentDepartures.update( + rds, + departures, + &merge_and_expire_departures(&1, departures, expiration), + ttl: @recent_departures_ttl + ) + end + + :ok + end + + def merge_and_expire_departures(existing_departures, departures, expiration) do + existing_departures = + existing_departures + |> only_latest_departures() + |> Map.new() + + departures = + departures + |> only_latest_departures() + |> Map.new() + + existing_departures + |> Map.merge(departures) + |> Enum.reject(fn {_, departure_time} -> departure_time <= expiration end) + end + + @spec last_trip?(trip_id :: String.t()) :: boolean() + def last_trip?(trip_id) do + LastTrips.get(trip_id) == true + end + + @spec get_recent_departures(rds()) :: [departing_trip()] + def get_recent_departures({_r, _d, _s} = rds) do + rds + |> RecentDepartures.get() + |> List.wrap() + end + + @spec reset() :: :ok + def reset do + LastTrips.delete_all() + RecentDepartures.delete_all() + + :ok + end + + defp only_latest_departures(departures) do + # Only take the latest departure time for each trip + departures + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Enum.map(fn {trip_id, departure_times} -> + {trip_id, Enum.max(departure_times)} + end) + end +end diff --git a/lib/screens/last_trip/cache/last_trips.ex b/lib/screens/last_trip/cache/last_trips.ex new file mode 100644 index 000000000..7d46c3919 --- /dev/null +++ b/lib/screens/last_trip/cache/last_trips.ex @@ -0,0 +1,11 @@ +defmodule Screens.LastTrip.Cache.LastTrips do + @moduledoc """ + Cache of Trip IDs (`t:key/0`) where `last_trip` was `true` (`t:value/0`). + """ + use Nebulex.Cache, + otp_app: :screens, + adapter: Nebulex.Adapters.Local + + @type key :: trip_id :: String.t() + @type value :: last_trip? :: true +end diff --git a/lib/screens/last_trip/cache/recent_departures.ex b/lib/screens/last_trip/cache/recent_departures.ex new file mode 100644 index 000000000..d0cc54ea0 --- /dev/null +++ b/lib/screens/last_trip/cache/recent_departures.ex @@ -0,0 +1,13 @@ +defmodule Screens.LastTrip.Cache.RecentDepartures do + @moduledoc """ + Cache of recent departures keyed by route-direction-stop tuple (`t:key/0`). + + Values are trip id departure time tuples (`t:value/0`). + """ + use Nebulex.Cache, + otp_app: :screens, + adapter: Nebulex.Adapters.Local + + @type key :: Screens.LastTrip.Cache.rds() + @type value :: [Screens.LastTrip.Cache.departing_trip()] +end diff --git a/lib/screens/last_trip/parser.ex b/lib/screens/last_trip/parser.ex new file mode 100644 index 000000000..fea45b3d8 --- /dev/null +++ b/lib/screens/last_trip/parser.ex @@ -0,0 +1,53 @@ +defmodule Screens.LastTrip.Parser do + @moduledoc """ + Functions to parse relevant data from TripUpdate and VehiclePositions maps. + + Used by `Screens.LastTrip.Poller`. + """ + alias Screens.LastTrip.Cache.RecentDepartures + + @spec get_running_trips(trip_updates_enhanced_json :: map()) :: [trip_update_json :: map()] + def get_running_trips(trip_updates_enhanced_json) do + trip_updates_enhanced_json["entity"] + |> Stream.map(& &1["trip_update"]) + |> Enum.reject(&(&1["trip"]["schedule_relationship"] == "CANCELED")) + end + + @spec get_last_trips(trip_updates_enhanced_json :: map()) :: [trip_id :: String.t()] + def get_last_trips(trip_updates_enhanced_json) do + trip_updates_enhanced_json + |> get_running_trips() + |> Enum.filter(&(&1["trip"]["last_trip"] == true)) + |> Enum.map(& &1["trip"]["trip_id"]) + end + + @spec get_recent_departures( + trip_updates_enhanced_json :: map(), + vehicle_positions_enhanced_json :: map() + ) :: %{RecentDepartures.key() => RecentDepartures.value()} + def get_recent_departures(trip_updates_enhanced_json, vehicle_positions_enhanced_json) do + vehicle_positions_by_id = + Map.new(vehicle_positions_enhanced_json["entity"], &{&1["id"], &1["vehicle"]}) + + running_trips = get_running_trips(trip_updates_enhanced_json) + + for %{"vehicle" => %{"id" => vehicle_id}} = trip <- running_trips, + stop_time_update <- trip["stop_time_update"] do + vehicle_position = vehicle_positions_by_id[vehicle_id] + + departure_time = stop_time_update["departure"]["time"] + + if vehicle_position["stop_id"] == stop_time_update["stop_id"] and + vehicle_position["current_status"] == "STOPPED_AT" and not is_nil(departure_time) do + rds = + {trip["trip"]["route_id"], trip["trip"]["direction_id"], stop_time_update["stop_id"]} + + trip_id = trip["trip"]["trip_id"] + + {rds, {trip_id, departure_time}} + end + end + |> Enum.reject(&is_nil/1) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + end +end diff --git a/lib/screens/last_trip/poller.ex b/lib/screens/last_trip/poller.ex new file mode 100644 index 000000000..55a6abe96 --- /dev/null +++ b/lib/screens/last_trip/poller.ex @@ -0,0 +1,86 @@ +defmodule Screens.LastTrip.Poller do + @moduledoc """ + GenServer that polls predictions to calculate the last trip of the day + """ + alias Screens.LastTrip.Cache + alias Screens.LastTrip.Parser + alias Screens.LastTrip.TripUpdates + alias Screens.LastTrip.VehiclePositions + use GenServer + + defstruct [:next_reset] + + @polling_interval :timer.seconds(1) + + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @impl true + def init(_) do + state = %__MODULE__{next_reset: next_reset()} + + send(self(), :poll) + + {:ok, state} + end + + @impl true + def handle_info(:poll, %__MODULE__{} = state) do + state = + if DateTime.after?(now(), state.next_reset) do + :ok = Cache.reset() + %{state | next_reset: next_reset()} + else + {:ok, + %{ + trip_updates: trip_updates, + vehicle_positions: vehicle_positions + }} = fetch_trip_updates_and_vehicle_positions() + + update_last_trips(trip_updates) + update_recent_departures(trip_updates, vehicle_positions) + + state + end + + Process.send_after(self(), :poll, @polling_interval) + + {:noreply, state} + end + + defp fetch_trip_updates_and_vehicle_positions do + with {:ok, %{status_code: 200, body: trip_updates}} <- TripUpdates.get(), + {:ok, %{status_code: 200, body: vehicle_positions}} <- VehiclePositions.get() do + {:ok, + %{ + trip_updates: trip_updates, + vehicle_positions: vehicle_positions + }} + end + end + + defp update_last_trips(trip_updates) do + trip_updates + |> Parser.get_last_trips() + |> Enum.map(&{&1, true}) + |> Cache.update_last_trips() + end + + defp update_recent_departures(trip_updates, vehicle_positions) do + trip_updates + |> Parser.get_recent_departures(vehicle_positions) + |> Cache.update_recent_departures() + end + + defp now(now_fn \\ &DateTime.utc_now/0) do + now_fn.() |> DateTime.shift_zone!("America/New_York") + end + + defp next_reset do + now() + |> DateTime.add(1, :day) + |> DateTime.to_date() + |> DateTime.new!(~T[03:30:00], "America/New_York") + end +end diff --git a/lib/screens/last_trip/trip_updates.ex b/lib/screens/last_trip/trip_updates.ex new file mode 100644 index 000000000..73ff435e7 --- /dev/null +++ b/lib/screens/last_trip/trip_updates.ex @@ -0,0 +1,10 @@ +defmodule Screens.LastTrip.TripUpdates do + @moduledoc """ + Behaviour and proxying module for fetching trip updates + """ + @adapter Application.compile_env!(:screens, [Screens.LastTrip, :trip_updates_adapter]) + + @callback get() :: {:ok, map()} | {:error, term()} + + defdelegate get, to: @adapter +end diff --git a/lib/screens/last_trip/trip_updates/gtfs.ex b/lib/screens/last_trip/trip_updates/gtfs.ex new file mode 100644 index 000000000..e1c42effd --- /dev/null +++ b/lib/screens/last_trip/trip_updates/gtfs.ex @@ -0,0 +1,17 @@ +defmodule Screens.LastTrip.TripUpdates.GTFS do + @moduledoc """ + Screens.LastTrip.TripUpdates adapter that fetches trip updates from + GTFS + """ + @behaviour Screens.LastTrip.TripUpdates + + @impl true + def get do + trip_updates_url = Application.fetch_env!(:screens, :trip_updates_url) + + with {:ok, %{body: body} = response} <- HTTPoison.get(trip_updates_url), + {:ok, decoded} <- Jason.decode(body) do + {:ok, %{response | body: decoded}} + end + end +end diff --git a/lib/screens/last_trip/trip_updates/noop.ex b/lib/screens/last_trip/trip_updates/noop.ex new file mode 100644 index 000000000..5475c8ccf --- /dev/null +++ b/lib/screens/last_trip/trip_updates/noop.ex @@ -0,0 +1,9 @@ +defmodule Screens.LastTrip.TripUpdates.Noop do + @moduledoc "Noop TripUpdates adapter for testing" + @behaviour Screens.LastTrip.TripUpdates + + @impl true + def get do + {:ok, %{status_code: 200, body: %{"entity" => []}}} + end +end diff --git a/lib/screens/last_trip/vehicle_positions.ex b/lib/screens/last_trip/vehicle_positions.ex new file mode 100644 index 000000000..2e3213dcc --- /dev/null +++ b/lib/screens/last_trip/vehicle_positions.ex @@ -0,0 +1,10 @@ +defmodule Screens.LastTrip.VehiclePositions do + @moduledoc """ + Behaviour and proxying module for fetching vehicle positions + """ + @adapter Application.compile_env!(:screens, [Screens.LastTrip, :vehicle_positions_adapter]) + + @callback get() :: {:ok, map()} | {:error, term()} + + defdelegate get, to: @adapter +end diff --git a/lib/screens/last_trip/vehicle_positions/gtfs.ex b/lib/screens/last_trip/vehicle_positions/gtfs.ex new file mode 100644 index 000000000..a109349ec --- /dev/null +++ b/lib/screens/last_trip/vehicle_positions/gtfs.ex @@ -0,0 +1,17 @@ +defmodule Screens.LastTrip.VehiclePositions.GTFS do + @moduledoc """ + Screens.LastTrip.VehiclePositions adapter that fetches trip updates from + GTFS + """ + @behaviour Screens.LastTrip.VehiclePositions + + @impl true + def get do + vehicle_positions_url = Application.fetch_env!(:screens, :vehicle_positions_url) + + with {:ok, %{body: body} = response} <- HTTPoison.get(vehicle_positions_url), + {:ok, decoded} <- Jason.decode(body) do + {:ok, %{response | body: decoded}} + end + end +end diff --git a/lib/screens/last_trip/vehicle_positions/noop.ex b/lib/screens/last_trip/vehicle_positions/noop.ex new file mode 100644 index 000000000..2393e2815 --- /dev/null +++ b/lib/screens/last_trip/vehicle_positions/noop.ex @@ -0,0 +1,9 @@ +defmodule Screens.LastTrip.VehiclePositions.Noop do + @moduledoc "Noop VehiclePositions adapter for testing" + @behaviour Screens.LastTrip.VehiclePositions + + @impl true + def get do + {:ok, %HTTPoison.Response{status_code: 200, body: %{"entity" => []}}} + end +end diff --git a/test/fixtures/TripUpdates_enhanced.json b/test/fixtures/TripUpdates_enhanced.json new file mode 100644 index 000000000..9245beec3 --- /dev/null +++ b/test/fixtures/TripUpdates_enhanced.json @@ -0,0 +1,799 @@ +{ + "header": { + "timestamp": 1726675439, + "gtfs_realtime_version": "2.0", + "incrementality": "FULL_DATASET" + }, + "entity": [ + { + "id": "canceled-trip-1", + "trip_update": { + "trip": { + "start_time": "08:33:00", + "schedule_relationship": "CANCELED", + "revenue": false, + "last_trip": false, + "trip_id": "canceled-trip-1", + "route_id": "Green-D", + "direction_id": 0, + "start_date": "20240918" + } + } + }, + { + "id": "scheduled-trip-1", + "trip_update": { + "timestamp": 1726675426, + "trip": { + "start_time": "11:49:00", + "revenue": true, + "last_trip": false, + "trip_id": "scheduled-trip-1", + "route_id": "23", + "direction_id": 0, + "start_date": "20240918" + }, + "vehicle": { + "id": "y1769", + "label": "1769" + }, + "stop_time_update": [ + { + "stop_id": "40001", + "stop_sequence": 7, + "arrival": { + "time": 1726675542 + }, + "departure": { + "time": 1726675542 + } + }, + { + "stop_id": "401", + "stop_sequence": 8, + "arrival": { + "time": 1726675572 + }, + "departure": { + "time": 1726675572 + } + }, + { + "stop_id": "404", + "stop_sequence": 9, + "arrival": { + "time": 1726675614 + }, + "departure": { + "time": 1726675614 + } + }, + { + "stop_id": "405", + "stop_sequence": 10, + "arrival": { + "time": 1726675678 + }, + "departure": { + "time": 1726675678 + } + }, + { + "stop_id": "406", + "stop_sequence": 11, + "arrival": { + "time": 1726675806 + }, + "departure": { + "time": 1726675806 + } + }, + { + "stop_id": "407", + "stop_sequence": 12, + "arrival": { + "time": 1726675861 + }, + "departure": { + "time": 1726675861 + } + }, + { + "stop_id": "410", + "stop_sequence": 13, + "arrival": { + "time": 1726675920 + }, + "departure": { + "time": 1726675920 + } + }, + { + "stop_id": "411", + "stop_sequence": 14, + "arrival": { + "time": 1726675970 + }, + "departure": { + "time": 1726675970 + } + }, + { + "stop_id": "412", + "stop_sequence": 15, + "arrival": { + "time": 1726676002 + }, + "departure": { + "time": 1726676002 + } + }, + { + "stop_id": "471", + "stop_sequence": 16, + "arrival": { + "time": 1726676188 + }, + "departure": { + "time": 1726676188 + } + }, + { + "stop_id": "472", + "stop_sequence": 17, + "arrival": { + "time": 1726676229 + }, + "departure": { + "time": 1726676229 + } + }, + { + "stop_id": "468", + "stop_sequence": 18, + "arrival": { + "time": 1726676258 + }, + "departure": { + "time": 1726676258 + } + }, + { + "stop_id": "475", + "stop_sequence": 19, + "arrival": { + "time": 1726676395 + }, + "departure": { + "time": 1726676395 + } + }, + { + "stop_id": "477", + "stop_sequence": 20, + "arrival": { + "time": 1726676451 + }, + "departure": { + "time": 1726676451 + } + }, + { + "stop_id": "478", + "stop_sequence": 21, + "arrival": { + "time": 1726676531 + }, + "departure": { + "time": 1726676531 + } + }, + { + "stop_id": "480", + "stop_sequence": 22, + "arrival": { + "time": 1726676570 + }, + "departure": { + "time": 1726676570 + } + }, + { + "stop_id": "482", + "stop_sequence": 23, + "arrival": { + "time": 1726676650 + }, + "departure": { + "time": 1726676650 + } + }, + { + "stop_id": "483", + "stop_sequence": 24, + "arrival": { + "time": 1726676697 + }, + "departure": { + "time": 1726676697 + } + }, + { + "stop_id": "485", + "stop_sequence": 25, + "arrival": { + "time": 1726676771 + }, + "departure": { + "time": 1726676771 + } + }, + { + "stop_id": "426", + "stop_sequence": 26, + "arrival": { + "time": 1726676860 + }, + "departure": { + "time": 1726676860 + } + }, + { + "stop_id": "428", + "stop_sequence": 27, + "arrival": { + "time": 1726676924 + }, + "departure": { + "time": 1726676924 + } + }, + { + "stop_id": "430", + "stop_sequence": 28, + "arrival": { + "time": 1726676965 + }, + "departure": { + "time": 1726676965 + } + }, + { + "stop_id": "334", + "stop_sequence": 29, + "arrival": { + "time": 1726677098 + } + } + ] + } + }, + { + "id": "scheduled-trip-2", + "trip_update": { + "timestamp": 1726675423, + "trip": { + "start_time": "12:38:00", + "revenue": true, + "last_trip": false, + "trip_id": "scheduled-trip-2", + "route_id": "94", + "direction_id": 0, + "start_date": "20240918" + }, + "vehicle": { + "id": "y2112" + }, + "stop_time_update": [ + { + "stop_id": "5104", + "stop_sequence": 1, + "departure": { + "time": 1726677480 + } + }, + { + "stop_id": "5019", + "stop_sequence": 2, + "arrival": { + "time": 1726677556 + }, + "departure": { + "time": 1726677556 + } + }, + { + "stop_id": "5021", + "stop_sequence": 3, + "arrival": { + "time": 1726677582 + }, + "departure": { + "time": 1726677582 + } + }, + { + "stop_id": "2405", + "stop_sequence": 4, + "arrival": { + "time": 1726677617 + }, + "departure": { + "time": 1726677617 + } + }, + { + "stop_id": "2406", + "stop_sequence": 5, + "arrival": { + "time": 1726677656 + }, + "departure": { + "time": 1726677656 + } + }, + { + "stop_id": "2407", + "stop_sequence": 6, + "arrival": { + "time": 1726677680 + }, + "departure": { + "time": 1726677680 + } + }, + { + "stop_id": "2408", + "stop_sequence": 7, + "arrival": { + "time": 1726677781 + }, + "departure": { + "time": 1726677781 + } + }, + { + "stop_id": "2410", + "stop_sequence": 8, + "arrival": { + "time": 1726677830 + }, + "departure": { + "time": 1726677830 + } + }, + { + "stop_id": "2411", + "stop_sequence": 9, + "arrival": { + "time": 1726677882 + }, + "departure": { + "time": 1726677882 + } + }, + { + "stop_id": "2412", + "stop_sequence": 10, + "arrival": { + "time": 1726677902 + }, + "departure": { + "time": 1726677902 + } + }, + { + "stop_id": "2413", + "stop_sequence": 11, + "arrival": { + "time": 1726677939 + }, + "departure": { + "time": 1726677939 + } + }, + { + "stop_id": "2414", + "stop_sequence": 12, + "arrival": { + "time": 1726677947 + }, + "departure": { + "time": 1726677947 + } + }, + { + "stop_id": "2415", + "stop_sequence": 13, + "arrival": { + "time": 1726677987 + }, + "departure": { + "time": 1726677987 + } + }, + { + "stop_id": "2416", + "stop_sequence": 14, + "arrival": { + "time": 1726678006 + }, + "departure": { + "time": 1726678006 + } + }, + { + "stop_id": "2417", + "stop_sequence": 15, + "arrival": { + "time": 1726678020 + }, + "departure": { + "time": 1726678020 + } + }, + { + "stop_id": "2418", + "stop_sequence": 16, + "arrival": { + "time": 1726678061 + }, + "departure": { + "time": 1726678061 + } + }, + { + "stop_id": "16316", + "stop_sequence": 17, + "arrival": { + "time": 1726678102 + }, + "departure": { + "time": 1726678102 + } + }, + { + "stop_id": "6316", + "stop_sequence": 18, + "arrival": { + "time": 1726678142 + }, + "departure": { + "time": 1726678142 + } + }, + { + "stop_id": "6317", + "stop_sequence": 19, + "arrival": { + "time": 1726678154 + }, + "departure": { + "time": 1726678154 + } + }, + { + "stop_id": "6318", + "stop_sequence": 20, + "arrival": { + "time": 1726678171 + }, + "departure": { + "time": 1726678171 + } + }, + { + "stop_id": "6320", + "stop_sequence": 21, + "arrival": { + "time": 1726678197 + }, + "departure": { + "time": 1726678197 + } + }, + { + "stop_id": "6321", + "stop_sequence": 22, + "arrival": { + "time": 1726678221 + }, + "departure": { + "time": 1726678221 + } + }, + { + "stop_id": "6322", + "stop_sequence": 23, + "arrival": { + "time": 1726678246 + }, + "departure": { + "time": 1726678246 + } + }, + { + "stop_id": "6323", + "stop_sequence": 24, + "arrival": { + "time": 1726678273 + }, + "departure": { + "time": 1726678273 + } + }, + { + "stop_id": "6324", + "stop_sequence": 25, + "arrival": { + "time": 1726678311 + }, + "departure": { + "time": 1726678311 + } + }, + { + "stop_id": "63241", + "stop_sequence": 26, + "arrival": { + "time": 1726678440 + }, + "departure": { + "time": 1726678440 + } + }, + { + "stop_id": "50021", + "stop_sequence": 27, + "arrival": { + "time": 1726678506 + }, + "departure": { + "time": 1726678506 + } + }, + { + "stop_id": "45002", + "stop_sequence": 28, + "arrival": { + "time": 1726678539 + } + } + ] + } + }, + { + "id": "last-trip-1", + "trip_update": { + "timestamp": 1726675418, + "trip": { + "start_time": "11:43:00", + "revenue": true, + "last_trip": true, + "trip_id": "last-trip-1", + "route_id": "66", + "direction_id": 1, + "start_date": "20240918" + }, + "vehicle": { + "id": "y3213", + "label": "3213" + }, + "stop_time_update": [ + { + "stop_id": "1304", + "stop_sequence": 17, + "arrival": { + "time": 1726675388 + }, + "departure": { + "time": 1726675388 + } + }, + { + "stop_id": "1306", + "stop_sequence": 18, + "arrival": { + "time": 1726675478 + }, + "departure": { + "time": 1726675478 + } + }, + { + "stop_id": "1308", + "stop_sequence": 19, + "arrival": { + "time": 1726675536 + }, + "departure": { + "time": 1726675536 + } + }, + { + "stop_id": "1309", + "stop_sequence": 20, + "arrival": { + "time": 1726675639 + }, + "departure": { + "time": 1726675639 + } + }, + { + "stop_id": "1310", + "stop_sequence": 21, + "arrival": { + "time": 1726675686 + }, + "departure": { + "time": 1726675686 + } + }, + { + "stop_id": "1311", + "stop_sequence": 22, + "arrival": { + "time": 1726675794 + }, + "departure": { + "time": 1726675794 + } + }, + { + "stop_id": "1313", + "stop_sequence": 23, + "arrival": { + "time": 1726675853 + }, + "departure": { + "time": 1726675853 + } + }, + { + "stop_id": "1555", + "stop_sequence": 24, + "arrival": { + "time": 1726676005 + }, + "departure": { + "time": 1726676005 + } + }, + { + "stop_id": "1314", + "stop_sequence": 25, + "arrival": { + "time": 1726676085 + }, + "departure": { + "time": 1726676085 + } + }, + { + "stop_id": "1315", + "stop_sequence": 26, + "arrival": { + "time": 1726676222 + }, + "departure": { + "time": 1726676222 + } + }, + { + "stop_id": "1317", + "stop_sequence": 27, + "arrival": { + "time": 1726676330 + }, + "departure": { + "time": 1726676330 + } + }, + { + "stop_id": "1319", + "stop_sequence": 28, + "arrival": { + "time": 1726676409 + }, + "departure": { + "time": 1726676409 + } + }, + { + "stop_id": "1320", + "stop_sequence": 29, + "arrival": { + "time": 1726676474 + }, + "departure": { + "time": 1726676474 + } + }, + { + "stop_id": "1322", + "stop_sequence": 30, + "arrival": { + "time": 1726676539 + }, + "departure": { + "time": 1726676539 + } + }, + { + "stop_id": "1323", + "stop_sequence": 31, + "arrival": { + "time": 1726676629 + }, + "departure": { + "time": 1726676629 + } + }, + { + "stop_id": "11257", + "stop_sequence": 32, + "arrival": { + "time": 1726676731 + }, + "departure": { + "time": 1726676731 + } + }, + { + "stop_id": "1259", + "stop_sequence": 33, + "arrival": { + "time": 1726676764 + }, + "departure": { + "time": 1726676764 + } + }, + { + "stop_id": "11323", + "stop_sequence": 34, + "arrival": { + "time": 1726676778 + }, + "departure": { + "time": 1726676778 + } + }, + { + "stop_id": "11259", + "stop_sequence": 35, + "arrival": { + "time": 1726676812 + }, + "departure": { + "time": 1726676812 + } + }, + { + "stop_id": "64000", + "stop_sequence": 36, + "arrival": { + "time": 1726676978 + } + } + ] + } + }, + { + "id": "canceled-trip-2", + "trip_update": { + "trip": { + "start_time": "10:34:00", + "schedule_relationship": "CANCELED", + "revenue": false, + "last_trip": false, + "trip_id": "canceled-trip-2", + "route_id": "Green-B", + "direction_id": 1, + "start_date": "20240918" + } + } + } + ] +} diff --git a/test/fixtures/VehiclePositions_enhanced.json b/test/fixtures/VehiclePositions_enhanced.json new file mode 100644 index 000000000..b8904a1d7 --- /dev/null +++ b/test/fixtures/VehiclePositions_enhanced.json @@ -0,0 +1,69 @@ +{ + "entity": [ + { + "id": "y3213", + "vehicle": { + "current_status": "STOPPED_AT", + "current_stop_sequence": 17, + "occupancy_percentage": 20, + "occupancy_status": "MANY_SEATS_AVAILABLE", + "position": { + "bearing": 0, + "latitude": 42.3454648, + "longitude": -71.1273508 + }, + "stop_id": "1304", + "timestamp": 1726675439, + "trip": { + "start_time": "11:43:00", + "schedule_relationship": "SCHEDULED", + "last_trip": false, + "trip_id": "last-trip-1", + "route_id": "66", + "direction_id": 1, + "start_date": "20240918", + "revenue": true + }, + "vehicle": { + "id": "y3213", + "label": "3213" + } + } + }, + { + "id": "y1913", + "vehicle": { + "current_status": "IN_TRANSIT_TO", + "current_stop_sequence": 13, + "occupancy_percentage": 40, + "occupancy_status": "MANY_SEATS_AVAILABLE", + "position": { + "bearing": 0, + "latitude": 42.32913652, + "longitude": -71.06879793 + }, + "stop_id": "30009", + "timestamp": 1726675436, + "trip": { + "start_time": "11:35:00", + "schedule_relationship": "SCHEDULED", + "last_trip": false, + "trip_id": "64464685", + "route_id": "10", + "direction_id": 0, + "start_date": "20240918", + "revenue": true + }, + "vehicle": { + "id": "y1913", + "label": "1913" + } + } + } + ], + "header": { + "timestamp": 1726675447, + "gtfs_realtime_version": "2.0", + "incrementality": "FULL_DATASET" + } +} diff --git a/test/screens/last_trip/cache_test.exs b/test/screens/last_trip/cache_test.exs new file mode 100644 index 000000000..e1ca86c3d --- /dev/null +++ b/test/screens/last_trip/cache_test.exs @@ -0,0 +1,25 @@ +defmodule Screens.LastTrip.CacheTest do + use ExUnit.Case, async: true + + alias Screens.LastTrip.Cache + + describe "merge_and_expire_departures/2" do + test "merges departures, keeping only the latest and most recent for each trip" do + existing_departures = [{"trip-1", 1}, {"trip-2", 4}] + departures = [{"trip-2", 2}, {"trip-2", 3}, {"trip-3", 2}] + expiration = 0 + + assert [{"trip-1", 1}, {"trip-2", 3}, {"trip-3", 2}] == + Cache.merge_and_expire_departures(existing_departures, departures, expiration) + end + + test "removes departures that are older than the expiration" do + existing_departures = [{"trip-1", 1}, {"trip-2", 4}] + departures = [{"trip-2", 2}, {"trip-2", 3}, {"trip-3", 2}] + expiration = 1 + + assert [{"trip-2", 3}, {"trip-3", 2}] == + Cache.merge_and_expire_departures(existing_departures, departures, expiration) + end + end +end diff --git a/test/screens/last_trip/parser_test.exs b/test/screens/last_trip/parser_test.exs new file mode 100644 index 000000000..f65cb8967 --- /dev/null +++ b/test/screens/last_trip/parser_test.exs @@ -0,0 +1,37 @@ +defmodule Screens.LastTrip.ParserTest do + use ExUnit.Case, async: true + + alias Screens.LastTrip.Parser + + @trip_updates "test/fixtures/TripUpdates_enhanced.json" + |> File.read!() + |> Jason.decode!() + + @vehicle_positions "test/fixtures/VehiclePositions_enhanced.json" + |> File.read!() + |> Jason.decode!() + + describe "get_running_trips/1" do + test "returns trip_updates where schedule_relationship is not CANCELED" do + assert [ + %{"trip" => %{"trip_id" => "scheduled-trip-1"}}, + %{"trip" => %{"trip_id" => "scheduled-trip-2"}}, + %{"trip" => %{"trip_id" => "last-trip-1"}} + ] = + Parser.get_running_trips(@trip_updates) + end + end + + describe "get_last_trips/1" do + test "returns trip_ids where last_trip is true" do + assert ["last-trip-1"] = Parser.get_last_trips(@trip_updates) + end + end + + describe "get_recent_departures/2" do + test "returns all of the recent departures" do + assert %{{"66", 1, "1304"} => [{"last-trip-1", 1_726_675_388}]} == + Parser.get_recent_departures(@trip_updates, @vehicle_positions) + end + end +end