From 7e627376849293dc9c09ea25e649cbe6bc90bd76 Mon Sep 17 00:00:00 2001 From: Jon Zimbel Date: Fri, 13 Jun 2025 07:54:48 -0400 Subject: [PATCH 1/3] feat: Use "natural" stop sequences to condense GL derived limits --- lib/arrow/hastus/export_upload.ex | 146 ++++++++++++++--------- lib/arrow/polytree_helper.ex | 74 ++++++++++++ mix.exs | 1 + mix.lock | 1 + test/arrow/hastus/export_upload_test.exs | 5 + 5 files changed, 171 insertions(+), 56 deletions(-) create mode 100644 lib/arrow/polytree_helper.ex diff --git a/lib/arrow/hastus/export_upload.ex b/lib/arrow/hastus/export_upload.ex index 7a0b82c09..d1cbc293a 100644 --- a/lib/arrow/hastus/export_upload.ex +++ b/lib/arrow/hastus/export_upload.ex @@ -8,6 +8,7 @@ defmodule Arrow.Hastus.ExportUpload do require Logger alias Arrow.Hastus.TripRouteDirection + alias Arrow.PolytreeHelper @type t :: %__MODULE__{ services: list(map()), @@ -468,30 +469,31 @@ defmodule Arrow.Hastus.ExportUpload do unique_trip_counts = Enum.frequencies_by(trips, &trip_type/1) # Trim down the number of trips we look at to save time: - # - Only direction_id 0 trips + # - Only direction_id 1 trips # - Only trips of a type that occurs more than a handful of times. # (We can assume the uncommon trip types are train repositionings.) trips = trips - |> Stream.filter(&(trp_direction_to_direction_id[&1["trp_direction"]] == 0)) + |> Stream.filter(&(trp_direction_to_direction_id[&1["trp_direction"]] == 1)) |> Enum.filter(&(unique_trip_counts[trip_type(&1)] >= @min_trip_type_occurrence)) - canonical_stop_sequences = stop_sequences_for_routes(route_ids) + tree = build_polytree(route_ids) + natural_stop_sequences = PolytreeHelper.all_full_paths(tree) - Enum.map(services, &add_derived_limits(&1, trips, stop_times, canonical_stop_sequences)) + Enum.map(services, &add_derived_limits(&1, trips, stop_times, natural_stop_sequences, tree)) end - defp add_derived_limits(service, trips, stop_times, canonical_stop_sequences) + defp add_derived_limits(service, trips, stop_times, natural_stop_sequences, tree) - defp add_derived_limits(%{service_dates: []} = service, _, _, _) do + defp add_derived_limits(%{service_dates: []} = service, _, _, _, _) do Map.put(service, :derived_limits, []) end - defp add_derived_limits(service, trips, stop_times, canonical_stop_sequences) do + defp add_derived_limits(service, trips, stop_times, natural_stop_sequences, tree) do # Summary of logic: # Chunk trips within this service into hour windows, based on the departure time of each trip's first stop_time. # For each chunk, collect all trips' visited stops into a set of stop IDs. - # Compare these with canonical stop sequence(s) for the line, looking for "holes" of unvisited stops. + # Compare these with stop sequence(s) for the line, looking for "holes" of unvisited stops. # These "holes" are the derived limits. # # Why: @@ -518,16 +520,19 @@ defmodule Arrow.Hastus.ExportUpload do fn {_trip_id, stop_times} -> MapSet.new(stop_times, & &1["stop_id"]) end ) |> Map.delete(:skip) - |> Enum.map(fn {_hour, stop_id_sets} -> - Enum.reduce(stop_id_sets, &MapSet.union/2) - end) + # Sort the map keys before iterating over them to prevent undefined order in test results. + |> Enum.sort_by(fn {hour, _} -> hour end) + |> Enum.map(fn {_hour, stop_id_sets} -> Enum.reduce(stop_id_sets, &MapSet.union/2) end) derived_limits = - for visited_stops <- visited_stops_per_time_window, - seq <- canonical_stop_sequences, - {start_stop_id, end_stop_id} <- limits_from_sequence(seq, visited_stops) do - %{start_stop_id: start_stop_id, end_stop_id: end_stop_id} - end + visited_stops_per_time_window + |> Enum.flat_map(fn visited_stops -> + for seq <- natural_stop_sequences, + limit <- limit_slices_from_sequence(seq, visited_stops) do + limit + end + |> condense_limits(tree) + end) |> Enum.uniq() Map.put(service, :derived_limits, derived_limits) @@ -545,21 +550,30 @@ defmodule Arrow.Hastus.ExportUpload do ) end - # Returns a list of lists with the direction_id=0 canonical stop sequence(s) for the given routes. - @spec stop_sequences_for_routes([String.t()]) :: [[stop_id :: String.t()]] - defp stop_sequences_for_routes(route_ids) do + # Returns a polytree (directed acyclic graph that can have multiple roots) + # constructed from a line's canonical direction_id=1 stop sequences. + # + # Nodes of the tree use platform stop IDs as their IDs, + # and contain corresponding parent station IDs as their values. + @spec build_polytree([String.t()]) :: UnrootedPolytree.t() + defp build_polytree(route_ids) do Arrow.Repo.all( from t in Arrow.Gtfs.Trip, - where: t.direction_id == 0, + where: t.direction_id == 1, where: t.service_id == "canonical", where: t.route_id in ^route_ids, join: st in Arrow.Gtfs.StopTime, on: t.id == st.trip_id, + join: s in Arrow.Gtfs.Stop, + on: st.stop_id == s.id, + join: ps in Arrow.Gtfs.Stop, + on: ps.id == s.parent_station_id, order_by: [t.id, st.stop_sequence], - select: %{trip_id: t.id, stop_id: st.stop_id} + select: %{trip_id: t.id, stop_id: st.stop_id, parent_id: ps.id} ) |> Stream.chunk_by(& &1.trip_id) - |> Enum.map(fn stops -> Enum.map(stops, & &1.stop_id) end) + |> Enum.map(fn stops -> Enum.map(stops, &{&1.stop_id, &1.parent_id}) end) + |> UnrootedPolytree.from_lists() end # Maps HASTUS all_trips.txt `trp_direction` values @@ -580,61 +594,81 @@ defmodule Arrow.Hastus.ExportUpload do |> Map.new() end - @typep limit :: {start_stop_id :: stop_id, end_stop_id :: stop_id} + @typep limit :: %{start_stop_id: stop_id, end_stop_id: stop_id} @typep stop_id :: String.t() - @spec limits_from_sequence([stop_id], MapSet.t(stop_id)) :: [limit] - defp limits_from_sequence(stop_sequence, visited_stops) - - defp limits_from_sequence([], _visited_stops), do: [] + @spec limit_slices_from_sequence([stop_id], MapSet.t(stop_id)) :: [[stop_id]] + defp limit_slices_from_sequence(stop_sequence, visited_stops) - defp limits_from_sequence([first_stop | stops] = stop_sequence, visited_stops) do - # Regardless of whether it was visited, the first stop in the sequence - # is the potential first stop of a limit. - acc = {first_stop, first_stop in visited_stops} + defp limit_slices_from_sequence([], _visited_stops), do: [] + defp limit_slices_from_sequence([first_stop | stops], visited_stops) do Enum.chunk_while( stops, - acc, - &chunk_limits(&1, &2, &1 in visited_stops), - &chunk_limits(&1, stop_sequence) + [first_stop], + &chunk_limits(&1, &2, visited_stops), + &chunk_limits(&1, visited_stops) ) end - # The acc records: - # 1. the potential first stop of a limit, and - # 2. whether the previous stop in the sequence was visited by any trip in the time window. - @typep limits_acc :: {potential_first_stop_of_limit :: stop_id, prev_stop_visited? :: boolean} + @typep limit_acc :: nonempty_list(stop_id) # chunk fun - @spec chunk_limits(stop_id, limits_acc, boolean) :: - {:cont, limit, limits_acc} | {:cont, limits_acc} - defp chunk_limits(stop, acc, stop_visited?) + @spec chunk_limits(stop_id, limit_acc, MapSet.t(stop_id)) :: + {:cont, [stop_id], limit_acc} | {:cont, limit_acc} + defp chunk_limits(stop, limit_stops, visited_stops) + + defp chunk_limits(stop, limit_stops, visited_stops) do + prev_stop_visited? = hd(limit_stops) in visited_stops + stop_visited? = stop in visited_stops - defp chunk_limits(stop, {first_stop, prev_stop_visited?}, stop_visited?) do cond do # This stop was not visited. - # Potential start of limit remains where it was. - not stop_visited? -> {:cont, {first_stop, stop_visited?}} + # Add it to the in-progress limit. + not stop_visited? -> {:cont, [stop | limit_stops]} # Prev stop was visited, this stop was visited. # Potential start of limit moves to this stop. - prev_stop_visited? -> {:cont, {stop, stop_visited?}} + prev_stop_visited? -> {:cont, [stop]} # Prev stop was not visited, this stop was visited. - # This is the end of a limit--emit it and form a new limit starting at this stop. - not prev_stop_visited? -> {:cont, {first_stop, stop}, {stop, stop_visited?}} + # This is the end of a limit--emit it and begin a new limit starting at this stop. + not prev_stop_visited? -> {:cont, Enum.reverse([stop | limit_stops]), [stop]} end end # after fun - @spec chunk_limits(limits_acc, [stop_id]) :: {:cont, term} | {:cont, limit, term} - defp chunk_limits(acc, sequence) - - # The last stop in the sequence was visited. - defp chunk_limits({_, true}, _), do: {:cont, nil} - - # The last stop in the sequence was not visited. Emit a limit that ends with it. - defp chunk_limits({first_stop, false}, sequence) do - {:cont, {first_stop, List.last(sequence)}, nil} + @spec chunk_limits(limit_acc, MapSet.t(stop_id)) :: {:cont, term} | {:cont, [stop_id], term} + defp chunk_limits(limit_stops, visited_stops) do + # If the last stop in the sequence was not visited, emit a limit that ends with it. + if hd(limit_stops) in visited_stops, + do: {:cont, nil}, + else: {:cont, Enum.reverse(limit_stops), nil} + end + + # Merges all limit slices (sequences of stop IDs) produced from an hour window + # into a final, minimal list of limits. + @spec condense_limits([[stop_id]], UnrootedPolytree.t()) :: [limit] + defp condense_limits(limit_slices, tree) do + # Convert slices to parent station IDs to avoid unwanted duplicates of + # limits that include e.g. Kenmore, which has multiple eastbound platform stop IDs. + limits_tree = + limit_slices + |> Enum.map(fn slice -> + Enum.map(slice, fn stop_id -> + {:ok, tree_node} = UnrootedPolytree.node_for_id(tree, stop_id) + parent_id = tree_node.value + {parent_id, stop_id} + end) + end) + |> UnrootedPolytree.from_lists() + + limits_tree + |> PolytreeHelper.all_full_paths() + # Convert parent IDs in these paths back to their child IDs + |> Enum.map(fn path -> + {:ok, start_node} = UnrootedPolytree.node_for_id(limits_tree, hd(path)) + {:ok, end_node} = UnrootedPolytree.node_for_id(limits_tree, List.last(path)) + %{start_stop_id: start_node.value, end_stop_id: end_node.value} + end) end defp chunk_dates(date, {start_date, last_date}, service, exceptions) do diff --git a/lib/arrow/polytree_helper.ex b/lib/arrow/polytree_helper.ex new file mode 100644 index 000000000..fc1b07cc6 --- /dev/null +++ b/lib/arrow/polytree_helper.ex @@ -0,0 +1,74 @@ +defmodule Arrow.PolytreeHelper do + @moduledoc """ + Functions for creating and analyzing UnrootedPolytree structures. + + Intended for use with subway line stop sequences, but can work with any UnrootedPolytree. + """ + alias UnrootedPolytree, as: UPTree + + @doc """ + Returns a list of IDs for all nodes in the polytree that have no previous nodes. + """ + @spec leftmost_ids(UPTree.t()) :: [UPTree.Node.id()] + def leftmost_ids(%UPTree{} = tree), do: do_leftmost(tree, tree.starting_nodes) + + @doc """ + Returns a list of all possible paths from a leftmost node to a rightmost node + in the polytree. + + Each path is a list of node IDs, where the first element is a leftmost node + and the last is a rightmost node. + """ + @spec all_full_paths(UPTree.t()) :: [[UPTree.Node.id()]] + def all_full_paths(%UPTree{} = tree) do + tree + |> leftmost_ids() + |> Enum.map(&[&1]) + |> then(&build_paths(tree, &1)) + end + + @doc """ + Constructs an UnrootedPolytree from a list of stop sequences. + """ + @spec seqs_to_tree([[stop_id :: String.t()]]) :: UPTree.t() + def seqs_to_tree(seqs) do + seqs + |> Enum.map(fn seq -> Enum.map(seq, &{&1, &1}) end) + |> UPTree.from_lists() + end + + defp do_leftmost(tree, ids, acc \\ [], visited \\ MapSet.new()) + + defp do_leftmost(_tree, [], acc, _visited), do: Enum.uniq(acc) + + defp do_leftmost(tree, ids, acc, visited) do + {prev_ids, acc} = + Enum.reduce(ids, {[], acc}, fn id, {prev_ids, acc} -> + case UPTree.edges_for_id(tree, id).previous do + [] -> {prev_ids, [id | acc]} + prev -> {Enum.reject(prev, &(&1 in visited)) ++ prev_ids, acc} + end + end) + + do_leftmost(tree, prev_ids, acc, for(id <- ids, into: visited, do: id)) + end + + defp build_paths(tree, paths) do + if Enum.all?(paths, &match?({:done, _}, &1)) do + Enum.map(paths, fn {:done, path} -> Enum.reverse(path) end) + else + paths + |> Enum.flat_map(fn + {:done, path} -> + [{:done, path}] + + [id | _] = path -> + case UPTree.edges_for_id(tree, id).next do + [] -> [{:done, path}] + next -> Enum.map(next, &[&1 | path]) + end + end) + |> then(&build_paths(tree, &1)) + end + end +end diff --git a/mix.exs b/mix.exs index f2129554c..100bae626 100644 --- a/mix.exs +++ b/mix.exs @@ -85,6 +85,7 @@ defmodule Arrow.MixProject do {:tzdata, "~> 1.1"}, {:ueberauth_oidcc, "~> 0.4.0"}, {:ueberauth, "~> 0.10"}, + {:unrooted_polytree, "~> 0.1.1"}, {:wallaby, "~> 0.30", runtime: false, only: :test}, {:sentry, "~> 10.7"}, {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, diff --git a/mix.lock b/mix.lock index e369cd614..8b5f1fb83 100644 --- a/mix.lock +++ b/mix.lock @@ -77,6 +77,7 @@ "ueberauth": {:hex, :ueberauth, "0.10.8", "ba78fbcbb27d811a6cd06ad851793aaf7d27c3b30c9e95349c2c362b344cd8f0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f2d3172e52821375bccb8460e5fa5cb91cfd60b19b636b6e57e9759b6f8c10c1"}, "ueberauth_oidcc": {:hex, :ueberauth_oidcc, "0.4.1", "172f202c8e6731d30c2221f5ea67a2217c30f60436b6a236e745e978497e57d9", [:mix], [{:oidcc, "~> 3.2.0", [hex: :oidcc, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.10", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "ba4447d428df74d5cff8b6717e1249163649d946d4aefd22f7445a9979adab54"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "unrooted_polytree": {:hex, :unrooted_polytree, "0.1.1", "95027b1619d707fcbbd8980708a50efd170782142dd3de5112e9332d4cc27fef", [:mix], [], "hexpm", "9c8143d2015526ae49c3642ca509802e4db129685a57a0ec413e66546fe0c251"}, "unzip": {:hex, :unzip, "0.12.0", "beed92238724732418b41eba77dcb7f51e235b707406c05b1732a3052d1c0f36", [:mix], [], "hexpm", "95655b72db368e5a84951f0bed586ac053b55ee3815fd96062fce10ce4fc998d"}, "wallaby": {:hex, :wallaby, "0.30.10", "574afb8796521252daf49a4cd76a1c389d53cae5897f2d4b5f55dfae159c8e50", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "a8f89b92d8acce37a94b5dfae6075c2ef00cb3689d6333f5f36c04b381c077b2"}, "web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"}, diff --git a/test/arrow/hastus/export_upload_test.exs b/test/arrow/hastus/export_upload_test.exs index b602128cc..daf64f270 100644 --- a/test/arrow/hastus/export_upload_test.exs +++ b/test/arrow/hastus/export_upload_test.exs @@ -484,6 +484,11 @@ defmodule Arrow.Hastus.ExportUploadTest do end end + ############################################### + # TODO: Need to also set up parent station ID # + # relations for all platform IDs... # + ############################################### + defp build_gtfs(%{skip_build_gtfs: true}), do: :ok defp build_gtfs(%{build_gtfs_line: line}), do: build_gtfs(line_params(line)) From cb7b2a23fa7e669dd0d46dc234c02bd1fbab2388 Mon Sep 17 00:00:00 2001 From: Jon Zimbel Date: Mon, 23 Jun 2025 06:47:20 -0400 Subject: [PATCH 2/3] Update tests & fixtures for new derived limits logic --- test/arrow/hastus/export_upload_test.exs | 277 ++-------- test/support/fixtures/gtfs_line_fixtures.ex | 543 ++++++++++++++++++++ 2 files changed, 585 insertions(+), 235 deletions(-) create mode 100644 test/support/fixtures/gtfs_line_fixtures.ex diff --git a/test/arrow/hastus/export_upload_test.exs b/test/arrow/hastus/export_upload_test.exs index daf64f270..ccf3cbad0 100644 --- a/test/arrow/hastus/export_upload_test.exs +++ b/test/arrow/hastus/export_upload_test.exs @@ -7,9 +7,9 @@ defmodule Arrow.Hastus.ExportUploadTest do @export_dir "test/support/fixtures/hastus" describe "extract_data_from_upload/2" do - setup :build_gtfs + setup {Arrow.GtfsLineFixtures, :build_gtfs_line} - @tag build_gtfs_line: "line-Blue" + @tag gtfs_line: "line-Blue" @tag export: "valid_export.zip" test "extracts data from export", %{export: export} do data = ExportUpload.extract_data_from_upload(%{path: "#{@export_dir}/#{export}"}, "uid") @@ -38,7 +38,7 @@ defmodule Arrow.Hastus.ExportUploadTest do %{start_date: ~D[2025-03-27], end_date: ~D[2025-04-01]}, %{start_date: ~D[2025-04-04], end_date: ~D[2025-04-04]} ], - derived_limits: [%{start_stop_id: "70039", end_stop_id: "70838"}] + derived_limits: [%{start_stop_id: "70038", end_stop_id: "70040"}] } ] @@ -52,7 +52,7 @@ defmodule Arrow.Hastus.ExportUploadTest do }}} = data end - @tag :skip_build_gtfs + @tag :skip_build_gtfs_line @tag export: "trips_no_shapes.zip" test "gives validation errors for invalid exports", %{export: export} do data = ExportUpload.extract_data_from_upload(%{path: "#{@export_dir}/#{export}"}, "uid") @@ -63,7 +63,7 @@ defmodule Arrow.Hastus.ExportUploadTest do end @tag export: "gl_known_variant.zip" - @tag build_gtfs_line: "line-Green" + @tag gtfs_line: "line-Green" test "handles a GL export with a known variant", %{export: export} do data = ExportUpload.extract_data_from_upload(%{path: "#{@export_dir}/#{export}"}, "uid") @@ -91,7 +91,7 @@ defmodule Arrow.Hastus.ExportUploadTest do end @tag export: "gl_unambiguous_branch.zip" - @tag build_gtfs_line: "line-Green" + @tag gtfs_line: "line-Green" test "handles a GL export with unknown variant but unambiguous branch", %{export: export} do data = ExportUpload.extract_data_from_upload(%{path: "#{@export_dir}/#{export}"}, "uid") @@ -113,7 +113,7 @@ defmodule Arrow.Hastus.ExportUploadTest do end @tag export: "gl_trips_ambiguous_branch.zip" - @tag build_gtfs_line: "line-Green" + @tag gtfs_line: "line-Green" test "gives validation errors for GL export with ambiguous branches", %{export: export} do data = ExportUpload.extract_data_from_upload(%{path: "#{@export_dir}/#{export}"}, "uid") @@ -123,7 +123,7 @@ defmodule Arrow.Hastus.ExportUploadTest do data end - @tag skip_build_gtfs: true + @tag skip_build_gtfs_line: true @tag export: "gl_trips_ambiguous_branch_real_world.zip" test "handles GL export with variant suffixes (e.g. BE, CE, DE)", %{export: export} do line = insert(:gtfs_line, id: "line-Green") @@ -165,7 +165,7 @@ defmodule Arrow.Hastus.ExportUploadTest do end @tag export: "empty_all_calendar.zip" - @tag build_gtfs_line: "line-Blue" + @tag gtfs_line: "line-Blue" test "exports services even when all_calendar.txt is empty", %{export: export} do expected_services = [ %{name: "RTL12025-hmb15016-Saturday-01", service_dates: [], derived_limits: []}, @@ -186,7 +186,7 @@ defmodule Arrow.Hastus.ExportUploadTest do end @tag export: "valid_export.zip" - @tag build_gtfs_line: "line-Blue" + @tag gtfs_line: "line-Blue" test "amends duplicate service IDs", %{export: export} do # Insert 2 HASTUS services whose IDs are duplicates of those in the export %{name: service_id1} = insert(:hastus_service, name: "RTL12025-hmb15016-Saturday-01") @@ -224,7 +224,7 @@ defmodule Arrow.Hastus.ExportUploadTest do end @tag export: "BowdoinClosureTest.zip" - @tag build_gtfs_line: "line-Blue" + @tag gtfs_line: "line-Blue" test "extracts multiple derived limits when exported service implies multiple limits", %{ export: export } do @@ -244,12 +244,12 @@ defmodule Arrow.Hastus.ExportUploadTest do %{ name: "RTL12025-hmb15mo1-Weekday-01-3", service_dates: [%{start_date: ~D[2025-03-24], end_date: ~D[2025-03-24]}], - derived_limits: [%{start_stop_id: "70039", end_stop_id: "70838"}] + derived_limits: [%{start_stop_id: "70038", end_stop_id: "70040"}] }, %{ name: "RTL12025-hmb15wg1-Weekday-01-4", service_dates: [%{start_date: ~D[2025-03-21], end_date: ~D[2025-03-21]}], - derived_limits: [%{start_stop_id: "70039", end_stop_id: "70838"}] + derived_limits: [%{start_stop_id: "70038", end_stop_id: "70040"}] } ] @@ -264,7 +264,7 @@ defmodule Arrow.Hastus.ExportUploadTest do end @tag export: "2025-Spring-vehicle-OLNorthStationOakGrove-v3.zip" - @tag build_gtfs_line: "line-Orange" + @tag gtfs_line: "line-Orange" test "OL export", %{export: export} do data = ExportUpload.extract_data_from_upload(%{path: "#{@export_dir}/#{export}"}, "uid") @@ -272,12 +272,12 @@ defmodule Arrow.Hastus.ExportUploadTest do %{ name: "RTL22025-hmo25ea1-Weekday-01", service_dates: [%{start_date: ~D[2025-05-09], end_date: ~D[2025-05-09]}], - derived_limits: [%{start_stop_id: "70036", end_stop_id: "70026"}] + derived_limits: [%{start_stop_id: "70027", end_stop_id: "70036"}] }, %{ name: "RTL22025-hmo25on1-Weekday-01", service_dates: [%{start_date: ~D[2025-05-12], end_date: ~D[2025-05-16]}], - derived_limits: [%{start_stop_id: "70036", end_stop_id: "70026"}] + derived_limits: [%{start_stop_id: "70027", end_stop_id: "70036"}] }, %{ name: "RTL22025-hmo25on6-Saturday-01", @@ -285,7 +285,7 @@ defmodule Arrow.Hastus.ExportUploadTest do %{start_date: ~D[2025-05-10], end_date: ~D[2025-05-10]}, %{start_date: ~D[2025-05-17], end_date: ~D[2025-05-17]} ], - derived_limits: [%{start_stop_id: "70036", end_stop_id: "70026"}] + derived_limits: [%{start_stop_id: "70027", end_stop_id: "70036"}] }, %{ name: "RTL22025-hmo25on7-Sunday-01", @@ -293,7 +293,7 @@ defmodule Arrow.Hastus.ExportUploadTest do %{start_date: ~D[2025-05-11], end_date: ~D[2025-05-11]}, %{start_date: ~D[2025-05-18], end_date: ~D[2025-05-18]} ], - derived_limits: [%{start_stop_id: "70036", end_stop_id: "70026"}] + derived_limits: [%{start_stop_id: "70027", end_stop_id: "70036"}] } ] @@ -308,7 +308,7 @@ defmodule Arrow.Hastus.ExportUploadTest do end @tag export: "2025-AprilGLX-vehicle-v1.zip" - @tag build_gtfs_line: "line-Green" + @tag gtfs_line: "line-Green" test "a complex GL export", %{export: export} do data = ExportUpload.extract_data_from_upload(%{path: "#{@export_dir}/#{export}"}, "uid") @@ -317,16 +317,16 @@ defmodule Arrow.Hastus.ExportUploadTest do name: "LRV22025-hlb25gv6-Saturday-01-1", service_dates: [%{start_date: ~D[2025-04-26], end_date: ~D[2025-04-26]}], derived_limits: [ - %{start_stop_id: "70504", end_stop_id: "70202"}, - %{start_stop_id: "70512", end_stop_id: "70202"} + %{start_stop_id: "70201", end_stop_id: "70503"}, + %{start_stop_id: "70201", end_stop_id: "70511"} ] }, %{ name: "LRV22025-hlb25gv7-Sunday-01-1", service_dates: [%{start_date: ~D[2025-04-27], end_date: ~D[2025-04-27]}], derived_limits: [ - %{start_stop_id: "70504", end_stop_id: "70202"}, - %{start_stop_id: "70512", end_stop_id: "70202"} + %{start_stop_id: "70201", end_stop_id: "70503"}, + %{start_stop_id: "70201", end_stop_id: "70511"} ] } ] @@ -379,7 +379,7 @@ defmodule Arrow.Hastus.ExportUploadTest do end @tag export: "2025-spring-GLBabcockNorthStation-v2.zip" - @tag build_gtfs_line: "line-Green" + @tag gtfs_line: "line-Green" test "an especially complex GL export", %{export: export} do data = ExportUpload.extract_data_from_upload(%{path: "#{@export_dir}/#{export}"}, "uid") @@ -387,59 +387,43 @@ defmodule Arrow.Hastus.ExportUploadTest do %{ name: "LRV22025-hlb25ge1-Weekday-01", service_dates: [%{start_date: ~D[2025-06-04], end_date: ~D[2025-06-04]}], - # These limits are technically correct as they "sum up" to the limits - # we expect, but there's a lot of overlap. - # We'll try to make some followup tweaks to improve this, but it's - # acceptable for now. derived_limits: [ - # North Station to Gov Ctr - from ? - %{start_stop_id: "70206", end_stop_id: "70202"}, - # Gov Ctr to Boylston - from ? - %{start_stop_id: "70202", end_stop_id: "70159"}, - # Copley to Heath St - from Green-E - %{start_stop_id: "70155", end_stop_id: "70260"}, - # Gov Ctr to Babcock - from Green-B - %{start_stop_id: "70202", end_stop_id: "170137"}, - # Gov Ctr to Kenmore - from Green-C - %{start_stop_id: "70202", end_stop_id: "70151"}, - # North Station to Kenmore - from Green-D - %{start_stop_id: "70206", end_stop_id: "70151"}, - # North Station to Heath St - from Green-E - %{start_stop_id: "70206", end_stop_id: "70260"} + # Babcock to Copley + %{start_stop_id: "170136", end_stop_id: "70154"}, + # Babcock to North Station + %{start_stop_id: "170136", end_stop_id: "70205"}, + # Heath St to North Station + %{start_stop_id: "70260", end_stop_id: "70205"} ] }, %{ name: "LRV22025-hlb25gn6-Saturday-01", service_dates: [%{start_date: ~D[2025-06-07], end_date: ~D[2025-06-07]}], derived_limits: [ - # Gov Ctr to Babcock - from Green-B - %{start_stop_id: "70202", end_stop_id: "170137"}, - # Gov Ctr to Kenmore - from Green-C - %{start_stop_id: "70202", end_stop_id: "70151"}, - # North Station to Kenmore - from Green-D - %{start_stop_id: "70206", end_stop_id: "70151"}, - # North Station to Heath St - from Green-E - %{start_stop_id: "70206", end_stop_id: "70260"} + # Babcock to North Station + %{start_stop_id: "170136", end_stop_id: "70205"}, + # Heath St to North station + %{start_stop_id: "70260", end_stop_id: "70205"} ] }, %{ name: "LRV22025-hlb25gn7-Sunday-01", service_dates: [%{start_date: ~D[2025-06-08], end_date: ~D[2025-06-08]}], derived_limits: [ - %{start_stop_id: "70202", end_stop_id: "170137"}, - %{start_stop_id: "70202", end_stop_id: "70151"}, - %{start_stop_id: "70206", end_stop_id: "70151"}, - %{start_stop_id: "70206", end_stop_id: "70260"} + # Babcock to North Station + %{start_stop_id: "170136", end_stop_id: "70205"}, + # Heath St to North station + %{start_stop_id: "70260", end_stop_id: "70205"} ] }, %{ name: "LRV22025-hlb35gn1-Weekday-01", service_dates: [%{start_date: ~D[2025-06-05], end_date: ~D[2025-06-06]}], derived_limits: [ - %{start_stop_id: "70202", end_stop_id: "170137"}, - %{start_stop_id: "70202", end_stop_id: "70151"}, - %{start_stop_id: "70206", end_stop_id: "70151"}, - %{start_stop_id: "70206", end_stop_id: "70260"} + # Babcock to North Station + %{start_stop_id: "170136", end_stop_id: "70205"}, + # Heath St to North station + %{start_stop_id: "70260", end_stop_id: "70205"} ] } ] @@ -483,181 +467,4 @@ defmodule Arrow.Hastus.ExportUploadTest do }}} = data end end - - ############################################### - # TODO: Need to also set up parent station ID # - # relations for all platform IDs... # - ############################################### - - defp build_gtfs(%{skip_build_gtfs: true}), do: :ok - defp build_gtfs(%{build_gtfs_line: line}), do: build_gtfs(line_params(line)) - - defp build_gtfs(context) do - line = insert(:gtfs_line, id: context.line_id) - service = insert(:gtfs_service, id: "canonical") - - true = length(context.route_ids) == length(context.stop_sequences) - true = tuple_size(context.direction_descs) == 2 - - [context.route_ids, context.stop_sequences] - |> Enum.zip() - |> Enum.each(&insert_canonical(&1, line, service, context.direction_descs)) - end - - defp insert_canonical( - {route_id, {stop_sequence0, stop_sequence1}}, - line, - service, - {dir_desc0, dir_desc1} - ) do - route = insert(:gtfs_route, id: route_id, line: line) - - direction0 = insert(:gtfs_direction, direction_id: 0, route: route, desc: dir_desc0) - direction1 = insert(:gtfs_direction, direction_id: 1, route: route, desc: dir_desc1) - - trip_id0 = ExMachina.sequence("representative_trip") - trip_id1 = ExMachina.sequence("representative_trip") - - route_pattern0 = - insert(:gtfs_route_pattern, - route: route, - representative_trip_id: trip_id0, - direction_id: 0 - ) - - route_pattern1 = - insert(:gtfs_route_pattern, - route: route, - representative_trip_id: trip_id1, - direction_id: 1 - ) - - trip0 = - insert(:gtfs_trip, - id: trip_id0, - service: service, - route: route, - route_pattern: route_pattern0, - direction_id: 0, - directions: [direction0, direction1] - ) - - trip1 = - insert(:gtfs_trip, - id: trip_id1, - service: service, - route: route, - route_pattern: route_pattern1, - direction_id: 1, - directions: [direction0, direction1] - ) - - stop_sequence0 - |> Enum.with_index(1) - |> Enum.each(fn {stop_id, stop_sequence} -> - stop = maybe_insert(:gtfs_stop, [id: stop_id], Arrow.Gtfs.Stop) - - insert(:gtfs_stop_time, - trip: trip0, - stop_sequence: stop_sequence, - stop: stop - ) - end) - - stop_sequence1 - |> Enum.with_index(1) - |> Enum.each(fn {stop_id, stop_sequence} -> - stop = maybe_insert(:gtfs_stop, [id: stop_id], Arrow.Gtfs.Stop) - - insert(:gtfs_stop_time, - trip: trip1, - stop_sequence: stop_sequence, - stop: stop - ) - end) - end - - defp maybe_insert(factory, attrs, schema_mod) do - insert(factory, attrs) - rescue - e in [Ecto.ConstraintError] -> - if String.contains?(e.message, "unique_constraint") do - id = Keyword.fetch!(attrs, :id) - Arrow.Repo.one!(from(schema_mod, where: [id: ^id])) - else - reraise e, __STACKTRACE__ - end - end - - defp line_params("line-Blue") do - %{ - line_id: "line-Blue", - direction_descs: {"West", "East"}, - route_ids: ["Blue"], - stop_sequences: [ - { - ~w[70059 70057 70055 70053 70051 70049 70047 70045 70043 70041 70039 70838], - ~w[70038 70040 70042 70044 70046 70048 70050 70052 70054 70056 70058 70060] - } - ] - } - end - - defp line_params("line-Orange") do - %{ - line_id: "line-Orange", - direction_descs: {"South", "North"}, - route_ids: ["Orange"], - stop_sequences: [ - { - ~w[70036 70034 70032 70278 70030 70028 70026 70024 70022 70020 70018 70016 70014 70012 70010 70008 70006 70004 70002 70001], - ~w[70001 70003 70005 70007 70009 70011 70013 70015 70017 70019 70021 70023 70025 70027 70029 70031 70279 70033 70035 70036] - } - ] - } - end - - defp line_params("line-Red") do - %{ - line_id: "line-Red", - direction_descs: {"South", "North"}, - route_ids: ["Red"], - stop_sequences: [ - { - ~w[70061 70063 70065 70067 70069 70071 70073 70075 70077 70079 70081 70083 70095 70097 70099 70101 70103 70105], - ~w[70105 70104 70102 70100 70098 70096 70084 70082 70080 70078 70076 70074 70072 70070 70068 70066 70064 70061] - }, - { - ~w[70061 70063 70065 70067 70069 70071 70073 70075 70077 70079 70081 70083 70085 70087 70089 70091 70093], - ~w[70094 70092 70090 70088 70086 70084 70082 70080 70078 70076 70074 70072 70070 70068 70066 70064 70061] - } - ] - } - end - - defp line_params("line-Green") do - %{ - line_id: "line-Green", - direction_descs: {"West", "East"}, - route_ids: ["Green-B", "Green-C", "Green-D", "Green-E"], - stop_sequences: [ - { - ~w[70202 70196 70159 70157 70155 70153 71151 70149 70147 70145 170141 170137 70135 70131 70129 70127 70125 70121 70117 70115 70113 70111 70107], - ~w[70106 70110 70112 70114 70116 70120 70124 70126 70128 70130 70134 170136 170140 70144 70146 70148 71150 70152 70154 70156 70158 70200 70201] - }, - { - ~w[70202 70197 70159 70157 70155 70153 70151 70211 70213 70215 70217 70219 70223 70225 70227 70229 70231 70233 70235 70237], - ~w[70238 70236 70234 70232 70230 70228 70226 70224 70220 70218 70216 70214 70212 70150 70152 70154 70156 70158 70200 70201] - }, - { - ~w[70504 70502 70208 70206 70204 70202 70198 70159 70157 70155 70153 70151 70187 70183 70181 70179 70177 70175 70173 70171 70169 70167 70165 70163 70161], - ~w[70160 70162 70164 70166 70168 70170 70172 70174 70176 70178 70180 70182 70186 70150 70152 70154 70156 70158 70200 70201 70203 70205 70207 70501 70503] - }, - { - ~w[70512 70510 70508 70506 70514 70502 70208 70206 70204 70202 70199 70159 70157 70155 70239 70241 70243 70245 70247 70249 70251 70253 70255 70257 70260], - ~w[70260 70258 70256 70254 70252 70250 70248 70246 70244 70242 70240 70154 70156 70158 70200 70201 70203 70205 70207 70501 70513 70505 70507 70509 70511] - } - ] - } - end end diff --git a/test/support/fixtures/gtfs_line_fixtures.ex b/test/support/fixtures/gtfs_line_fixtures.ex new file mode 100644 index 000000000..e26a4dc0d --- /dev/null +++ b/test/support/fixtures/gtfs_line_fixtures.ex @@ -0,0 +1,543 @@ +defmodule Arrow.GtfsLineFixtures do + @moduledoc """ + This module defines a `build_gtfs/1` helper to + insert routes, trips, stops, etc into Arrow's + `gtfs_*` DB tables. + """ + import Arrow.Factory + import Ecto.Query + + @doc """ + Inserts canonical data about a subway line into the DB. + + This inserts records in the following tables: + - gtfs_lines + - gtfs_services + - gtfs_routes + - gtfs_directions + - gtfs_route_patterns + - gtfs_trips + - gtfs_stops + - gtfs_stop_times + + Intended for use with `setup` and a tag: + + describe "some_fn_with_gtfs_deps/1" do + setup {Arrow.GtfsLineFixtures, :build_gtfs_line} + + # Insert Green Line data for this test. + @tag gtfs_line: "line-Green" + test "handles Green Line scenario" do + # This test has access to Green Line canonical data. + end + + # If a test in the block doesn't need line data, use tag `:skip_build_gtfs_line`. + @tag :skip_build_gtfs_line + test "raises exception on bad argument" do + ... + end + end + """ + def build_gtfs_line(%{skip_build_gtfs_line: true}), do: :ok + def build_gtfs_line(%{gtfs_line: line}), do: do_build_gtfs_line(line_params(line)) + + defp do_build_gtfs_line(context) do + line = insert(:gtfs_line, id: context.line_id) + service = insert(:gtfs_service, id: "canonical") + + true = length(context.route_ids) == length(context.stop_sequences) + true = tuple_size(context.direction_descs) == 2 + + [context.route_ids, context.stop_sequences] + |> Enum.zip() + |> Enum.each(&insert_canonical(&1, line, service, context.direction_descs)) + end + + defp insert_canonical( + {route_id, stop_sequences}, + line, + service, + {dir_desc0, dir_desc1} + ) do + route = insert(:gtfs_route, id: route_id, line: line) + + direction0 = insert(:gtfs_direction, direction_id: 0, route: route, desc: dir_desc0) + direction1 = insert(:gtfs_direction, direction_id: 1, route: route, desc: dir_desc1) + + trip_ids = + {ExMachina.sequence("representative_trip"), ExMachina.sequence("representative_trip")} + + Enum.each(0..1, fn direction_id -> + trip_id = elem(trip_ids, direction_id) + + route_pattern = + insert(:gtfs_route_pattern, + route: route, + representative_trip_id: trip_id, + direction_id: direction_id + ) + + trip = + insert(:gtfs_trip, + id: trip_id, + service: service, + route: route, + route_pattern: route_pattern, + direction_id: direction_id, + directions: [direction0, direction1] + ) + + stop_sequences + |> elem(direction_id) + |> Enum.with_index(1) + |> Enum.each(fn {{stop_id, parent_id}, stop_sequence} -> + parent = maybe_insert(:gtfs_stop, [id: parent_id], Arrow.Gtfs.Stop) + stop = maybe_insert(:gtfs_stop, [id: stop_id, parent_station: parent], Arrow.Gtfs.Stop) + + insert(:gtfs_stop_time, + trip: trip, + stop_sequence: stop_sequence, + stop: stop + ) + end) + end) + end + + defp maybe_insert(factory, attrs, schema_mod) do + insert(factory, attrs) + rescue + e in [Ecto.ConstraintError] -> + if String.contains?(e.message, "unique_constraint") do + id = Keyword.fetch!(attrs, :id) + Arrow.Repo.one!(from(schema_mod, where: [id: ^id])) + else + reraise e, __STACKTRACE__ + end + end + + # Stop sequence data was constructed with: + # + # Arrow.Repo.all( + # from t in Arrow.Gtfs.Trip, + # # where: t.direction_id == 1, + # where: t.service_id == "canonical", + # where: t.route_id in ^route_ids, + # join: st in Arrow.Gtfs.StopTime, + # on: t.id == st.trip_id, + # join: s in Arrow.Gtfs.Stop, + # on: st.stop_id == s.id, + # join: ps in Arrow.Gtfs.Stop, + # on: ps.id == s.parent_station_id, + # order_by: [t.id, t.direction_id, st.stop_sequence], + # select: %{route_id: t.route_id, direction_id: t.direction_id, trip_id: t.id, stop_id: st.stop_id, parent_id: ps.id} + # ) + # |> Stream.chunk_by(&{&1.direction_id, &1.trip_id}) + # |> Enum.map(fn stops -> Enum.map(stops, &{&1.stop_id, &1.parent_id}) end) + defp line_params("line-Blue") do + %{ + line_id: "line-Blue", + direction_descs: {"West", "East"}, + route_ids: ["Blue"], + stop_sequences: [ + { + [ + {"70059", "place-wondl"}, + {"70057", "place-rbmnl"}, + {"70055", "place-bmmnl"}, + {"70053", "place-sdmnl"}, + {"70051", "place-orhte"}, + {"70049", "place-wimnl"}, + {"70047", "place-aport"}, + {"70045", "place-mvbcl"}, + {"70043", "place-aqucl"}, + {"70041", "place-state"}, + {"70039", "place-gover"}, + {"70838", "place-bomnl"} + ], + [ + {"70038", "place-bomnl"}, + {"70040", "place-gover"}, + {"70042", "place-state"}, + {"70044", "place-aqucl"}, + {"70046", "place-mvbcl"}, + {"70048", "place-aport"}, + {"70050", "place-wimnl"}, + {"70052", "place-orhte"}, + {"70054", "place-sdmnl"}, + {"70056", "place-bmmnl"}, + {"70058", "place-rbmnl"}, + {"70060", "place-wondl"} + ] + } + ] + } + end + + defp line_params("line-Orange") do + %{ + line_id: "line-Orange", + direction_descs: {"South", "North"}, + route_ids: ["Orange"], + stop_sequences: [ + { + [ + {"70036", "place-ogmnl"}, + {"70034", "place-mlmnl"}, + {"70032", "place-welln"}, + {"70278", "place-astao"}, + {"70030", "place-sull"}, + {"70028", "place-ccmnl"}, + {"70026", "place-north"}, + {"70024", "place-haecl"}, + {"70022", "place-state"}, + {"70020", "place-dwnxg"}, + {"70018", "place-chncl"}, + {"70016", "place-tumnl"}, + {"70014", "place-bbsta"}, + {"70012", "place-masta"}, + {"70010", "place-rugg"}, + {"70008", "place-rcmnl"}, + {"70006", "place-jaksn"}, + {"70004", "place-sbmnl"}, + {"70002", "place-grnst"}, + {"70001", "place-forhl"} + ], + [ + {"70001", "place-forhl"}, + {"70003", "place-grnst"}, + {"70005", "place-sbmnl"}, + {"70007", "place-jaksn"}, + {"70009", "place-rcmnl"}, + {"70011", "place-rugg"}, + {"70013", "place-masta"}, + {"70015", "place-bbsta"}, + {"70017", "place-tumnl"}, + {"70019", "place-chncl"}, + {"70021", "place-dwnxg"}, + {"70023", "place-state"}, + {"70025", "place-haecl"}, + {"70027", "place-north"}, + {"70029", "place-ccmnl"}, + {"70031", "place-sull"}, + {"70279", "place-astao"}, + {"70033", "place-welln"}, + {"70035", "place-mlmnl"}, + {"70036", "place-ogmnl"} + ] + } + ] + } + end + + defp line_params("line-Red") do + %{ + line_id: "line-Red", + direction_descs: {"South", "North"}, + route_ids: ["Red"], + stop_sequences: [ + { + [ + {"70061", "place-alfcl"}, + {"70063", "place-davis"}, + {"70065", "place-portr"}, + {"70067", "place-harsq"}, + {"70069", "place-cntsq"}, + {"70071", "place-knncl"}, + {"70073", "place-chmnl"}, + {"70075", "place-pktrm"}, + {"70077", "place-dwnxg"}, + {"70079", "place-sstat"}, + {"70081", "place-brdwy"}, + {"70083", "place-andrw"}, + {"70095", "place-jfk"}, + {"70097", "place-nqncy"}, + {"70099", "place-wlsta"}, + {"70101", "place-qnctr"}, + {"70103", "place-qamnl"}, + {"70105", "place-brntn"} + ], + [ + {"70105", "place-brntn"}, + {"70104", "place-qamnl"}, + {"70102", "place-qnctr"}, + {"70100", "place-wlsta"}, + {"70098", "place-nqncy"}, + {"70096", "place-jfk"}, + {"70084", "place-andrw"}, + {"70082", "place-brdwy"}, + {"70080", "place-sstat"}, + {"70078", "place-dwnxg"}, + {"70076", "place-pktrm"}, + {"70074", "place-chmnl"}, + {"70072", "place-knncl"}, + {"70070", "place-cntsq"}, + {"70068", "place-harsq"}, + {"70066", "place-portr"}, + {"70064", "place-davis"}, + {"70061", "place-alfcl"} + ] + }, + { + [ + {"70061", "place-alfcl"}, + {"70063", "place-davis"}, + {"70065", "place-portr"}, + {"70067", "place-harsq"}, + {"70069", "place-cntsq"}, + {"70071", "place-knncl"}, + {"70073", "place-chmnl"}, + {"70075", "place-pktrm"}, + {"70077", "place-dwnxg"}, + {"70079", "place-sstat"}, + {"70081", "place-brdwy"}, + {"70083", "place-andrw"}, + {"70085", "place-jfk"}, + {"70087", "place-shmnl"}, + {"70089", "place-fldcr"}, + {"70091", "place-smmnl"}, + {"70093", "place-asmnl"} + ], + [ + {"70094", "place-asmnl"}, + {"70092", "place-smmnl"}, + {"70090", "place-fldcr"}, + {"70088", "place-shmnl"}, + {"70086", "place-jfk"}, + {"70084", "place-andrw"}, + {"70082", "place-brdwy"}, + {"70080", "place-sstat"}, + {"70078", "place-dwnxg"}, + {"70076", "place-pktrm"}, + {"70074", "place-chmnl"}, + {"70072", "place-knncl"}, + {"70070", "place-cntsq"}, + {"70068", "place-harsq"}, + {"70066", "place-portr"}, + {"70064", "place-davis"}, + {"70061", "place-alfcl"} + ] + } + ] + } + end + + defp line_params("line-Green") do + %{ + line_id: "line-Green", + direction_descs: {"West", "East"}, + route_ids: ["Green-B", "Green-C", "Green-D", "Green-E"], + stop_sequences: [ + { + [ + {"70202", "place-gover"}, + {"70196", "place-pktrm"}, + {"70159", "place-boyls"}, + {"70157", "place-armnl"}, + {"70155", "place-coecl"}, + {"70153", "place-hymnl"}, + {"71151", "place-kencl"}, + {"70149", "place-bland"}, + {"70147", "place-buest"}, + {"70145", "place-bucen"}, + {"170141", "place-amory"}, + {"170137", "place-babck"}, + {"70135", "place-brico"}, + {"70131", "place-harvd"}, + {"70129", "place-grigg"}, + {"70127", "place-alsgr"}, + {"70125", "place-wrnst"}, + {"70121", "place-wascm"}, + {"70117", "place-sthld"}, + {"70115", "place-chswk"}, + {"70113", "place-chill"}, + {"70111", "place-sougr"}, + {"70107", "place-lake"} + ], + [ + {"70106", "place-lake"}, + {"70110", "place-sougr"}, + {"70112", "place-chill"}, + {"70114", "place-chswk"}, + {"70116", "place-sthld"}, + {"70120", "place-wascm"}, + {"70124", "place-wrnst"}, + {"70126", "place-alsgr"}, + {"70128", "place-grigg"}, + {"70130", "place-harvd"}, + {"70134", "place-brico"}, + {"170136", "place-babck"}, + {"170140", "place-amory"}, + {"70144", "place-bucen"}, + {"70146", "place-buest"}, + {"70148", "place-bland"}, + {"71150", "place-kencl"}, + {"70152", "place-hymnl"}, + {"70154", "place-coecl"}, + {"70156", "place-armnl"}, + {"70158", "place-boyls"}, + {"70200", "place-pktrm"}, + {"70201", "place-gover"} + ] + }, + { + [ + {"70202", "place-gover"}, + {"70197", "place-pktrm"}, + {"70159", "place-boyls"}, + {"70157", "place-armnl"}, + {"70155", "place-coecl"}, + {"70153", "place-hymnl"}, + {"70151", "place-kencl"}, + {"70211", "place-smary"}, + {"70213", "place-hwsst"}, + {"70215", "place-kntst"}, + {"70217", "place-stpul"}, + {"70219", "place-cool"}, + {"70223", "place-sumav"}, + {"70225", "place-bndhl"}, + {"70227", "place-fbkst"}, + {"70229", "place-bcnwa"}, + {"70231", "place-tapst"}, + {"70233", "place-denrd"}, + {"70235", "place-engav"}, + {"70237", "place-clmnl"} + ], + [ + {"70238", "place-clmnl"}, + {"70236", "place-engav"}, + {"70234", "place-denrd"}, + {"70232", "place-tapst"}, + {"70230", "place-bcnwa"}, + {"70228", "place-fbkst"}, + {"70226", "place-bndhl"}, + {"70224", "place-sumav"}, + {"70220", "place-cool"}, + {"70218", "place-stpul"}, + {"70216", "place-kntst"}, + {"70214", "place-hwsst"}, + {"70212", "place-smary"}, + {"70150", "place-kencl"}, + {"70152", "place-hymnl"}, + {"70154", "place-coecl"}, + {"70156", "place-armnl"}, + {"70158", "place-boyls"}, + {"70200", "place-pktrm"}, + {"70201", "place-gover"} + ] + }, + { + [ + {"70504", "place-unsqu"}, + {"70502", "place-lech"}, + {"70208", "place-spmnl"}, + {"70206", "place-north"}, + {"70204", "place-haecl"}, + {"70202", "place-gover"}, + {"70198", "place-pktrm"}, + {"70159", "place-boyls"}, + {"70157", "place-armnl"}, + {"70155", "place-coecl"}, + {"70153", "place-hymnl"}, + {"70151", "place-kencl"}, + {"70187", "place-fenwy"}, + {"70183", "place-longw"}, + {"70181", "place-bvmnl"}, + {"70179", "place-brkhl"}, + {"70177", "place-bcnfd"}, + {"70175", "place-rsmnl"}, + {"70173", "place-chhil"}, + {"70171", "place-newto"}, + {"70169", "place-newtn"}, + {"70167", "place-eliot"}, + {"70165", "place-waban"}, + {"70163", "place-woodl"}, + {"70161", "place-river"} + ], + [ + {"70160", "place-river"}, + {"70162", "place-woodl"}, + {"70164", "place-waban"}, + {"70166", "place-eliot"}, + {"70168", "place-newtn"}, + {"70170", "place-newto"}, + {"70172", "place-chhil"}, + {"70174", "place-rsmnl"}, + {"70176", "place-bcnfd"}, + {"70178", "place-brkhl"}, + {"70180", "place-bvmnl"}, + {"70182", "place-longw"}, + {"70186", "place-fenwy"}, + {"70150", "place-kencl"}, + {"70152", "place-hymnl"}, + {"70154", "place-coecl"}, + {"70156", "place-armnl"}, + {"70158", "place-boyls"}, + {"70200", "place-pktrm"}, + {"70201", "place-gover"}, + {"70203", "place-haecl"}, + {"70205", "place-north"}, + {"70207", "place-spmnl"}, + {"70501", "place-lech"}, + {"70503", "place-unsqu"} + ] + }, + { + [ + {"70512", "place-mdftf"}, + {"70510", "place-balsq"}, + {"70508", "place-mgngl"}, + {"70506", "place-gilmn"}, + {"70514", "place-esomr"}, + {"70502", "place-lech"}, + {"70208", "place-spmnl"}, + {"70206", "place-north"}, + {"70204", "place-haecl"}, + {"70202", "place-gover"}, + {"70199", "place-pktrm"}, + {"70159", "place-boyls"}, + {"70157", "place-armnl"}, + {"70155", "place-coecl"}, + {"70239", "place-prmnl"}, + {"70241", "place-symcl"}, + {"70243", "place-nuniv"}, + {"70245", "place-mfa"}, + {"70247", "place-lngmd"}, + {"70249", "place-brmnl"}, + {"70251", "place-fenwd"}, + {"70253", "place-mispk"}, + {"70255", "place-rvrwy"}, + {"70257", "place-bckhl"}, + {"70260", "place-hsmnl"} + ], + [ + {"70260", "place-hsmnl"}, + {"70258", "place-bckhl"}, + {"70256", "place-rvrwy"}, + {"70254", "place-mispk"}, + {"70252", "place-fenwd"}, + {"70250", "place-brmnl"}, + {"70248", "place-lngmd"}, + {"70246", "place-mfa"}, + {"70244", "place-nuniv"}, + {"70242", "place-symcl"}, + {"70240", "place-prmnl"}, + {"70154", "place-coecl"}, + {"70156", "place-armnl"}, + {"70158", "place-boyls"}, + {"70200", "place-pktrm"}, + {"70201", "place-gover"}, + {"70203", "place-haecl"}, + {"70205", "place-north"}, + {"70207", "place-spmnl"}, + {"70501", "place-lech"}, + {"70513", "place-esomr"}, + {"70505", "place-gilmn"}, + {"70507", "place-mgngl"}, + {"70509", "place-balsq"}, + {"70511", "place-mdftf"} + ] + } + ] + } + end +end From 62f6ceceaa554bc2ac1e36959602adf9afc688ad Mon Sep 17 00:00:00 2001 From: Jon Zimbel Date: Mon, 23 Jun 2025 07:21:42 -0400 Subject: [PATCH 3/3] Appease credo: reduce function body nesting --- lib/arrow/polytree_helper.ex | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/lib/arrow/polytree_helper.ex b/lib/arrow/polytree_helper.ex index fc1b07cc6..16280e82a 100644 --- a/lib/arrow/polytree_helper.ex +++ b/lib/arrow/polytree_helper.ex @@ -53,22 +53,24 @@ defmodule Arrow.PolytreeHelper do do_leftmost(tree, prev_ids, acc, for(id <- ids, into: visited, do: id)) end - defp build_paths(tree, paths) do - if Enum.all?(paths, &match?({:done, _}, &1)) do - Enum.map(paths, fn {:done, path} -> Enum.reverse(path) end) - else - paths - |> Enum.flat_map(fn - {:done, path} -> - [{:done, path}] + defp build_paths(tree, paths, completed \\ []) - [id | _] = path -> - case UPTree.edges_for_id(tree, id).next do - [] -> [{:done, path}] - next -> Enum.map(next, &[&1 | path]) - end - end) - |> then(&build_paths(tree, &1)) - end + defp build_paths(_tree, [], completed) do + completed + |> Enum.reverse() + |> Enum.map(&Enum.reverse/1) + end + + defp build_paths(tree, paths, completed) do + paths_with_next = + Enum.map(paths, fn [id | _] = path -> {path, UPTree.edges_for_id(tree, id).next} end) + + {new_completed, paths} = + Enum.split_with(paths_with_next, &match?({_path, []}, &1)) + + new_completed = Enum.map(new_completed, fn {path, _} -> path end) + new_paths = Enum.flat_map(paths, fn {path, next} -> Enum.map(next, &[&1 | path]) end) + + build_paths(tree, new_paths, Enum.reverse(new_completed) ++ completed) end end