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 what leaves from near a foci
f1808db · Gabriel Morell · 2026-09-03 16:58
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