Skip to content

Commit 1da4065

Browse files
authored
feat(predictions): add schedule relationship filter (#1087)
Summary of changes Asana Ticket: 🔮 Build a basic Splunk dashboard for RTR Problem: As part of building out observability for our subway predictions, we need to periodically check how many ADDED / SKIPPED predictions we are making for each line and direction. We want to add this check in the api_checker, which polls the V3 API. However, no filter exists for schedule_relationship on the predictions endpoint. Solution: Add a filter for schedule_relationship on the predictions endpoint. Following this, we can add product health checks that look at the proportion of ADDED / SKIPPED predictions we are making per route / direction.
1 parent ff8ccea commit 1da4065

6 files changed

Lines changed: 146 additions & 1 deletion

File tree

‎apps/api_web/lib/api_web/controllers/prediction_controller.ex‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@ defmodule ApiWeb.PredictionController do
77
* route
88
* trip
99
* radius
10+
* direction_id
11+
* stop_sequence
12+
* route_type
13+
* route_pattern
14+
* revenue
15+
* schedule_relationship
1016
"""
1117
use ApiWeb.Web, :api_controller
1218
require Logger
1319
alias ApiWeb.LegacyStops
1420
alias State.Prediction
1521

16-
@filters ~w(stop route trip latitude longitude radius direction_id stop_sequence route_type route_pattern revenue)s
22+
@filters ~w(stop route trip latitude longitude radius direction_id stop_sequence route_type route_pattern revenue schedule_relationship)s
1723
@pagination_opts ~w(offset limit order_by)a
1824
@includes ~w(schedule stop route trip vehicle alerts)
1925

@@ -63,6 +69,11 @@ defmodule ApiWeb.PredictionController do
6369
filter_param(:id, name: :trip)
6470
filter_param(:revenue, desc: "Filter predictions by revenue status.")
6571

72+
filter_param(:schedule_relationship,
73+
desc:
74+
"Filter predictions by schedule relationship: https://github.com/google/transit/blob/master/gtfs-realtime/spec/en/reference.md#enum-schedulerelationship"
75+
)
76+
6677
parameter("filter[route_pattern]", :query, :string, """
6778
Filter by `/included/{index}/relationships/route_pattern/data/id` of a trip. Multiple `route_pattern_id` #{comma_separated_list()}.
6879
""")
@@ -79,9 +90,15 @@ defmodule ApiWeb.PredictionController do
7990
with :ok <- Params.validate_includes(params, @includes, conn),
8091
{:ok, filtered_params} <- Params.filter_params(params, filters(conn), conn) do
8192
case filtered_params do
93+
%{"schedule_relationship" => _} = p when map_size(p) == 1 ->
94+
{:error, :only_schedule_relationship}
95+
8296
%{"route_type" => _} = p when map_size(p) == 1 ->
8397
{:error, :only_route_type}
8498

99+
%{"route_type" => _, "schedule_relationship" => _} = p when map_size(p) == 2 ->
100+
{:error, :only_route_type_and_schedule_relationship}
101+
85102
p when map_size(p) > 0 ->
86103
do_index_data(conn, params, filtered_params)
87104

@@ -101,6 +118,9 @@ defmodule ApiWeb.PredictionController do
101118
route_ids = Params.split_on_comma(filtered_params, "route")
102119
route_types = Params.route_types(filtered_params)
103120

121+
schedule_relationships =
122+
Params.schedule_relationships(filtered_params)
123+
104124
pagination_opts =
105125
Params.filter_opts(params, @pagination_opts, conn, order_by: {:arrival_time, :asc})
106126

@@ -113,6 +133,7 @@ defmodule ApiWeb.PredictionController do
113133
filtered_params
114134
|> build_stop_sequence_matchers(direction_id_matcher)
115135
|> add_revenue_matchers(revenue)
136+
|> add_schedule_relationship_matchers(schedule_relationships)
116137

117138
{trip_ids, route_pattern_ids}
118139
|> case do
@@ -245,6 +266,25 @@ defmodule ApiWeb.PredictionController do
245266
end
246267
end
247268

269+
defp add_schedule_relationship_matchers(matchers, nil),
270+
do: matchers
271+
272+
defp add_schedule_relationship_matchers(matchers, []),
273+
do: matchers
274+
275+
defp add_schedule_relationship_matchers(matchers, schedule_relationships) do
276+
for schedule_relationship <- schedule_relationships, matcher <- matchers do
277+
schedule_relationship_atom =
278+
schedule_relationship |> String.downcase() |> String.to_existing_atom()
279+
280+
if schedule_relationship_atom == :scheduled do
281+
Map.put(matcher, :schedule_relationship, nil)
282+
else
283+
Map.put(matcher, :schedule_relationship, schedule_relationship_atom)
284+
end
285+
end
286+
end
287+
248288
def swagger_definitions do
249289
import PhoenixSwagger.JsonApi, except: [page: 1]
250290

‎apps/api_web/lib/api_web/params.ex‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,18 @@ defmodule ApiWeb.Params do
201201
def route_types(%{"route_type" => route_types}), do: integer_values(route_types)
202202
def route_types(_), do: []
203203

204+
@doc """
205+
Parses a list of schedule relationships out of a parameter map
206+
"""
207+
@spec schedule_relationships(%{String.t() => String.t()}) :: [String.t()] | nil
208+
def schedule_relationships(%{"schedule_relationship" => schedule_relationships}),
209+
do:
210+
schedule_relationships
211+
|> split_on_comma()
212+
|> Enum.filter(&(&1 in ["SCHEDULED", "SKIPPED", "ADDED"]))
213+
214+
def schedule_relationships(_), do: nil
215+
204216
@doc """
205217
Parse canonical filter param into boolean
206218
"""

‎apps/api_web/lib/api_web/swagger_helpers.ex‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,23 @@ defmodule ApiWeb.SwaggerHelpers do
285285
)
286286
end
287287

288+
def filter_param(path_object, :schedule_relationship, opts) do
289+
Path.parameter(
290+
path_object,
291+
"filter[schedule_relationship]",
292+
:query,
293+
:string,
294+
"""
295+
#{opts[:desc]}
296+
Relationship between the prediction and the current GTFS static schedule.
297+
When filter is not included, the default behavior is to return trips with all
298+
schedule relationships.
299+
300+
""",
301+
enum: ["SCHEDULED", "SKIPPED", "ADDED"]
302+
)
303+
end
304+
288305
def page(resource) do
289306
resource
290307
|> JsonApi.page()

‎apps/api_web/lib/api_web/views/error_view.ex‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ defmodule ApiWeb.ErrorView do
4747
})
4848
end
4949

50+
def render("400.json" <> _, %{error: :only_schedule_relationship}) do
51+
ErrorSerializer.format(%{
52+
code: :bad_request,
53+
status: "400",
54+
detail: "filter[schedule_relationship] must be used in conjunction with another filter[]."
55+
})
56+
end
57+
58+
def render("400.json" <> _, %{error: :only_route_type_and_schedule_relationship}) do
59+
ErrorSerializer.format(%{
60+
code: :bad_request,
61+
status: "400",
62+
detail:
63+
"filter[route_type], filter[schedule_relationship] must be used in conjunction with another filter[]."
64+
})
65+
end
66+
5067
def render("400.json" <> _, %{error: :only_direction_id}) do
5168
ErrorSerializer.format(%{
5269
code: :bad_request,

‎apps/api_web/test/api_web/controllers/prediction_controller_test.exs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ defmodule ApiWeb.PredictionControllerTest do
6060
assert ApiWeb.PredictionController.index_data(conn, %{"route_type" => "0,1"}) ==
6161
{:error, :only_route_type}
6262
end
63+
64+
test "returns an error if only the schedule_relationship filter is provided", %{conn: conn} do
65+
assert ApiWeb.PredictionController.index_data(conn, %{"schedule_relationship" => "SKIPPED"}) ==
66+
{:error, :only_schedule_relationship}
67+
end
68+
69+
test "returns an error if only the schedule_relationship filter and route_type filter are provided",
70+
%{conn: conn} do
71+
assert ApiWeb.PredictionController.index_data(conn, %{
72+
"schedule_relationship" => "SKIPPED",
73+
"route_type" => "0,1"
74+
}) ==
75+
{:error, :only_route_type_and_schedule_relationship}
76+
end
6377
end
6478

6579
test "predictions can be paginated and are sorted by arrival_time", %{conn: base_conn} do
@@ -142,6 +156,39 @@ defmodule ApiWeb.PredictionControllerTest do
142156
end
143157
end
144158

159+
test "allows filtering by schedule_relationship", %{conn: conn} do
160+
scheduled_prediction = %Prediction{
161+
stop_id: "1",
162+
route_id: "Red",
163+
arrival_time: @latest_arrival,
164+
schedule_relationship: nil
165+
}
166+
167+
added_prediction = %Prediction{
168+
stop_id: "1",
169+
route_id: "Red",
170+
schedule_relationship: :added
171+
}
172+
173+
skipped_prediction = %Prediction{
174+
stop_id: "1",
175+
route_id: "Red",
176+
schedule_relationship: :skipped
177+
}
178+
179+
State.Prediction.new_state([added_prediction, skipped_prediction, scheduled_prediction])
180+
181+
for {params, expected} <- [
182+
# show all scehdule_relationship values by default
183+
{%{"route" => "Red"}, [added_prediction, skipped_prediction, scheduled_prediction]},
184+
{%{"route" => "Red", "schedule_relationship" => "SKIPPED"}, [skipped_prediction]},
185+
{%{"route" => "Red", "schedule_relationship" => "ADDED"}, [added_prediction]}
186+
] do
187+
conn = get(conn, "/predictions", params)
188+
assert conn.assigns.data == expected
189+
end
190+
end
191+
145192
test "versions before 2021-01-09 allow an unused `date` filter", %{conn: conn} do
146193
conn = assign(conn, :api_version, "2021-01-09")
147194
resp = get(conn, "/predictions", stop: @stop.id, date: "2020-01-01")

‎apps/state/lib/state/prediction.ex‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ defmodule State.Prediction do
99
import Parse.Time, only: [service_date: 1]
1010
import State.Route, only: [by_types: 1]
1111

12+
def filter_by_schedule_relationshp(predictions, nil), do: predictions
13+
def filter_by_schedule_relationshp(predictions, []), do: predictions
14+
15+
def filter_by_schedule_relationshp(predictions, schedule_relationshps) do
16+
route_ids =
17+
schedule_relationshps
18+
|> by_types()
19+
|> MapSet.new(& &1.id)
20+
21+
Enum.filter(predictions, &(&1.route_id in route_ids))
22+
end
23+
1224
def filter_by_route_type(predictions, nil), do: predictions
1325
def filter_by_route_type(predictions, []), do: predictions
1426

0 commit comments

Comments
 (0)