Ask the stops you can actually board from

fa3ea0b · Gabriel Morell · 2026-09-03 17:55

25 files +208 -28
Message
{commit_body(@commit)}

Files changed

modified ?
+0 −0

Binary file — not rendered.

modified ?
+0 −0

Binary file — not rendered.

modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+1 −1
@@ -248,7 +248,7 @@ defmodule RoomGtfs.Worker do
248 248 foci = Configuration.get_foci!(query.foci_id)
249 249
250 250 stops =
251 − Storage.nearby_stops(id, foci.place, query.stops || 3)
251 + Storage.nearby_stops(id, foci.place, query.stops || 3, query.radius)
252 252 |> Enum.map(& &1.stop_id)
253 253
254 254 stops
modified ?
+0 −0

Binary file — not rendered.

modified ?
+0 −0

Binary file — not rendered.

modified apps/room_sanctum/lib/room_sanctum/storage.ex
+43 −14
@@ -2785,8 +2785,20 @@ defmodule RoomSanctum.Storage do
2785 2785 """
2786 2786 def stations_near_foci(source_id, foci_id, radius) do
2787 2787 case get_foci_by_id(foci_id) do
2788 − %{place: %Geo.Point{} = place} ->
2789 − from(st in StationStatus,
2788 + %{place: %Geo.Point{} = place} -> stations_near(source_id, place, radius)
2789 + _ -> []
2790 + end
2791 + end
2792 +
2793 + @doc """
2794 + Docks within a radius of a point, closest first.
2795 +
2796 + The point-taking half of `stations_near_foci/3`, so an anchor that is not a
2797 + foci can ask it. A docked system answers a question about where you are with
2798 + its docks; only a dockless one answers with loose bikes.
2799 + """
2800 + def stations_near(source_id, %Geo.Point{} = place, radius) do
2801 + from(st in StationStatus,
2790 2802 join: si in StationInfo,
2791 2803 on: si.station_id == st.station_id and si.source_id == st.source_id,
2792 2804 left_join: eb in EbikesAtStations,
@@ -2813,16 +2825,14 @@ defmodule RoomSanctum.Storage do
2813 2825 place: si.place,
2814 2826 name: si.name,
2815 2827 short_name: si.short_name,
2816 − ebikes_info: eb.ebikes
2817 − }
2818 − )
2819 − |> Repo.all()
2820 −
2821 − _ ->
2822 − []
2823 − end
2828 + ebikes_info: eb.ebikes
2829 + }
2830 + )
2831 + |> Repo.all()
2824 2832 end
2825 2833
2834 + def stations_near(_source_id, _place, _radius), do: []
2835 +
2826 2836 @doc """
2827 2837 Free-floating bikes within `radius` metres of a foci.
2828 2838
@@ -3280,18 +3290,37 @@ defmodule RoomSanctum.Storage do
3280 3290 Ordered by the index operator rather than by distance, so this uses the GiST
3281 3291 index instead of measuring every stop and sorting the result.
3282 3292 """
3283 − def nearby_stops(source_id, point, limit \\ 5)
3293 + def nearby_stops(source_id, point, limit \\ 5, radius \\ nil)
3284 3294
3285 − def nearby_stops(source_id, %Geo.Point{} = point, limit) do
3295 + def nearby_stops(source_id, %Geo.Point{} = point, limit, radius) do
3286 3296 from(s in Stop,
3287 − where: s.source_id == ^source_id and not is_nil(s.stop_lat) and not is_nil(s.stop_lon),
3297 + where:
3298 + s.source_id == ^source_id and not is_nil(s.stop_lat) and not is_nil(s.stop_lon) and
3299 + # Only somewhere you can board. A GTFS feed keeps its stairwells,
3300 + # lifts and pathway nodes in this table too -- location_type 1 to 4 --
3301 + # and none of them appear in stop_times. The five things nearest a
3302 + # station entrance are usually five of its own nodes, so without this
3303 + # the answer is a list of doors with no departures behind any of them.
3304 + (is_nil(s.location_type) or s.location_type in ["", "0"]),
3288 3305 limit: ^limit,
3289 3306 order_by: fragment("place <-> ?", ^point)
3290 3307 )
3308 + |> within(point, radius)
3291 3309 |> Repo.all()
3292 3310 end
3293 3311
3294 − def nearby_stops(_source_id, _point, _limit), do: []
3312 + def nearby_stops(_source_id, _point, _limit, _radius), do: []
3313 +
3314 + # A radius, when one was asked for. Without it "nearest" means nearest in the
3315 + # world: a foci nowhere near a source still gets that source's five closest
3316 + # stops, an ocean away, and nothing says they are not nearby.
3317 + defp within(query, _point, nil), do: query
3318 +
3319 + defp within(query, point, radius) do
3320 + from(s in query,
3321 + where: fragment("ST_DWithin(place::geography, ?::geography, ?)", ^point, ^radius)
3322 + )
3323 + end
3295 3324
3296 3325 @doc """
3297 3326 Stops nearest a foci, closest first.
modified apps/room_sanctum/lib/room_sanctum/workers/plani_worker.ex
+35 −9
@@ -50,6 +50,7 @@ defmodule RoomSanctum.Worker.Plani do
50 50 data: %{},
51 51 queries: [],
52 52 places: [],
53 + notes: %{},
53 54 anchor: nil,
54 55 anchored_to: :home
55 56 }}
@@ -121,15 +122,20 @@ defmodule RoomSanctum.Worker.Plani do
121 122 Configuration.list_cfg_sources({:user, state.plani.user_id})
122 123 )
123 124
124 − {data, queries, places} =
125 + {data, queries, places, notes} =
125 126 sources
126 − |> Enum.reduce({%{}, [], []}, fn source_id, {data, queries, places} ->
127 + |> Enum.reduce({%{}, [], [], %{}}, fn source_id, {data, queries, places, notes} ->
127 128 case ask(source_id, anchor, state.plani) do
128 129 nil ->
129 − {data, queries, places}
130 + {data, queries, places, Map.put(notes, source_id, :not_spatial)}
131 +
132 + {:error, _id, message} ->
133 + Logger.warning("plani #{state.id} source #{source_id}: #{message}")
134 + {data, queries, places, Map.put(notes, source_id, {:error, message})}
130 135
131 136 {key, results, described, found} ->
132 − {Map.put(data, key, results), [described | queries], found ++ places}
137 + {Map.put(data, key, results), [described | queries], found ++ places,
138 + Map.put(notes, source_id, {:ok, length(results)})}
133 139 end
134 140 end)
135 141
@@ -139,6 +145,7 @@ defmodule RoomSanctum.Worker.Plani do
139 145 | data: data,
140 146 queries: Enum.reverse(queries),
141 147 places: places,
148 + notes: notes,
142 149 anchor: anchor,
143 150 anchored_to: anchored_to
144 151 }}
@@ -146,7 +153,12 @@ defmodule RoomSanctum.Worker.Plani do
146 153
147 154 def handle_call(:where, _from, state) do
148 155 {:reply,
149 − %{anchor: state.anchor, anchored_to: state.anchored_to, places: state.places}, state}
156 + %{
157 + anchor: state.anchor,
158 + anchored_to: state.anchored_to,
159 + places: state.places,
160 + notes: state.notes
161 + }, state}
150 162 end
151 163
152 164 def handle_call(:return_state, _from, state) do
@@ -181,7 +193,7 @@ defmodule RoomSanctum.Worker.Plani do
181 193
182 194 case source.type do
183 195 :gtfs ->
184 − stops = Storage.nearby_stops(source_id, anchor, plani.limit)
196 + stops = Storage.nearby_stops(source_id, anchor, plani.limit, plani.radius)
185 197
186 198 arrivals =
187 199 stops
@@ -193,8 +205,14 @@ defmodule RoomSanctum.Worker.Plani do
193 205 {{source_id, :gtfs}, arrivals, described(source), places(stops, source, :stop)}
194 206
195 207 :gbfs ->
208 + # Both, because a source does not say which kind it is. A docked
209 + # system answers with docks and a dockless one with loose bikes, and
210 + # asking only for the latter left a city full of docks looking empty.
196 211 bikes = Storage.free_bikes_near(source_id, anchor, plani.radius)
197 − {{source_id, :gbfs}, bikes, described(source), places(bikes, source, :bike)}
212 + docks = Storage.stations_near(source_id, anchor, plani.radius)
213 +
214 + {{source_id, :gbfs}, bikes ++ docks, described(source),
215 + places(bikes, source, :bike) ++ places(docks, source, :dock)}
198 216
199 217 :aqi ->
200 218 readings = Storage.nearby_aqi_stations(source_id, anchor, plani.limit)
@@ -206,10 +224,16 @@ defmodule RoomSanctum.Worker.Plani do
206 224 Logger.debug("plani: no spatial answer for a #{other} source")
207 225 nil
208 226 end
227 + rescue
228 + # `catch` takes throws and exits and lets a raise straight through, so one
229 + # source with no config, or a stop id that has gone, took the whole worker
230 + # down on every tick -- it restarted, lost its state, and the page stayed
231 + # empty with nothing anywhere saying why.
232 + error ->
233 + {:error, source_id, Exception.message(error)}
209 234 catch
210 235 kind, reason ->
211 − Logger.warning("plani source #{source_id} #{kind}: #{inspect(reason)}")
212 − nil
236 + {:error, source_id, "#{kind}: #{inspect(reason)}"}
213 237 end
214 238
215 239 defp ask(_source_id, _anchor, _plani), do: nil
@@ -259,6 +283,8 @@ defmodule RoomSanctum.Worker.Plani do
259 283
260 284 defp coordinates(%{point: %Geo.Point{coordinates: {lon, lat}}}), do: {lat, lon}
261 285
286 + defp coordinates(%{place: %Geo.Point{coordinates: {lon, lat}}}), do: {lat, lon}
287 +
262 288 defp coordinates(_record), do: nil
263 289
264 290 defp name_of(%{stop_name: name}, :stop) when is_binary(name), do: name
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/show.ex
+19 −0
@@ -102,6 +102,25 @@ defmodule RoomSanctumWeb.PlaniLive.Show do
102 102 end
103 103 end
104 104
105 + @doc """
106 + What happened to a source on the last tick.
107 +
108 + A count is the happy answer. The others are worth saying out loud: a Plani
109 + that shows nothing looks the same whether its sources are empty, ineligible
110 + or raising, and only one of those is something to wait out.
111 + """
112 + def outcome(%{notes: notes}, source_id) when is_map(notes) do
113 + case Map.get(notes, source_id) do
114 + {:ok, 0} -> {:empty, "nothing near it"}
115 + {:ok, n} -> {:ok, n}
116 + {:error, message} -> {:error, message}
117 + :not_spatial -> {:skipped, "nothing located in this source"}
118 + nil -> {:pending, "not asked yet"}
119 + end
120 + end
121 +
122 + def outcome(_where, _source_id), do: {:pending, "not asked yet"}
123 +
105 124 @doc "How many results a source came back with."
106 125 def count_for(state, source_id) do
107 126 state.data
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/show.html.heex
+14 −4
@@ -84,11 +84,21 @@
84 84 <%= source.name %>
85 85 </a>
86 86 <span class="ml-auto">
87 − <%= case RoomSanctumWeb.PlaniLive.Show.count_for(@state, source.id) do %>
88 − <% nil -> %>
89 − <span class="badge badge-ghost">nothing yet</span>
90 − <% n -> %>
87 + <%= case RoomSanctumWeb.PlaniLive.Show.outcome(@anchor, source.id) do %>
88 + <% {:ok, n} -> %>
91 89 <span class="badge badge-accent"><%= n %></span>
90 + <% {:empty, why} -> %>
91 + <span class="badge badge-ghost" title={why}>none</span>
92 + <% {:skipped, why} -> %>
93 + <span class="badge badge-warning gap-1" title={why}>
94 + <i class="fas fa-fw fa-ban text-xs"></i> skipped
95 + </span>
96 + <% {:error, message} -> %>
97 + <span class="badge badge-error gap-1" title={message}>
98 + <i class="fas fa-fw fa-triangle-exclamation text-xs"></i> failed
99 + </span>
100 + <% {:pending, why} -> %>
101 + <span class="badge badge-ghost" title={why}>waiting</span>
92 102 <% end %>
93 103 </span>
94 104 </div>
added apps/room_sanctum/test/room_sanctum/nearby_stops_test.exs
+96 −0
@@ -0,0 +1,96 @@
1 +defmodule RoomSanctum.NearbyStopsTest do
2 + @moduledoc """
3 + Which stops count as near a point.
4 +
5 + A GTFS feed keeps more than platforms in its stops table: stations, station
6 + entrances, lifts, stairwells and pathway nodes all live there, told apart by
7 + `location_type`, and only a platform appears in stop_times. The five things
8 + nearest a station entrance are usually five of that station's own nodes.
9 + """
10 + use RoomSanctum.DataCase
11 +
12 + alias RoomSanctum.{Repo, Storage}
13 +
14 + # Medford/Tufts, which is where this went wrong.
15 + @here %Geo.Point{coordinates: {-71.11553, 42.40837}, srid: 4326}
16 +
17 + defp stop(source_id, attrs) do
18 + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
19 +
20 + {1, _} =
21 + Repo.insert_all(
22 + "gtfs_stops",
23 + [
24 + Map.merge(
25 + %{
26 + source_id: source_id,
27 + stop_id: attrs.stop_id,
28 + stop_name: attrs.stop_name,
29 + stop_lat: attrs.lat,
30 + stop_lon: attrs.lon,
31 + location_type: attrs[:location_type],
32 + inserted_at: now,
33 + updated_at: now
34 + },
35 + %{}
36 + )
37 + ]
38 + )
39 +
40 + :ok
41 + end
42 +
43 + setup do
44 + # gtfs_stops has a foreign key to the source, so one has to exist.
45 + {:ok, user} =
46 + RoomSanctum.Accounts.register_user(%{
47 + email: "stops#{System.unique_integer([:positive])}@example.com",
48 + password: "hello world!hello world!"
49 + })
50 +
51 + {:ok, source} =
52 + RoomSanctum.Configuration.create_source(%{
53 + name: "Feed",
54 + type: :gtfs,
55 + config: %{},
56 + enabled: true,
57 + user_id: user.id
58 + })
59 +
60 + %{source_id: source.id}
61 + end
62 +
63 + test "a pathway node is not somewhere you can board", %{source_id: source_id} do
64 + # Closer, but you cannot catch anything from a stairwell.
65 + stop(source_id, %{stop_id: "node-stairs", stop_name: "Stairs", lat: 42.40838, lon: -71.11554, location_type: "3"})
66 + stop(source_id, %{stop_id: "70511", stop_name: "Platform", lat: 42.4090, lon: -71.1160, location_type: "0"})
67 +
68 + assert [%{stop_id: "70511"}] = Storage.nearby_stops(source_id, @here, 5)
69 + end
70 +
71 + test "a station is not a platform either", %{source_id: source_id} do
72 + stop(source_id, %{stop_id: "place-mdftf", stop_name: "Station", lat: 42.40838, lon: -71.11554, location_type: "1"})
73 + stop(source_id, %{stop_id: "70511", stop_name: "Platform", lat: 42.4090, lon: -71.1160, location_type: "0"})
74 +
75 + assert [%{stop_id: "70511"}] = Storage.nearby_stops(source_id, @here, 5)
76 + end
77 +
78 + test "a feed that leaves location_type empty still answers", %{source_id: source_id} do
79 + # It is optional in the spec, and an absent one means a stop.
80 + stop(source_id, %{stop_id: "plain", stop_name: "Plain", lat: 42.4084, lon: -71.1156, location_type: nil})
81 + stop(source_id, %{stop_id: "blank", stop_name: "Blank", lat: 42.4085, lon: -71.1157, location_type: ""})
82 +
83 + assert length(Storage.nearby_stops(source_id, @here, 5)) == 2
84 + end
85 +
86 + test "a radius keeps nearest from meaning nearest in the world", %{source_id: source_id} do
87 + stop(source_id, %{stop_id: "close", stop_name: "Close", lat: 42.4090, lon: -71.1160, location_type: "0"})
88 + # San Francisco.
89 + stop(source_id, %{stop_id: "far", stop_name: "Far", lat: 37.7749, lon: -122.4194, location_type: "0"})
90 +
91 + # Without one, a foci nowhere near a source still gets that source's
92 + # closest stops -- an ocean away, and nothing saying they are not near.
93 + assert length(Storage.nearby_stops(source_id, @here, 5)) == 2
94 + assert [%{stop_id: "close"}] = Storage.nearby_stops(source_id, @here, 5, 800)
95 + end
96 +end

Parents: 2ed496b