Skip to content

Commit

Permalink
feat: initial destinations framework
Browse files Browse the repository at this point in the history
This adds a module which generates "destinations" from screen configs,
the first step in the proposed "real-time destinations" logic. Both
"permanent" (from typical route patterns) and "live" (from upcoming
departures) destinations are generated. Initially there is only one
possible state for a destination, `NoDepartures`. Following tasks will
add more states and wire these into the `DupNew` screen variant.
  • Loading branch information
digitalcora committed Oct 11, 2024
1 parent 68b3470 commit 89cdbc0
Show file tree
Hide file tree
Showing 10 changed files with 443 additions and 7 deletions.
5 changes: 5 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ config :screens, Screens.V2.ScreenData,

config :screens, Screens.V2.CandidateGenerator.DupNew, stop_module: Screens.Stops.MockStop

config :screens, Screens.V2.RDS,
departure_module: Screens.V2.MockDeparture,
route_pattern_module: Screens.RoutePatterns.MockRoutePattern,
stop_module: Screens.Stops.MockStop

config :screens, Screens.LastTrip,
trip_updates_adapter: Screens.LastTrip.TripUpdates.Noop,
vehicle_positions_adapter: Screens.LastTrip.VehiclePositions.Noop
3 changes: 1 addition & 2 deletions lib/screens/lines/line.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
defmodule Screens.Lines.Line do
@moduledoc false

@enforce_keys ~w[id long_name short_name sort_order]a
defstruct @enforce_keys
defstruct ~w[id long_name short_name sort_order]a

@type id :: String.t()

Expand Down
82 changes: 82 additions & 0 deletions lib/screens/route_patterns/route_direction_stops.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
defmodule Screens.RoutePatterns.RouteDirectionStops do
@moduledoc false

require Logger

def parse_result(%{"data" => data, "included" => included}, route_id) do
included_data = parse_included_data(included)
parse_data(data, included_data, route_id)
end

def parse_result(_, _) do
Logger.warning("Unrecognized format of route_pattern data.")
:error
end

defp parse_included_data(data) do
data
|> Enum.map(fn item ->
{{Map.get(item, "type"), Map.get(item, "id")}, parse_included(item)}
end)
|> Enum.into(%{})
end

defp parse_included(%{"type" => "stop"} = item) do
Screens.Stops.Parser.parse_stop(item)
end

defp parse_included(%{
"type" => "trip",
"relationships" => %{"stops" => %{"data" => stops_data}}
}) do
Enum.map(stops_data, &parse_stop_data/1)
end

defp parse_stop_data(%{"id" => stop_id, "type" => "stop"}) do
stop_id
end

defp parse_data(data, included_data, route_id) do
filtered_data = filter_by_route(data, route_id)
[typical_data | _] = filtered_data
parse_route_pattern(typical_data, included_data)
end

defp filter_by_route(data, route_id) do
Enum.filter(data, &has_related_route(&1, route_id))
end

defp has_related_route(%{"relationships" => %{"route" => %{"data" => %{"id" => id}}}}, route_id) do
route_id == id
end

defp has_related_route(_, _route_id) do
false
end

defp parse_route_pattern(
%{
"relationships" => %{
"representative_trip" => %{"data" => %{"id" => trip_id, "type" => "trip"}}
}
},
included_data
) do
# The only way this function output an empty array is if the trip data has an empty stop list
# This happens occasionally in dev-green
parsed =
included_data
|> Map.get({"trip", trip_id})
|> Enum.map(fn stop_id -> Map.get(included_data, {"stop", stop_id}) end)

case parsed do
# If `trip` is present, but the stop array is empty, there's a problem with the trip in the API
[] ->
Logger.warning("Trip data doesn't contain stop ids. trip_id: #{trip_id}")
:error

_ ->
parsed
end
end
end
2 changes: 2 additions & 0 deletions lib/screens/stops/parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ defmodule Screens.Stops.Parser do
"id" => id,
"attributes" => %{
"name" => name,
"location_type" => location_type,
"platform_code" => platform_code,
"platform_name" => platform_name
}
}) do
%Screens.Stops.Stop{
id: id,
name: name,
location_type: location_type,
platform_code: platform_code,
platform_name: platform_name
}
Expand Down
50 changes: 46 additions & 4 deletions lib/screens/stops/stop.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,14 @@ defmodule Screens.Stops.Stop do
alias Screens.V3Api
alias ScreensConfig.V2.{BusEink, BusShelter, Dup, GlEink, PreFare}

