Ask what leaves from near a foci

f1808db · Gabriel Morell · 2026-09-03 16:58

24 files +180 -11
Message
{commit_body(@commit)}

Files changed

modified PLANI.md
+11 −6
@@ -100,8 +100,9 @@ rather than discovering at runtime.
100 100 3. **Nearest-N for what is already indexed.** GBFS stations and bikes, AQI
101 101 monitors. The queries exist; they need a limit and a caller.
102 102 4. ~~**GTFS stops.**~~ Done: `gtfs_stops.place` is generated and GiST indexed,
103 − and `nearby_stops/3` uses it. What is still missing is a *query* that asks
104 − for them — GBFS has `mode: :area`, GTFS has only a named stop.
103 + `nearby_stops/3` uses it, and GTFS queries have a `mode: :area` that asks
104 + what is leaving near a foci. Both spatial sources now move when their
105 + anchor does.
105 106
106 107 ## Open questions
107 108
@@ -116,10 +117,14 @@ rather than discovering at runtime.
116 117 be a foci whose place comes from a GenServer rather than a column. The
117 118 vision machinery, the condensers and the clients would all be untouched.
118 119
119 − What does *not* compose is asking for things there is no query for. GBFS
120 − already has `mode: :area` around a foci with a radius, so "bikes near me"
121 − works the moment the foci moves. GTFS has only a named stop, so "stops near
122 − me" needs a new query mode — a query type, not a Pythiae mode.
120 + What did *not* compose was asking for things there was no query for. Both
121 + GBFS and GTFS now have `mode: :area` around a foci, so "what is near me"
122 + works the moment the foci moves. That was a query mode rather than a
123 + Pythiae mode, which is the shape the rest of this should keep.
124 +
125 + What remains is the substitution itself: nothing yet reads `curr_foci`, so
126 + a query still resolves its own `foci_id`. That is the one piece of wiring
127 + between here and a foci that travels.
123 128 - **What does a client see while it has never reported?** The home foci, which
124 129 is correct but silent. A board that says which anchor it used would save
125 130 somebody wondering why the times are for the wrong town.
modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+28 −0
@@ -130,6 +130,9 @@ defmodule RoomGtfs.Worker do
130 130 _ -> %{}
131 131 end
132 132
133 + @area_arrivals_per_stop 8
134 + @area_arrivals_total 16
135 +
133 136 def query_alerts(name, stop, route_ids) do
134 137 try do
135 138 "gtfs-rt#{name}"
@@ -232,6 +235,31 @@ defmodule RoomGtfs.Worker do
232 235 realtime in scheduled_ids
233 236 end
234 237
238 + @doc """
239 + What is leaving from near a foci, rather than from one named stop.
240 +
241 + Gathers the nearest few stops and asks each of them, then takes the
242 + soonest departures across the lot. The answer is deliberately blended: an
243 + area query asks what you can catch from here, not which pole to stand at,
244 + and the stops in a radius this size are usually the same corner.
245 + """
246 + def query_stop(id, %{mode: :area} = query) do
247 + inst = Configuration.get_source!(:bare, id)
248 + foci = Configuration.get_foci!(query.foci_id)
249 +
250 + stops =
251 + Storage.nearby_stops(id, foci.place, query.stops || 3)
252 + |> Enum.map(& &1.stop_id)
253 +
254 + stops
255 + |> Enum.flat_map(fn stop ->
256 + Storage.get_upcoming_arrivals_for_stop(id, stop, @area_arrivals_per_stop, :now, inst.config.tz)
257 + end)
258 + |> Storage.fix_arrival_times()
259 + |> Enum.sort_by(& &1.arrival_time)
260 + |> Enum.take(@area_arrivals_total)
261 + end
262 +
235 263 def query_stop(id, query) do
236 264 # Bare: this wants the config blob, and nothing on this path reads the
237 265 # mailboxes and webhooks the preloading version fetches alongside it.
modified apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+3 −1
@@ -295,7 +295,9 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
295 295 def condense({id, :gtfs}, data, query) do
296 296 condensed = condense_data({id, :gtfs}, data)
297 297 route_ids = condensed |> Enum.map(& &1.route) |> Enum.uniq()
298 − stop = query.query.stop
298 + # An area query has no one stop, so there is no stop-specific alert to
299 + # look for -- only the ones on the routes that turned up.
300 + stop = Map.get(query.query, :stop)
299 301 alerts = RoomGtfs.Worker.query_alerts(id, stop, route_ids)
300 302
301 303 condensed_with_alerts = condensed |> Enum.map(fn route ->
modified apps/room_sanctum/lib/room_sanctum/configuration/queries/gtfs.ex
+41 −2
@@ -4,14 +4,53 @@ defmodule RoomSanctum.Configuration.Queries.GTFS do
4 4
5 5 @primary_key false
6 6
7 + # Metres. Transit stops are further apart than bike docks and people walk to
8 + # them, so this starts wider than the GBFS default.
9 + @default_radius 800
10 + @default_stops 3
11 +
7 12 embedded_schema do
13 + # :station names one stop. :area asks what is leaving near a foci, which
14 + # is what a foci that moves is for.
15 + field :mode, Ecto.Enum, values: [:station, :area], default: :station
16 +
17 + # :station
8 18 field :stop, :string
9 19 field :routes, {:array, :string}
20 +
21 + # :area
22 + field :foci_id, :integer
23 + field :radius, :integer, default: @default_radius
24 + # How many stops to gather from. A board is a dozen departures; asking
25 + # twenty stops for them would fill it with the same bus from every corner.
26 + field :stops, :integer, default: @default_stops
10 27 end
11 28
29 + def default_radius, do: @default_radius
30 + def default_stops, do: @default_stops
31 +
12 32 def changeset(source, params) do
13 33 source
14 − |> cast(params, ~w(stop routes)a)
15 − |> validate_required(:stop)
34 + |> cast(params, ~w(mode stop routes foci_id radius stops)a)
35 + |> validate_required([:mode])
36 + |> validate_mode()
37 + end
38 +
39 + defp validate_mode(changeset) do
40 + case get_field(changeset, :mode) do
41 + :area -> validate_area(changeset)
42 + _ -> validate_station(changeset)
43 + end
44 + end
45 +
46 + defp validate_station(changeset), do: validate_required(changeset, :stop)
47 +
48 + defp validate_area(changeset) do
49 + changeset
50 + |> validate_required([:foci_id, :radius])
51 + # Beyond a couple of kilometres this stops being "what can I catch from
52 + # here" and starts being every stop in the city.
53 + |> validate_number(:radius, greater_than_or_equal_to: 50, less_than_or_equal_to: 3000)
54 + |> validate_number(:stops, greater_than_or_equal_to: 1, less_than_or_equal_to: 8)
16 55 end
17 56 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/show.ex
+9 −0
@@ -165,4 +165,13 @@ defmodule RoomSanctumWeb.PythiaeLive.Show do
165 165 {:ok, updated} = Configuration.update_pythiae(pythiae, %{curr_foci: foci_id})
166 166 {:noreply, socket |> assign(:pythiae, updated)}
167 167 end
168 +
169 + # Setting a foci was one-way: every foci had a button to become the current
170 + # one and none of them had a way back, so a Pythiae that had ever been
171 + # pointed at a place stayed pointed at it.
172 + def handle_event("clear-current-foci", _params, socket) do
173 + pythiae = socket.assigns.pythiae
174 + {:ok, updated} = Configuration.update_pythiae(pythiae, %{curr_foci: nil})
175 + {:noreply, socket |> assign(:pythiae, updated)}
176 + end
168 177 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/show.html.heex
+12 −1
@@ -61,7 +61,18 @@
61 61 </div>
62 62 <div class="card card-compact w-full md:w-1/5 bg-base-100 shadow-xl m-2">
63 63 <div class="card-body">
64 − <h3 class="card-title">Foci</h3>
64 + <h3 class="card-title">
65 + Foci
66 + <%= if @pythiae.curr_foci do %>
67 + <button
68 + class="btn btn-xs btn-ghost"
69 + phx-click="clear-current-foci"
70 + title="Stop using a current foci"
71 + >
72 + <i class="fas fa-fw fa-xmark"></i> unset
73 + </button>
74 + <% end %>
75 + </h3>
65 76 <p>
66 77 <%= for f <- @foci do %>
67 78 <div class="btn-group">
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/form_component.html.heex
+25 −1
@@ -105,13 +105,37 @@
105 105
106 106 <% :gtfs -> %>
107 107 <%= polymorphic_embed_inputs_for @form, :query, :gtfs, fn gtfs_form -> %>
108 + <div class="form-control">
109 + <.input field={gtfs_form[:mode]} type="select" label="Mode"
110 + options={[{"Station - one stop", "station"}, {"Area - what leaves near a Foci", "area"}]} />
111 + </div>
112 +
113 + <div class="divider">Station mode</div>
108 114 <div class="form-control">
109 115 <.input field={gtfs_form[:stop]} type="text" label="Stop" />
110 116 </div>
117 + <.search_input field={gtfs_form[:stop]} id="search-1" type="gtfs" label="Stop Search" results={@results} label_field={:stop_name} value_field={:stop_id} />
118 +
119 + <div class="divider">Area mode</div>
120 + <div class="form-control">
121 + <.input field={gtfs_form[:foci_id]} type="select" label="Foci" options={@cfg_foci_sel} />
122 + </div>
123 + <div class="form-control">
124 + <.input field={gtfs_form[:radius]} type="number" label="Radius (metres, 50-3000)" />
125 + </div>
126 + <div class="form-control">
127 + <.input field={gtfs_form[:stops]} type="number" label="Stops to gather from (1-8)" />
128 + <p class="text-xs text-base-content/60 mt-1">
129 + What is leaving from near the Foci rather than from one named stop. The departures
130 + of the nearest few stops are blended: an area query asks what you can catch from
131 + here, not which pole to stand at.
132 + </p>
133 + </div>
134 +
135 + <div class="divider">Both modes</div>
111 136 <div class="form-control">
112 137 <.input field={gtfs_form[:routes]} type="text" label="Route Filter" />
113 138 </div>
114 − <.search_input field={gtfs_form[:stop]} id="search-1" type="gtfs" label="Stop Search" results={@results} label_field={:stop_name} value_field={:stop_id} />
115 139 <% end %>
116 140
117 141 <% :hass -> %>
added apps/room_sanctum/test/room_sanctum/gtfs_area_query_test.exs
+51 −0
@@ -0,0 +1,51 @@
1 +defmodule RoomSanctum.GtfsAreaQueryTest do
2 + @moduledoc """
3 + A GTFS query in either of its two modes.
4 +
5 + Station mode names one stop. Area mode names a foci and a radius, which is
6 + what lets the anchor move — the mode a Plani needs and the reason for the
7 + spatial index on stops.
8 + """
9 + use ExUnit.Case, async: true
10 +
11 + alias RoomSanctum.Configuration.Queries.GTFS
12 +
13 + defp changeset(params), do: GTFS.changeset(%GTFS{}, params)
14 +
15 + describe "station mode" do
16 + test "wants a stop" do
17 + assert changeset(%{mode: :station}).errors[:stop]
18 + assert changeset(%{mode: :station, stop: "1234"}).valid?
19 + end
20 +
21 + test "is what a query without a mode gets" do
22 + # Every GTFS query written before area mode existed has no mode in it,
23 + # and must go on meaning the stop it names.
24 + assert changeset(%{stop: "1234"}).valid?
25 + end
26 + end
27 +
28 + describe "area mode" do
29 + test "wants a foci and a radius, not a stop" do
30 + refute changeset(%{mode: :area}).valid?
31 + assert changeset(%{mode: :area, foci_id: 1, radius: 800}).valid?
32 + end
33 +
34 + test "refuses a radius that stops meaning near" do
35 + refute changeset(%{mode: :area, foci_id: 1, radius: 20}).valid?
36 + refute changeset(%{mode: :area, foci_id: 1, radius: 50_000}).valid?
37 + end
38 +
39 + test "refuses gathering from more stops than a board can show" do
40 + refute changeset(%{mode: :area, foci_id: 1, radius: 800, stops: 40}).valid?
41 + assert changeset(%{mode: :area, foci_id: 1, radius: 800, stops: 5}).valid?
42 + end
43 +
44 + test "has defaults worth having" do
45 + query = changeset(%{mode: :area, foci_id: 1}) |> Ecto.Changeset.apply_changes()
46 +
47 + assert query.radius == GTFS.default_radius()
48 + assert query.stops == GTFS.default_stops()
49 + end
50 + end
51 +end

Parents: 8685bee