neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
Log in to mark this repository.
Ask the stops you can actually board from
fa3ea0b · Gabriel Morell · 2026-09-03 17:55
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 | 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 | 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 | 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 | 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 | 50 | data: %{}, |
| 51 | 51 | queries: [], |
| 52 | 52 | places: [], |
| 53 | + notes: %{}, | |
| 53 | 54 | anchor: nil, |
| 54 | 55 | anchored_to: :home |
| 55 | 56 | }} |
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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
| 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