defstruct id: nil,
name: nil,
platform_code: nil,
platform_name: nil
defstruct ~w[id name location_type platform_code platform_name]a

@type id :: String.t()

@type t :: %__MODULE__{
id: id,
name: String.t(),
location_type: 0 | 1 | 2 | 3,
platform_code: String.t() | nil,
platform_name: String.t() | nil
}
Expand Down Expand Up @@ -311,6 +309,50 @@ defmodule Screens.Stops.Stop do
end
end

@doc """
Returns a list of child stops for each given stop ID (in the same order). For stop IDs that are
already child stops, the list contains only the stop itself. For stop IDs that do not exist, the
list is empty.
"""
@callback fetch_child_stops([id()]) :: {:ok, [[t()]]} | {:error, term()}
def fetch_child_stops(stop_ids, get_json_fn \\ &Screens.V3Api.get_json/2) do
case get_json_fn.("stops", %{
"filter[id]" => Enum.join(stop_ids, ","),
"include" => "child_stops"
}) do
{:ok, %{"data" => data} = response} ->
child_stops =
response
|> Map.get("included", [])
|> Enum.map(&Stops.Parser.parse_stop/1)
|> Map.new(&{&1.id, &1})

stops_with_children =
data
|> Enum.map(fn %{"relationships" => %{"child_stops" => %{"data" => children}}} = stop ->
{
Stops.Parser.parse_stop(stop),
children
|> Enum.map(fn %{"id" => id} -> Map.fetch!(child_stops, id) end)
|> Enum.filter(&(&1.location_type == 0))
}
end)
|> Map.new(&{elem(&1, 0).id, &1})

{:ok,
Enum.map(stop_ids, fn stop_id ->
case stops_with_children[stop_id] do
nil -> []
{stop, []} -> [stop]
{_stop, children} -> children
end
end)}

error ->
{:error, error}
end
end

# --- END API functions ---

def stop_on_route?(stop_id, stop_sequence) when not is_nil(stop_id) do
Expand Down
2 changes: 1 addition & 1 deletion lib/screens/v2/departure.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ defmodule Screens.V2.Departure do

@type fetch :: (params(), opts() -> result())

@spec fetch(params(), opts()) :: result()
@callback fetch(params(), opts()) :: result()
def fetch(params, opts \\ []) do
# This is equivalent to an argument with a default value, so it's fine
# credo:disable-for-next-line Screens.Checks.UntestableDateTime
Expand Down
107 changes: 107 additions & 0 deletions lib/screens/v2/rds.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
defmodule Screens.V2.RDS do
@moduledoc """
Real-time Destination State. Represents a "destination" a rider could reach (ordinarily or
currently) by taking a line from a stop, and the "state" of that destination, in the form of
departure countdowns, a headway estimate, a message that service has ended for the day, etc.
Conceptually, screen configuration is translated into a set of "destinations", each of which is
assigned a "state", containing all the data required to present it. These can then be translated
into widgets by screen-specific code.
"""

alias Screens.Lines.Line
alias Screens.RoutePatterns.RoutePattern
alias Screens.Routes.Route
alias Screens.Stops.Stop
alias Screens.V2.Departure

alias ScreensConfig.V2.Departures
alias ScreensConfig.V2.Departures.{Query, Section}

alias __MODULE__.NoDepartures

@type t :: %__MODULE__{
stop: Stop.t(),
line: Line.t(),
headsign: String.t(),
state: NoDepartures.t()
}
@enforce_keys ~w[stop line headsign state]a
defstruct @enforce_keys

defmodule NoDepartures do
@moduledoc """
The fallback state, presented as a headway or "no departures" message. A destination is in
this state when A) displaying departures has been manually disabled for the relevant transit
mode, or B) there simply aren't any upcoming departures we want to display, and as far as we
can tell this is "normal"/expected.
"""
@type t :: %__MODULE__{}
defstruct []
end

