Import shapes.txt and linked_datasets.txt, and fix CR CR LF feeds

521323d · Gabriel Morell · 2026-08-10 22:10

23 files +752 -5
Message
{commit_body(@commit)}

Files changed

modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+135 −5
@@ -701,10 +701,50 @@ end
701 701 |> String.trim()
702 702 end
703 703
704 + # Postgres picks its newline style from the first terminator it sees. Given
705 + # CR CR LF -- which Caltrain's trips.txt ships -- it decides the file is
706 + # CR-terminated and then reads every second line as empty, failing with
707 + # "missing data for column". Collapsing the CR run before each LF fixes that,
708 + # and mapping a lone CR to LF keeps classic CR-only files loading.
709 + defp rewrite_newlines(binary) do
710 + binary
711 + |> String.replace(~r/\r+\n/, "\n")
712 + |> String.replace("\r", "\n")
713 + end
714 +
715 + # A CR run at the end of a chunk may be finished by a LF at the start of the
716 + # next one, so it is held back rather than converted early.
717 + defp hold_trailing_cr(binary) do
718 + case Regex.run(~r/\r+\z/, binary, return: :index) do
719 + [{start, len}] -> {binary_part(binary, 0, start), binary_part(binary, start, len)}
720 + nil -> {binary, ""}
721 + end
722 + end
723 +
724 + @doc false
725 + # Exposed only so the normaliser can be exercised directly; the importer
726 + # calls the private function.
727 + def normalize_newlines_for_test(contents), do: normalize_newlines(contents)
728 +
729 + defp normalize_newlines(contents) do
730 + Stream.transform(
731 + contents,
732 + fn -> "" end,
733 + fn chunk, carry ->
734 + {body, pending} = hold_trailing_cr(carry <> IO.iodata_to_binary(chunk))
735 + {[rewrite_newlines(body)], pending}
736 + end,
737 + fn carry -> {[rewrite_newlines(carry)], ""} end,
738 + fn _carry -> :ok end
739 + )
740 + end
741 +
704 742 defp write_file(contents, type, id, pid) do
705 743 datetime = NaiveDateTime.local_now()
706 744 Logger.info("GTFS::#{id} writing #{type} (c)")
707 745
746 + contents = normalize_newlines(contents)
747 +
708 748 cols_j =
709 749 contents
710 750 |> Stream.chunk_every(500)
@@ -732,6 +772,7 @@ end
732 772 :stops -> RoomSanctum.Storage.truncate_stop(id)
733 773 :stop_times -> RoomSanctum.Storage.truncate_stop_time(id)
734 774 :trips -> RoomSanctum.Storage.truncate_trip(id)
775 + :shapes -> RoomSanctum.Storage.truncate_shape(id)
735 776 _ -> :ok
736 777 end
737 778
@@ -745,6 +786,7 @@ end
745 786 :stops -> {:gtfs_stops, [GTFS.Stop |> get_cols(cols_j_plus)], GTFS.Stop |> get_cols_pgtypes(cols_j_plus)}
746 787 :stop_times -> {:gtfs_stop_times, [GTFS.StopTime |> get_cols(cols_j_plus)], GTFS.StopTime |> get_cols_pgtypes(cols_j_plus)}
747 788 :trips -> {:gtfs_trips, [GTFS.Trip |> get_cols(cols_j_plus)], GTFS.Trip |> get_cols_pgtypes(cols_j_plus)}
789 + :shapes -> {:gtfs_shapes, [GTFS.Shape |> get_cols(cols_j_plus)], GTFS.Shape |> get_cols_pgtypes(cols_j_plus)}
748 790 end
749 791
750 792 # IO.inspect({table, columns, pg_cols})
@@ -937,6 +979,76 @@ end
937 979 DateTime.utc_now()
938 980 end
939 981
982 +
983 + # linked_datasets.txt associates GTFS-RT feeds with the schedule that
984 + # describes them, so a feed that ships one can wire up its own realtime URLs.
985 + #
986 + # Publishers disagree on the details, so this reads by header name rather
987 + # than position: MBTA ships five columns and writes the flags as 1/0, while
988 + # Caltrain ships seven and writes true/false.
989 + @rt_flag_fields [
990 + {"trip_updates", :url_rt_tu},
991 + {"vehicle_positions", :url_rt_vp},
992 + {"service_alerts", :url_rt_sa}
993 + ]
994 +
995 + def parse_linked_datasets(csv) do
996 + case String.split(csv, ~r/\r?\n/, trim: true) do
997 + [] ->
998 + []
999 +
1000 + [header | rows] ->
1001 + keys = header |> String.split(",") |> Enum.map(&normalize_header/1)
1002 +
1003 + Enum.map(rows, fn row ->
1004 + keys
1005 + |> Enum.zip(String.split(row, ","))
1006 + |> Map.new(fn {k, v} -> {k, String.trim(v)} end)
1007 + end)
1008 + end
1009 + end
1010 +
1011 + defp rt_flag?(value) do
1012 + String.downcase(String.trim(value || "")) in ["1", "true", "yes"]
1013 + end
1014 +
1015 + # A feed behind a key cannot be fetched with what we have, so it is left for
1016 + # the user to fill in by hand rather than saved as a URL that always 401s.
1017 + defp rt_open?(row) do
1018 + case Map.get(row, "authentication_type") do
1019 + nil -> true
1020 + value -> String.downcase(String.trim(value)) in ["", "0", "none"]
1021 + end
1022 + end
1023 +
1024 + def linked_dataset_urls(rows) do
1025 + Enum.reduce(@rt_flag_fields, %{}, fn {flag, field}, acc ->
1026 + row = Enum.find(rows, fn r -> rt_flag?(Map.get(r, flag)) and rt_open?(r) end)
1027 + url = row && String.trim(Map.get(row, "url") || "")
1028 +
1029 + if url in [nil, ""], do: acc, else: Map.put(acc, field, url)
1030 + end)
1031 + end
1032 +
1033 + # Only ever fills blanks: a URL already in the config was put there
1034 + # deliberately and outranks whatever the feed advertises.
1035 + defp apply_linked_datasets(cfg, urls) do
1036 + fill =
1037 + urls
1038 + |> Enum.reject(fn {field, _url} ->
1039 + existing = Map.get(cfg.config, field)
1040 + is_binary(existing) and String.trim(existing) != ""
1041 + end)
1042 + |> Map.new()
1043 +
1044 + if fill == %{} do
1045 + :ok
1046 + else
1047 + Logger.info("GTFS::#{cfg.id} linked_datasets supplied #{inspect(Map.keys(fill))}")
1048 + Configuration.update_source_config(cfg, fill)
1049 + end
1050 + end
1051 +
940 1052 defp file_to_atom(filename) do
941 1053 case filename do
942 1054 "agency.txt" ->
@@ -959,6 +1071,9 @@ end
959 1071
960 1072 "trips.txt" ->
961 1073 :trips
1074 +
1075 + "shapes.txt" ->
1076 + :shapes
962 1077 end
963 1078 end
964 1079
@@ -971,6 +1086,7 @@ end
971 1086 "stops.txt" -> 7
972 1087 "stop_times.txt" -> 8
973 1088 "trips.txt" -> 9
1089 + "shapes.txt" -> 10
974 1090 end
975 1091 end
976 1092
@@ -985,16 +1101,29 @@ end
985 1101 case cfg.enabled do
986 1102 true ->
987 1103 Logger.info("GTFS::#{state.id} updating static info")
988 − bcast(state.id, :downloading, 1, 9)
1104 + bcast(state.id, :downloading, 1, 10)
989 1105
990 1106 case HTTPoison.get(cfg.config.url, [], follow_redirect: true) do
991 1107 {:ok, result} ->
992 − bcast(state.id, :extracting, 2, 9)
1108 + bcast(state.id, :extracting, 2, 10)
993 1109
994 1110 case result.body |> Unzip.InMem.new() |> Unzip.new() do
995 1111 {:ok, unzip} ->
996 1112 files = Unzip.list_entries(unzip)
997 1113
1114 + case Enum.find(files, &(&1.file_name == "linked_datasets.txt")) do
1115 + nil ->
1116 + :ok
1117 +
1118 + entry ->
1119 + Unzip.file_stream!(unzip, entry.file_name)
1120 + |> Enum.to_list()
1121 + |> IO.iodata_to_binary()
1122 + |> parse_linked_datasets()
1123 + |> linked_dataset_urls()
1124 + |> then(&apply_linked_datasets(cfg, &1))
1125 + end
1126 +
998 1127 try do
999 1128 files
1000 1129 |> Enum.map(fn e ->
@@ -1007,11 +1136,12 @@ end
1007 1136 "stops.txt",
1008 1137 "stop_times.txt",
1009 1138 "trips.txt",
1139 + "shapes.txt",
1010 1140 ],
1011 1141 e.file_name
1012 1142 ) do
1013 1143
1014 − bcast(state.id, file_to_atom(e.file_name), file_to_order(e.file_name), 9)
1144 + bcast(state.id, file_to_atom(e.file_name), file_to_order(e.file_name), 10)
1015 1145
1016 1146 Unzip.file_stream!(unzip, e.file_name)
1017 1147 |> write_file(file_to_atom(e.file_name), state.id, nil)
@@ -1046,11 +1176,11 @@ end
1046 1176 case cfg.enabled do
1047 1177 true ->
1048 1178 Logger.info("GTFS::#{state.id} updating static info")
1049 − bcast(state.id, :downloading, 1, 9)
1179 + bcast(state.id, :downloading, 1, 10)
1050 1180
1051 1181 case HTTPoison.get(cfg.config.url) do
1052 1182 {:ok, result} ->
1053 − bcast(state.id, :extracting, 2, 9)
1183 + bcast(state.id, :extracting, 2, 10)
1054 1184
1055 1185 case result.body
1056 1186 |> :zip.unzip([:memory]) do
added apps/room_gtfs/test/linked_datasets_test.exs
+58 −0
@@ -0,0 +1,58 @@
1 +defmodule RoomGtfs.LinkedDatasetsTest do
2 + @moduledoc """
3 + linked_datasets.txt wires a GTFS feed to its own GTFS-RT endpoints. Publishers
4 + disagree on the spelling, so these are the shapes seen in the wild.
5 + """
6 + use ExUnit.Case, async: true
7 +
8 + describe "linked_datasets.txt" do
9 + test "reads MBTA's 1/0 flags across one row per feed" do
10 + csv = """
11 + url,trip_updates,vehicle_positions,service_alerts,authentication_type
12 + https://cdn.mbta.com/realtime/TripUpdates.pb,1,0,0,0
13 + https://cdn.mbta.com/realtime/VehiclePositions.pb,0,1,0,0
14 + https://cdn.mbta.com/realtime/Alerts.pb,0,0,1,0
15 + """
16 +
17 + urls = csv |> RoomGtfs.Worker.Static.parse_linked_datasets() |> RoomGtfs.Worker.Static.linked_dataset_urls()
18 +
19 + assert urls == %{
20 + url_rt_tu: "https://cdn.mbta.com/realtime/TripUpdates.pb",
21 + url_rt_vp: "https://cdn.mbta.com/realtime/VehiclePositions.pb",
22 + url_rt_sa: "https://cdn.mbta.com/realtime/Alerts.pb"
23 + }
24 + end
25 +
26 + test "reads Caltrain's true/false flags and extra columns" do
27 + csv = """
28 + url,trip_updates,vehicle_positions,service_alerts,authentication_type,authentication_info_url,api_key_parameter_name
29 + http://example.test/service_alerts.proto,false,false,true,none,,
30 + """
31 +
32 + urls = csv |> RoomGtfs.Worker.Static.parse_linked_datasets() |> RoomGtfs.Worker.Static.linked_dataset_urls()
33 +
34 + assert urls == %{url_rt_sa: "http://example.test/service_alerts.proto"}
35 + end
36 +
37 + test "a feed behind an api key is left alone" do
38 + csv = """
39 + url,trip_updates,vehicle_positions,service_alerts,authentication_type
40 + https://example.test/tu.pb,1,0,0,2
41 + https://example.test/vp.pb,0,1,0,0
42 + """
43 +
44 + urls = csv |> RoomGtfs.Worker.Static.parse_linked_datasets() |> RoomGtfs.Worker.Static.linked_dataset_urls()
45 +
46 + assert urls == %{url_rt_vp: "https://example.test/vp.pb"}
47 + refute Map.has_key?(urls, :url_rt_tu)
48 + end
49 +
50 + test "a header-only or empty file yields nothing" do
51 + assert RoomGtfs.Worker.Static.parse_linked_datasets("") |> RoomGtfs.Worker.Static.linked_dataset_urls() == %{}
52 +
53 + assert "url,trip_updates,vehicle_positions,service_alerts\n"
54 + |> RoomGtfs.Worker.Static.parse_linked_datasets()
55 + |> RoomGtfs.Worker.Static.linked_dataset_urls() == %{}
56 + end
57 + end
58 +end
added apps/room_gtfs/test/newline_normalisation_test.exs
+65 −0
@@ -0,0 +1,65 @@
1 +defmodule RoomGtfs.NewlineNormalisationTest do
2 + @moduledoc """
3 + Feeds disagree about line endings, and COPY is unforgiving about it: Postgres
4 + infers the newline style from the first terminator, so CR CR LF (Caltrain's
5 + trips.txt) reads as CR-terminated and every second line comes out empty.
6 +
7 + The normaliser is private, so these drive it the way the importer does --
8 + through a stream of chunks -- via the public entry point used in tests.
9 + """
10 + use ExUnit.Case, async: true
11 +
12 + alias RoomGtfs.Worker.Static
13 +
14 + defp normalize(chunks) do
15 + chunks
16 + |> Static.normalize_newlines_for_test()
17 + |> Enum.join()
18 + end
19 +
20 + test "leaves plain LF alone" do
21 + assert normalize(["a,b\n1,2\n"]) == "a,b\n1,2\n"
22 + end
23 +
24 + test "converts CRLF to LF" do
25 + assert normalize(["a,b\r\n1,2\r\n"]) == "a,b\n1,2\n"
26 + end
27 +
28 + test "collapses the CR CR LF that breaks the import" do
29 + assert normalize(["a,b\r\r\n1,2\r\r\n"]) == "a,b\n1,2\n"
30 + end
31 +
32 + test "collapses arbitrarily long CR runs" do
33 + assert normalize(["a,b\r\r\r\r\n1,2\n"]) == "a,b\n1,2\n"
34 + end
35 +
36 + test "treats a lone CR as a terminator so CR-only files still load" do
37 + assert normalize(["a,b\r1,2\r"]) == "a,b\n1,2\n"
38 + end
39 +
40 + test "a CR run split across chunks is still collapsed" do
41 + # the boundary case: the CRs end one chunk, the LF starts the next
42 + assert normalize(["a,b\r", "\r\n1,2\n"]) == "a,b\n1,2\n"
43 + assert normalize(["a,b\r\r", "\n1,2\n"]) == "a,b\n1,2\n"
44 + assert normalize(["a,b", "\r\r\n", "1,2\n"]) == "a,b\n1,2\n"
45 + end
46 +
47 + test "a trailing CR at end of input is not swallowed" do
48 + assert normalize(["a,b\n1,2\r"]) == "a,b\n1,2\n"
49 + end
50 +
51 + test "does not invent or drop records" do
52 + caltrain = "route_id,service_id,trip_id\r\r\n77119,c_717,163\r\r\n77119,c_717,167\r\r\n"
53 +
54 + lines = caltrain |> List.wrap() |> normalize() |> String.split("\n", trim: true)
55 +
56 + assert length(lines) == 3
57 + assert hd(lines) == "route_id,service_id,trip_id"
58 + assert List.last(lines) == "77119,c_717,167"
59 + end
60 +
61 + test "content is otherwise untouched" do
62 + row = ~s(1,"Main St, Apt 2",x\r\r\n)
63 + assert normalize([row]) == ~s(1,"Main St, Apt 2",x\n)
64 + end
65 +end
modified apps/room_sanctum/lib/room_sanctum/storage.ex
+174 −0
@@ -358,6 +358,7 @@ defmodule RoomSanctum.Storage do
358 358 end
359 359
360 360 alias RoomSanctum.Storage.GTFS.Route
361 + alias RoomSanctum.Storage.GTFS.Shape
361 362
362 363 @doc """
363 364 Returns the list of routes.
@@ -377,6 +378,169 @@ defmodule RoomSanctum.Storage do
377 378 |> Repo.all()
378 379 end
379 380
381 + @doc """
382 + The routes that call at a stop, as a list of route_ids.
383 +
384 + Backed by the (source_id, stop_id) index on stop_times -- without it the
385 + planner walks every trip in the feed.
386 + """
387 + def routes_serving_stop(source_id, stop_id) do
388 + Repo.query!(
389 + """
390 + SELECT DISTINCT t.route_id
391 + FROM gtfs_stop_times st
392 + JOIN gtfs_trips t ON t.source_id = st.source_id AND t.trip_id = st.trip_id
393 + WHERE st.source_id = $1 AND st.stop_id = $2
394 + """,
395 + [source_id, stop_id]
396 + ).rows
397 + |> Enum.map(&List.first/1)
398 + end
399 +
400 + @doc """
401 + route_id => route_type for one source.
402 +
403 + Realtime vehicle positions carry only a route_id, but what a vehicle *is* --
404 + bus, tram, ferry -- lives on the route, so callers resolve it once and carry
405 + the map rather than querying per vehicle.
406 + """
407 + def route_types(source_id) do
408 + from(r in Route,
409 + where: r.source_id == ^source_id,
410 + select: {r.route_id, r.route_type}
411 + )
412 + |> Repo.all()
413 + |> Map.new()
414 + end
415 +
416 + # A drawn shape can run to a thousand points; at the weight these are
417 + # rendered, far fewer is indistinguishable and keeps the payload sane when a
418 + # feed has hundreds of routes.
419 + @max_line_points 200
420 +
421 + @doc """
422 + One polyline per route: `[%{id:, color:, points: [[lat, lng], ...]}]`.
423 +
424 + Uses shapes.txt where the feed ships it, which is the real drawn alignment.
425 + Routes with no shape fall back to the ordered stops of a representative trip
426 + -- straight hops between stops rather than following the road, but better
427 + than nothing on feeds that omit shapes.
428 + """
429 + def list_route_lines(source_ids) when is_list(source_ids) do
430 + source_ids
431 + |> Enum.uniq()
432 + |> Enum.flat_map(fn source_id ->
433 + # route_id is only unique within a feed, so the map would otherwise get
434 + # two lines claiming the same DOM id.
435 + source_id
436 + |> list_route_lines()
437 + |> Enum.map(fn line -> %{line | id: "#{source_id}-#{line.id}"} end)
438 + end)
439 + end
440 +
441 + def list_route_lines(source_id) do
442 + shaped = shaped_route_lines(source_id)
443 + covered = MapSet.new(shaped, & &1.id)
444 +
445 + uncovered =
446 + from(r in Route, where: r.source_id == ^source_id, select: r.route_id)
447 + |> Repo.all()
448 + |> Enum.reject(&MapSet.member?(covered, &1))
449 +
450 + # The stop-sequence fallback reads stop_times, which runs to millions of
451 + # rows; it is only worth paying for the routes shapes did not cover, and
452 + # on a feed with complete shapes that is none of them.
453 + shaped ++ stop_route_lines(source_id, uncovered)
454 + end
455 +
456 + defp shaped_route_lines(source_id) do
457 + Repo.query!(
458 + """
459 + WITH rep AS (
460 + SELECT DISTINCT ON (t.route_id) t.route_id, t.shape_id
461 + FROM gtfs_trips t
462 + WHERE t.source_id = $1 AND t.shape_id IS NOT NULL AND t.shape_id <> ''
463 + ORDER BY t.route_id, t.shape_id
464 + )
465 + SELECT r.route_id, r.route_color, sh.shape_pt_lat, sh.shape_pt_lon
466 + FROM rep
467 + JOIN gtfs_routes r ON r.source_id = $1 AND r.route_id = rep.route_id
468 + JOIN gtfs_shapes sh ON sh.source_id = $1 AND sh.shape_id = rep.shape_id
469 + WHERE sh.shape_pt_lat IS NOT NULL AND sh.shape_pt_lon IS NOT NULL
470 + ORDER BY r.route_id, sh.shape_pt_sequence
471 + """,
472 + [source_id]
473 + )
474 + |> rows_to_lines()
475 + end
476 +
477 + defp stop_route_lines(_source_id, []), do: []
478 +
479 + defp stop_route_lines(source_id, route_ids) do
480 + Repo.query!(
481 + """
482 + WITH rep AS (
483 + SELECT DISTINCT ON (t.route_id) t.route_id, t.trip_id
484 + FROM gtfs_trips t
485 + WHERE t.source_id = $1 AND t.route_id = ANY($2)
486 + ORDER BY t.route_id, t.trip_id
487 + )
488 + SELECT r.route_id, r.route_color, s.stop_lat, s.stop_lon
489 + FROM rep
490 + JOIN gtfs_routes r ON r.source_id = $1 AND r.route_id = rep.route_id
491 + JOIN gtfs_stop_times st ON st.source_id = $1 AND st.trip_id = rep.trip_id
492 + JOIN gtfs_stops s ON s.source_id = $1 AND s.stop_id = st.stop_id
493 + WHERE s.stop_lat IS NOT NULL AND s.stop_lon IS NOT NULL
494 + ORDER BY r.route_id, st.stop_sequence
495 + """,
496 + [source_id, route_ids]
497 + )
498 + |> rows_to_lines()
499 + end
500 +
501 + defp rows_to_lines(%{rows: rows}) do
502 + rows
503 + |> Enum.group_by(fn [route_id, _color, _lat, _lon] -> route_id end)
504 + |> Enum.map(fn {route_id, grouped} ->
505 + [[_, color, _, _] | _] = grouped
506 +
507 + %{
508 + id: route_id,
509 + color: route_color(color),
510 + points: grouped |> Enum.map(fn [_, _, lat, lon] -> [lat, lon] end) |> decimate()
511 + }
512 + end)
513 + # A single point is not a line, and a route with no usable geometry is noise.
514 + |> Enum.filter(&(length(&1.points) > 1))
515 + end
516 +
517 + # Every nth point, with the last one always kept so the line does not stop
518 + # short of where the route actually ends.
519 + defp decimate(points) do
520 + case length(points) do
521 + n when n <= @max_line_points ->
522 + points
523 +
524 + n ->
525 + step = div(n, @max_line_points) + 1
526 +
527 + kept =
528 + points
529 + |> Enum.with_index()
530 + |> Enum.filter(fn {_point, index} -> rem(index, step) == 0 end)
531 + |> Enum.map(&elem(&1, 0))
532 +
533 + last = List.last(points)
534 + if List.last(kept) == last, do: kept, else: kept ++ [last]
535 + end
536 + end
537 +
538 + # GTFS stores route_color bare ("FFC72C"); CSS needs the hash.
539 + defp route_color(nil), do: nil
540 + defp route_color(""), do: nil
541 + defp route_color("#" <> _ = color), do: color
542 + defp route_color(color), do: "#" <> color
543 +
380 544 def count_routes(source_id) do
381 545 Repo.one(from p in Route, where: p.source_id == ^source_id, select: count(p.id))
382 546 end
@@ -458,6 +622,16 @@ defmodule RoomSanctum.Storage do
458 622 Repo.delete(route)
459 623 end
460 624
625 + def truncate_shape(source_id) do
626 + from(p in Shape, where: p.source_id == ^source_id)
627 + |> Repo.delete_all()
628 + end
629 +
630 + def count_shapes(source_id) do
631 + from(p in Shape, where: p.source_id == ^source_id, select: count(p.id))
632 + |> Repo.one()
633 + end
634 +
461 635 def truncate_route(source_id) do
462 636 from(p in Route, where: p.source_id == ^source_id)
463 637 |> Repo.delete_all()
added apps/room_sanctum/lib/room_sanctum/storage/gtfs/shape.ex
+36 −0
@@ -0,0 +1,36 @@
1 +defmodule RoomSanctum.Storage.GTFS.Shape do
2 + use Ecto.Schema
3 + import Ecto.Changeset
4 +
5 + @moduledoc """
6 + A point on a route's drawn path, from shapes.txt.
7 +
8 + Trips reference these by shape_id, which is what makes a route line follow
9 + the road rather than hopping stop to stop.
10 + """
11 +
12 + schema "gtfs_shapes" do
13 + belongs_to :source, RoomSanctum.Configuration.Source
14 + field :shape_id, :string
15 + field :shape_pt_lat, :float
16 + field :shape_pt_lon, :float
17 + field :shape_pt_sequence, :integer
18 + field :shape_dist_traveled, :float
19 +
20 + timestamps()
21 + end
22 +
23 + @doc false
24 + def changeset(shape, attrs) do
25 + shape
26 + |> cast(attrs, [
27 + :shape_id,
28 + :shape_pt_lat,
29 + :shape_pt_lon,
30 + :shape_pt_sequence,
31 + :shape_dist_traveled,
32 + :source_id
33 + ])
34 + |> validate_required([:shape_id, :source_id])
35 + end
36 +end
added apps/room_sanctum/priv/repo/migrations/20260810180000_create_gtfs_shapes.exs
+22 −0
@@ -0,0 +1,22 @@
1 +defmodule RoomSanctum.Repo.Migrations.CreateGtfsShapes do
2 + use Ecto.Migration
3 +
4 + def change do
5 + create table(:gtfs_shapes) do
6 + add :source_id, references(:cfg_sources, on_delete: :delete_all), null: false
7 +
8 + add :shape_id, :string, null: false
9 + add :shape_pt_lat, :float
10 + add :shape_pt_lon, :float
11 + add :shape_pt_sequence, :integer
12 + # Optional in the spec, and MBTA ships it empty.
13 + add :shape_dist_traveled, :float
14 +
15 + timestamps()
16 + end
17 +
18 + # The only read is "give me one shape's points in order", which this covers
19 + # as a prefix scan.
20 + create index(:gtfs_shapes, [:source_id, :shape_id, :shape_pt_sequence])
21 + end
22 +end
added apps/room_sanctum/priv/repo/migrations/20260810190000_index_stop_times_by_stop.exs
+10 −0
@@ -0,0 +1,10 @@
1 +defmodule RoomSanctum.Repo.Migrations.IndexStopTimesByStop do
2 + use Ecto.Migration
3 +
4 + def change do
5 + # "which routes call at this stop" reads stop_times by stop_id, but the
6 + # only index there leads with trip_id, so the planner was scanning every
7 + # trip in the feed and probing per row.
8 + create index(:gtfs_stop_times, [:source_id, :stop_id])
9 + end
10 +end
added apps/room_sanctum/test/room_sanctum/gtfs_route_lines_test.exs
+252 −0
@@ -0,0 +1,252 @@
1 +defmodule RoomSanctum.GtfsRouteLinesTest do
2 + @moduledoc """
3 + Route geometry for the map's route-line layer, and the linked_datasets.txt
4 + parsing that wires up GTFS-RT automatically.
5 + """
6 + use RoomSanctum.DataCase
7 +
8 + alias RoomSanctum.{Accounts, Configuration, Repo, Storage}
9 + alias RoomSanctum.Storage.GTFS.{Route, Shape, Stop, StopTime, Trip}
10 +
11 + setup do
12 + {:ok, user} =
13 + Accounts.register_user(%{
14 + email: "lines#{System.unique_integer([:positive])}@example.com",
15 + password: "hello world!hello world!"
16 + })
17 +
18 + {:ok, source} =
19 + Configuration.create_source(%{
20 + name: "Feed",
21 + notes: "",
22 + type: :gtfs,
23 + enabled: true,
24 + user_id: user.id,
25 + config: %{"__type__" => "gtfs", "url" => "https://example.test/g.zip", "tz" => "UTC"}
26 + })
27 +
28 + %{source: source, sid: source.id}
29 + end
30 +
31 + defp route(sid, id, opts \\ []) do
32 + Repo.insert!(%Route{
33 + source_id: sid,
34 + route_id: id,
35 + route_type: Keyword.get(opts, :type, "3"),
36 + route_color: Keyword.get(opts, :color)
37 + })
38 + end
39 +
40 + defp trip(sid, route_id, trip_id, shape_id \\ nil) do
41 + Repo.insert!(%Trip{
42 + source_id: sid,
43 + route_id: route_id,
44 + trip_id: trip_id,
45 + shape_id: shape_id,
46 + service_id: "svc"
47 + })
48 + end
49 +
50 + defp shape_points(sid, shape_id, count) do
51 + for i <- 1..count do
52 + Repo.insert!(%Shape{
53 + source_id: sid,
54 + shape_id: shape_id,
55 + shape_pt_lat: 37.0 + i / 10_000,
56 + shape_pt_lon: -122.0,
57 + shape_pt_sequence: i
58 + })
59 + end
60 + end
61 +
62 + defp stops_for(sid, trip_id, count) do
63 + for i <- 1..count do
64 + stop_id = "#{trip_id}-s#{i}"
65 + Repo.insert!(%Stop{source_id: sid, stop_id: stop_id, stop_name: stop_id,
66 + stop_lat: 38.0 + i / 1000, stop_lon: -122.0})
67 + Repo.insert!(%StopTime{source_id: sid, trip_id: trip_id, stop_id: stop_id,
68 + stop_sequence: i})
69 + end
70 + end
71 +
72 + defp line_for(lines, id), do: Enum.find(lines, &(&1.id == id))
73 +
74 + describe "route geometry" do
75 + test "a route with a shape uses the drawn alignment", %{sid: sid} do
76 + route(sid, "R1")
77 + trip(sid, "R1", "T1", "SH1")
78 + shape_points(sid, "SH1", 12)
79 + # stops exist too, and must lose to the shape
80 + stops_for(sid, "T1", 3)
81 +
82 + line = Storage.list_route_lines(sid) |> line_for("R1")
83 +
84 + assert length(line.points) == 12
85 + assert [lat, lon] = hd(line.points)
86 + # shapes sit at 37.x, the stop fallback at 38.x
87 + assert_in_delta lat, 37.0, 0.01
88 + assert lon == -122.0
89 + end
90 +
91 + test "a route with no shape falls back to its stop sequence", %{sid: sid} do
92 + route(sid, "R2")
93 + trip(sid, "R2", "T2", nil)
94 + stops_for(sid, "T2", 4)
95 +
96 + line = Storage.list_route_lines(sid) |> line_for("R2")
97 +
98 + assert length(line.points) == 4
99 + assert [lat, _lon] = hd(line.points)
100 + assert_in_delta lat, 38.0, 0.01
101 + end
102 +
103 + test "shaped and unshaped routes coexist in one feed", %{sid: sid} do
104 + route(sid, "SHAPED")
105 + trip(sid, "SHAPED", "TA", "SHA")
106 + shape_points(sid, "SHA", 5)
107 +
108 + route(sid, "PLAIN")
109 + trip(sid, "PLAIN", "TB", nil)
110 + stops_for(sid, "TB", 3)
111 +
112 + lines = Storage.list_route_lines(sid)
113 +
114 + assert length(lines) == 2
115 + assert length(line_for(lines, "SHAPED").points) == 5
116 + assert length(line_for(lines, "PLAIN").points) == 3
117 + end
118 +
119 + test "an empty shape_id is treated as no shape", %{sid: sid} do
120 + route(sid, "R3")
121 + trip(sid, "R3", "T3", "")
122 + stops_for(sid, "T3", 3)
123 +
124 + assert length(Storage.list_route_lines(sid) |> line_for("R3") |> Map.fetch!(:points)) == 3
125 + end
126 +
127 + test "a long shape is decimated but still ends where the route ends", %{sid: sid} do
128 + route(sid, "LONG")
129 + trip(sid, "LONG", "TL", "SHL")
130 + shape_points(sid, "SHL", 1000)
131 +
132 + line = Storage.list_route_lines(sid) |> line_for("LONG")
133 +
134 + assert length(line.points) <= 200, "got #{length(line.points)} points"
135 + assert length(line.points) > 50, "decimated too far: #{length(line.points)}"
136 + # the 1000th point, i.e. the true end of the line
137 + assert List.last(line.points) == [37.0 + 1000 / 10_000, -122.0]
138 + assert hd(line.points) == [37.0 + 1 / 10_000, -122.0]
139 + end
140 +
141 + test "a one-point shape is not a line", %{sid: sid} do
142 + route(sid, "DOT")
143 + trip(sid, "DOT", "TD", "SHD")
144 + shape_points(sid, "SHD", 1)
145 +
146 + assert Storage.list_route_lines(sid) |> line_for("DOT") == nil
147 + end
148 +
149 + test "route_color gains the hash CSS needs, and absence is tolerated", %{sid: sid} do
150 + route(sid, "BARE", color: "FFC72C")
151 + trip(sid, "BARE", "T4", "SH4")
152 + shape_points(sid, "SH4", 3)
153 +
154 + route(sid, "HASHED", color: "#0a5")
155 + trip(sid, "HASHED", "T5", "SH5")
156 + shape_points(sid, "SH5", 3)
157 +
158 + route(sid, "NONE", color: nil)
159 + trip(sid, "NONE", "T6", "SH6")
160 + shape_points(sid, "SH6", 3)
161 +
162 + lines = Storage.list_route_lines(sid)
163 +
164 + assert line_for(lines, "BARE").color == "#FFC72C"
165 + assert line_for(lines, "HASHED").color == "#0a5"
166 + assert line_for(lines, "NONE").color == nil
167 + end
168 +
169 + test "a feed with no geometry at all yields nothing rather than raising", %{sid: sid} do
170 + route(sid, "EMPTY")
171 + assert Storage.list_route_lines(sid) == []
172 + end
173 +
174 + test "ids stay unique when several feeds are drawn on one map", %{sid: sid, source: source} do
175 + route(sid, "1")
176 + trip(sid, "1", "T1", "SH1")
177 + shape_points(sid, "SH1", 3)
178 +
179 + {:ok, other} =
180 + Configuration.create_source(%{
181 + name: "Other feed",
182 + notes: "",
183 + type: :gtfs,
184 + enabled: true,
185 + user_id: source.user_id,
186 + config: %{"__type__" => "gtfs", "url" => "https://example.test/o.zip", "tz" => "UTC"}
187 + })
188 +
189 + # same route_id in a different feed -- the ids must not collide
190 + route(other.id, "1")
191 + trip(other.id, "1", "T1", "SH1")
192 + shape_points(other.id, "SH1", 3)
193 +
194 + ids = Storage.list_route_lines([sid, other.id]) |> Enum.map(& &1.id) |> Enum.sort()
195 +
196 + assert ids == Enum.sort(["#{other.id}-1", "#{sid}-1"])
197 + end
198 + end
199 +
200 + describe "routes serviced by a stop" do
201 + setup %{sid: sid} do
202 + for stop_id <- ["shared", "only_a", "only_b"] do
203 + Repo.insert!(%Stop{source_id: sid, stop_id: stop_id, stop_name: stop_id,
204 + stop_lat: 37.0, stop_lon: -122.0})
205 + end
206 +
207 + # A calls at shared + only_a, B at shared + only_b, C at only_b alone
208 + for {rid, stops} <- [{"A", ["shared", "only_a"]}, {"B", ["shared", "only_b"]}, {"C", ["only_b"]}] do
209 + route(sid, rid)
210 + trip(sid, rid, "T#{rid}", nil)
211 +
212 + for {stop_id, seq} <- Enum.with_index(stops) do
213 + Repo.insert!(%StopTime{source_id: sid, trip_id: "T#{rid}", stop_id: stop_id,
214 + stop_sequence: seq})
215 + end
216 + end
217 +
218 + :ok
219 + end
220 +
221 + test "returns every route calling there, and only those", %{sid: sid} do
222 + assert Storage.routes_serving_stop(sid, "shared") |> Enum.sort() == ["A", "B"]
223 + assert Storage.routes_serving_stop(sid, "only_a") |> Enum.sort() == ["A"]
224 + assert Storage.routes_serving_stop(sid, "only_b") |> Enum.sort() == ["B", "C"]
225 + end
226 +
227 + test "a route calling twice is listed once", %{sid: sid} do
228 + Repo.insert!(%StopTime{source_id: sid, trip_id: "TA", stop_id: "shared", stop_sequence: 99})
229 +
230 + assert Storage.routes_serving_stop(sid, "shared") |> Enum.sort() == ["A", "B"]
231 + end
232 +
233 + test "an unknown stop serves nothing", %{sid: sid} do
234 + assert Storage.routes_serving_stop(sid, "nope") == []
235 + end
236 +
237 + test "does not leak across feeds", %{sid: sid, source: source} do
238 + {:ok, other} =
239 + Configuration.create_source(%{
240 + name: "Elsewhere",
241 + notes: "",
242 + type: :gtfs,
243 + enabled: true,
244 + user_id: source.user_id,
245 + config: %{"__type__" => "gtfs", "url" => "https://example.test/e.zip", "tz" => "UTC"}
246 + })
247 +
248 + assert Storage.routes_serving_stop(other.id, "shared") == []
249 + assert sid != other.id
250 + end
251 + end
252 +end

Parents: 0a0ddd6