@departure Application.compile_env(:screens, [__MODULE__, :departure_module], Departure)

@route_pattern Application.compile_env(
:screens,
[__MODULE__, :route_pattern_module],
RoutePattern
)

@stop Application.compile_env(:screens, [__MODULE__, :stop_module], Stop)

@max_departure_minutes 90

@doc """
Generates destinations from departures widget configuration.
Produces a list of destinations for each configured `Section`, in the same order the sections
occur in the config.
⚠️ Enforces that every section's query contains at least one ID in `stop_ids`.
"""
@spec get(Departures.t()) :: [[t()]]
@spec get(Departures.t(), DateTime.t()) :: [[t()]]
def get(%Departures{sections: sections}, now \\ DateTime.utc_now()),
do: Enum.map(sections, &from_section(&1, now))

defp from_section(
%Section{query: %Query{params: %Query.Params{stop_ids: stop_ids} = params}},
now
)
when stop_ids != [] do
{:ok, child_stops} = @stop.fetch_child_stops(stop_ids)
{:ok, typical_patterns} = params |> Map.put(:typicality, 1) |> @route_pattern.fetch()
{:ok, departures} = @departure.fetch(params, include_schedules: true, now: now)

(tuples_from_departures(departures, now) ++
tuples_from_patterns(typical_patterns, child_stops))
|> Enum.uniq()
|> Enum.map(fn {stop, line, headsign} ->
%__MODULE__{stop: stop, line: line, headsign: headsign, state: %NoDepartures{}}
end)
end

defp tuples_from_departures(departures, now) do
departures
|> Enum.reject(fn d ->
DateTime.diff(Departure.time(d), now, :minute) > @max_departure_minutes
end)
|> Enum.map(fn d ->
{Departure.stop(d), Departure.route(d).line, Departure.representative_headsign(d)}
end)
end

defp tuples_from_patterns(route_patterns, child_stops) do
stop_ids = child_stops |> List.flatten() |> Enum.map(& &1.id) |> MapSet.new()

Enum.flat_map(
route_patterns,
fn %RoutePattern{headsign: headsign, route: %Route{line: line}, stops: stops} ->
stops
|> Enum.filter(&(&1.id in stop_ids))
|> Enum.map(fn stop -> {stop, line, headsign} end)
end
)
end
end
55 changes: 55 additions & 0 deletions test/screens/stops/stop_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
defmodule Screens.Stops.StopTest do
use ExUnit.Case, async: true

alias Screens.Stops.Stop

describe "fetch_child_stops/2" do
test "fetches the child stops of the provided stop IDs" do
stop_attributes = %{
"name" => "test",
"location_type" => 0,
"platform_code" => "",
"platform_name" => ""
}

get_json_fn =
fn "stops", %{"filter[id]" => "sX,s1,p1,p2", "include" => "child_stops"} ->
{
:ok,
%{
"data" => [
# suppose sX doesn't exist
%{
"id" => "s1",
"attributes" => stop_attributes,
"relationships" => %{"child_stops" => %{"data" => []}}
},
%{
"id" => "p1",
"attributes" => Map.put(stop_attributes, "location_type", 1),
"relationships" => %{
"child_stops" => %{"data" => [%{"id" => "c1"}, %{"id" => "c2"}]}
}
},
%{
"id" => "p2",
"attributes" => Map.put(stop_attributes, "location_type", 1),
"relationships" => %{
"child_stops" => %{"data" => [%{"id" => "c3"}]}
}
}
],
"included" => [
%{"id" => "c1", "attributes" => stop_attributes},
%{"id" => "c2", "attributes" => stop_attributes},
%{"id" => "c3", "attributes" => stop_attributes}
]
}
}
end

assert {:ok, [[], [%Stop{id: "s1"}], [%Stop{id: "c1"}, %Stop{id: "c2"}], [%Stop{id: "c3"}]]} =
Stop.fetch_child_stops(~w[sX s1 p1 p2], get_json_fn)
end
end
end
Loading

0 comments on commit 89cdbc0

Please sign in to comment.