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.
Put AirNow stations on the maps, and let a query name one
ae41c81 · Gabriel Morell · 2026-08-11 18:18
Message
{commit_body(@commit)}
Files changed
modified
apps/room_air_quality/lib/room_air_quality/aqi_worker.ex
+24
−3
@@ -27,8 +27,27 @@ defmodule RoomAirQuality.Worker do
| 27 | 27 | |> GenServer.cast(:update_static) |
| 28 | 28 | end |
| 29 | 29 | |
| 30 | + # A station id names one monitor; a foci means "whatever is nearest". The | |
| 31 | + # station wins when both are set, and falls back to the foci if that monitor | |
| 32 | + # has stopped reporting rather than showing nothing. | |
| 30 | 33 | def query_place(id, query) do |
| 31 | − Storage.get_current_information_for_aqi(id, query.foci_id) | |
| 34 | + aqsid = Map.get(query, :aqsid) | |
| 35 | + foci_id = Map.get(query, :foci_id) | |
| 36 | + | |
| 37 | + cond do | |
| 38 | + is_binary(aqsid) and aqsid != "" -> | |
| 39 | + case Storage.get_aqi_station(id, aqsid) do | |
| 40 | + [] when not is_nil(foci_id) -> Storage.get_current_information_for_aqi(id, foci_id) | |
| 41 | + [] -> [] | |
| 42 | + station -> station | |
| 43 | + end | |
| 44 | + | |
| 45 | + not is_nil(foci_id) -> | |
| 46 | + Storage.get_current_information_for_aqi(id, foci_id) | |
| 47 | + | |
| 48 | + true -> | |
| 49 | + [] | |
| 50 | + end | |
| 32 | 51 | end |
| 33 | 52 | |
| 34 | 53 | def init(opts) do |
@@ -164,9 +183,11 @@ defmodule RoomAirQuality.Worker do
| 164 | 183 | f |> Map.put(:parameters, p) |
| 165 | 184 | end) |
| 166 | 185 | |> Enum.map(fn data -> |
| 186 | + # {lon, lat}, as PostGIS expects -- st_distance against a foci | |
| 187 | + # compares the two directly. | |
| 167 | 188 | point = %Geo.Point{ |
| 168 | 189 | coordinates: |
| 169 | − {data.latitude |> String.to_float(), data.longitude |> String.to_float()}, | |
| 190 | + {data.longitude |> String.to_float(), data.latitude |> String.to_float()}, | |
| 170 | 191 | srid: 4326 |
| 171 | 192 | } |
| 172 | 193 |
@@ -246,7 +267,7 @@ defmodule RoomAirQuality.Worker do
| 246 | 267 | |
| 247 | 268 | point = |
| 248 | 269 | if lat && lon do |
| 249 | − %Geo.Point{coordinates: {lat, lon}, srid: 4326} | |
| 270 | + %Geo.Point{coordinates: {lon, lat}, srid: 4326} | |
| 250 | 271 | end |
| 251 | 272 | |
| 252 | 273 | RoomSanctum.Storage.change_hourly_obs_data( |
modified
apps/room_sanctum/lib/room_sanctum/configuration/queries/aqi.ex
+38
−2
@@ -4,13 +4,49 @@ defmodule RoomSanctum.Configuration.Queries.AQI do
| 4 | 4 | |
| 5 | 5 | @primary_key false |
| 6 | 6 | |
| 7 | + @moduledoc """ | |
| 8 | + Air quality for one place. | |
| 9 | + | |
| 10 | + Either a foci -- take whichever monitoring station is nearest to it, which is | |
| 11 | + what you want for "the air where I live" and keeps working when a station | |
| 12 | + goes offline -- or a specific station by its AQS id, for watching one | |
| 13 | + monitor. A station id wins if both are set. | |
| 14 | + """ | |
| 15 | + | |
| 7 | 16 | embedded_schema do |
| 8 | 17 | field :foci_id, :integer |
| 18 | + field :aqsid, :string | |
| 9 | 19 | end |
| 10 | 20 | |
| 11 | 21 | def changeset(source, params) do |
| 12 | 22 | source |
| 13 | − |> cast(params, [:foci_id]) | |
| 14 | − |> validate_required([:foci_id]) | |
| 23 | + |> cast(params, [:foci_id, :aqsid]) | |
| 24 | + |> blank_to_nil(:aqsid) | |
| 25 | + |> validate_a_place() | |
| 26 | + end | |
| 27 | + | |
| 28 | + # The form submits "" for an untouched field, which would otherwise read as | |
| 29 | + # "a station was chosen" and match nothing. | |
| 30 | + defp blank_to_nil(changeset, field) do | |
| 31 | + case get_change(changeset, field) do | |
| 32 | + value when is_binary(value) -> | |
| 33 | + case String.trim(value) do | |
| 34 | + "" -> put_change(changeset, field, nil) | |
| 35 | + trimmed -> put_change(changeset, field, trimmed) | |
| 36 | + end | |
| 37 | + | |
| 38 | + _ -> | |
| 39 | + changeset | |
| 40 | + end | |
| 41 | + end | |
| 42 | + | |
| 43 | + defp validate_a_place(changeset) do | |
| 44 | + case {get_field(changeset, :foci_id), get_field(changeset, :aqsid)} do | |
| 45 | + {nil, nil} -> | |
| 46 | + add_error(changeset, :foci_id, "pick a foci, or name a station") | |
| 47 | + | |
| 48 | + _ -> | |
| 49 | + changeset | |
| 50 | + end | |
| 15 | 51 | end |
| 16 | 52 | end |
modified
apps/room_sanctum/lib/room_sanctum/storage.ex
+57
−8
@@ -2888,17 +2888,66 @@ defmodule RoomSanctum.Storage do
| 2888 | 2888 | end |
| 2889 | 2889 | |
| 2890 | 2890 | def get_current_information_for_aqi(source_id, foci_id) do |
| 2891 | + case nearest_aqi_stations(source_id, foci_id, 1) do | |
| 2892 | + [] -> [nil] | |
| 2893 | + [station] -> [station] | |
| 2894 | + end | |
| 2895 | + end | |
| 2896 | + | |
| 2897 | + @doc """ | |
| 2898 | + The monitoring stations nearest a foci, closest first. | |
| 2899 | + | |
| 2900 | + One row per station: the feed replaces the whole set each hour, so the | |
| 2901 | + current reading and the station itself are the same record. | |
| 2902 | + """ | |
| 2903 | + def nearest_aqi_stations(source_id, foci_id, limit \\ 5) do | |
| 2891 | 2904 | foci = Cfg.get_foci!(foci_id) |
| 2905 | + nearby_aqi_stations(source_id, foci.place, limit) | |
| 2906 | + end | |
| 2892 | 2907 | |
| 2893 | − # not great but I flailed a bit here for a few days and I want to get this done and didn't figure out array_agg | |
| 2894 | − q = | |
| 2895 | − from hod in HourlyObsData, | |
| 2896 | − where: hod.source_id == ^source_id, | |
| 2897 | − limit: 1, | |
| 2898 | − order_by: {:asc, st_distance(hod.point, ^foci.place)} | |
| 2908 | + @doc """ | |
| 2909 | + Monitoring stations nearest a point, closest first. | |
| 2910 | + | |
| 2911 | + Both geometries are {lon, lat}, so st_distance compares them directly -- | |
| 2912 | + they used to be transposed in step with each other, which worked until one | |
| 2913 | + side was normalised. | |
| 2914 | + """ | |
| 2915 | + def nearby_aqi_stations(source_id, %Geo.Point{} = point, limit \\ 5) do | |
| 2916 | + from(hod in HourlyObsData, | |
| 2917 | + where: hod.source_id == ^source_id and not is_nil(hod.point), | |
| 2918 | + limit: ^limit, | |
| 2919 | + order_by: {:asc, st_distance(hod.point, ^point)} | |
| 2920 | + ) | |
| 2921 | + |> Repo.all() | |
| 2922 | + end | |
| 2899 | 2923 | |
| 2900 | − res = Repo.one(q) | |
| 2901 | − [res] | |
| 2924 | + def nearby_aqi_stations(_source_id, _point, _limit), do: [] | |
| 2925 | + | |
| 2926 | + @doc """ | |
| 2927 | + One named monitoring station, or an empty list if it is not reporting. | |
| 2928 | + """ | |
| 2929 | + def get_aqi_station(source_id, aqsid) do | |
| 2930 | + from(hod in HourlyObsData, | |
| 2931 | + where: hod.source_id == ^source_id and hod.aqsid == ^aqsid, | |
| 2932 | + order_by: [desc: hod.valid_date, desc: hod.valid_time], | |
| 2933 | + limit: 1 | |
| 2934 | + ) | |
| 2935 | + |> Repo.all() | |
| 2936 | + end | |
| 2937 | + | |
| 2938 | + @doc """ | |
| 2939 | + Every station currently reporting for a source. | |
| 2940 | + | |
| 2941 | + DISTINCT ON rather than a plain select, so this stays one row per station if | |
| 2942 | + the feed ever keeps more than the latest hour. | |
| 2943 | + """ | |
| 2944 | + def list_aqi_stations(source_id) do | |
| 2945 | + from(hod in HourlyObsData, | |
| 2946 | + where: hod.source_id == ^source_id and not is_nil(hod.point), | |
| 2947 | + distinct: hod.aqsid, | |
| 2948 | + order_by: [asc: hod.aqsid, desc: hod.valid_date, desc: hod.valid_time] | |
| 2949 | + ) | |
| 2950 | + |> Repo.all() | |
| 2902 | 2951 | end |
| 2903 | 2952 | |
| 2904 | 2953 | alias RoomSanctum.Storage.ICalendar |
modified
apps/room_sanctum/lib/room_sanctum/storage/air_now/hourly_obs_data.ex
+23
−0
@@ -94,6 +94,29 @@ defmodule RoomSanctum.Storage.AirNow.HourlyObsData do
| 94 | 94 | :valid_time |
| 95 | 95 | ]) |
| 96 | 96 | end |
| 97 | + @doc """ | |
| 98 | + The headline AQI for an observation, as `{value, pollutant}`. | |
| 99 | + | |
| 100 | + AirNow reports a sub-index per pollutant and the AQI is the worst of them, | |
| 101 | + which is also the one worth naming -- "52 (PM2.5)" says what is driving it. | |
| 102 | + Returns nil when a station reported nothing this hour, which is common. | |
| 103 | + """ | |
| 104 | + def overall_aqi(nil), do: nil | |
| 105 | + | |
| 106 | + def overall_aqi(entry) do | |
| 107 | + [ | |
| 108 | + {Map.get(entry, :pm25_aqi), "PM2.5"}, | |
| 109 | + {Map.get(entry, :pm10_aqi), "PM10"}, | |
| 110 | + {Map.get(entry, :ozone_aqi), "Ozone"}, | |
| 111 | + {Map.get(entry, :no2_aqi), "NO2"} | |
| 112 | + ] | |
| 113 | + |> Enum.reject(fn {value, _pollutant} -> is_nil(value) end) | |
| 114 | + |> case do | |
| 115 | + [] -> nil | |
| 116 | + measured -> Enum.max_by(measured, fn {value, _pollutant} -> value end) | |
| 117 | + end | |
| 118 | + end | |
| 119 | + | |
| 97 | 120 | def compile_pairs(entry) when is_nil(entry) do |
| 98 | 121 | %{combined: "Pending"} |
| 99 | 122 | end |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+55
−2
@@ -21,7 +21,8 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 21 | 21 | |> assign(:preview_mode, :raw) |
| 22 | 22 | |> assign(:vehicle_positions, []) |
| 23 | 23 | |> assign(:show_route_lines, false) |
| 24 | − |> assign(:route_lines, [])} | |
| 24 | + |> assign(:route_lines, []) | |
| 25 | + |> assign(:nearby_stations, [])} | |
| 25 | 26 | end |
| 26 | 27 | |
| 27 | 28 | # Built on first use and then kept: the geometry query is not cheap enough to |
@@ -87,6 +88,9 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 87 | 88 | |> assign(:query_id, id) |
| 88 | 89 | |> assign(:type, q.source.type) |
| 89 | 90 | |> assign(:visions, []) |
| 91 | + # On arrival rather than on the first :update tick, so the page does not | |
| 92 | + # come up without the context it is mostly there to show. | |
| 93 | + |> assign(:nearby_stations, nearby_stations(q)) | |
| 90 | 94 | |> assign(:avail_visions, []) |
| 91 | 95 | |> assign(:avail_sel, :false) |
| 92 | 96 | } |
@@ -196,9 +200,58 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 196 | 200 | ) |
| 197 | 201 | end |
| 198 | 202 | |
| 199 | − {:noreply, assign(socket, :preview, result)} | |
| 203 | + {:noreply, | |
| 204 | + socket | |
| 205 | + |> assign(:preview, result) | |
| 206 | + |> assign(:nearby_stations, nearby_stations(socket.assigns.query))} | |
| 207 | + end | |
| 208 | + | |
| 209 | + # For an air quality query, the answer is one station -- so the neighbours | |
| 210 | + # are the context that makes it readable: whether the number is local or the | |
| 211 | + # whole city is like that. Ordered closest first, the query's own station | |
| 212 | + # leading. | |
| 213 | + defp nearby_stations(%{source: %{type: :aqi}} = query) do | |
| 214 | + case query.query do | |
| 215 | + %{aqsid: aqsid} = q when is_binary(aqsid) and aqsid != "" -> | |
| 216 | + case RoomSanctum.Storage.get_aqi_station(query.source_id, aqsid) do | |
| 217 | + [station] -> RoomSanctum.Storage.nearby_aqi_stations(query.source_id, station.point, 6) | |
| 218 | + _ -> from_foci(query, q) | |
| 219 | + end | |
| 220 | + | |
| 221 | + %{foci_id: foci_id} when not is_nil(foci_id) -> | |
| 222 | + RoomSanctum.Storage.nearest_aqi_stations(query.source_id, foci_id, 6) | |
| 223 | + | |
| 224 | + _ -> | |
| 225 | + [] | |
| 226 | + end | |
| 227 | + end | |
| 228 | + | |
| 229 | + defp nearby_stations(_query), do: [] | |
| 230 | + | |
| 231 | + # The map speaks stations, not observations. | |
| 232 | + defp as_stations(observations) do | |
| 233 | + Enum.map(observations, fn obs -> | |
| 234 | + %{ | |
| 235 | + place: obs.point, | |
| 236 | + station_id: obs.aqsid, | |
| 237 | + name: obs.site_name || obs.aqsid, | |
| 238 | + short_name: obs.aqsid, | |
| 239 | + capacity: 0, | |
| 240 | + address: obs.reporting_areas |> List.wrap() |> List.first(), | |
| 241 | + lat: obs.lat, | |
| 242 | + lon: obs.lon | |
| 243 | + } | |
| 244 | + end) | |
| 200 | 245 | end |
| 201 | 246 | |
| 247 | + defp aqi_of(observation), | |
| 248 | + do: RoomSanctum.Storage.AirNow.HourlyObsData.overall_aqi(observation) | |
| 249 | + | |
| 250 | + defp from_foci(query, %{foci_id: foci_id}) when not is_nil(foci_id), | |
| 251 | + do: RoomSanctum.Storage.nearest_aqi_stations(query.source_id, foci_id, 6) | |
| 252 | + | |
| 253 | + defp from_foci(_query, _q), do: [] | |
| 254 | + | |
| 202 | 255 | def handle_info(:update_sec, socket) do |
| 203 | 256 | visions = Configuration.get_visions(:query, socket.assigns.query_id) |
| 204 | 257 | nv = Configuration.get_visions_nv(:query, socket.assigns.query_id) |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+30
−0
@@ -124,6 +124,8 @@
| 124 | 124 | queries={[@query]} |
| 125 | 125 | vehicle_positions={@vehicle_positions} |
| 126 | 126 | aircraft={aircraft_from_preview(@query.source && @query.source.type, @preview)} |
| 127 | + stations={as_stations(@nearby_stations)} | |
| 128 | + queried_station_ids={Enum.take(@nearby_stations, 1) |> Enum.map(& &1.aqsid)} | |
| 127 | 129 | route_lines={if @show_route_lines, do: @route_lines, else: []} |
| 128 | 130 | route_lines_event={if @query.source && @query.source.type == :gtfs, do: "toggle-route-lines"} |
| 129 | 131 | show_route_lines={@show_route_lines} |
@@ -132,6 +134,34 @@
| 132 | 134 | height="350px" |
| 133 | 135 | /> |
| 134 | 136 | </div> |
| 137 | + | |
| 138 | + <%= if @nearby_stations != [] do %> | |
| 139 | + <%!-- The first is the station this query answers with; the rest are | |
| 140 | + what the air is doing around it. --%> | |
| 141 | + <div class="overflow-x-auto mt-3"> | |
| 142 | + <table class="table table-compact w-full"> | |
| 143 | + <tbody> | |
| 144 | + <%= for {station, index} <- Enum.with_index(@nearby_stations) do %> | |
| 145 | + <tr class={if index == 0, do: "font-semibold", else: "opacity-70"}> | |
| 146 | + <td> | |
| 147 | + <i class={"fa-solid fa-fw mr-1 #{if index == 0, do: "fa-location-crosshairs text-accent", else: "fa-location-dot"}"}></i> | |
| 148 | + <%= station.site_name || station.aqsid %> | |
| 149 | + </td> | |
| 150 | + <td class="text-right whitespace-nowrap"> | |
| 151 | + <%= case aqi_of(station) do %> | |
| 152 | + <% nil -> %> | |
| 153 | + <span class="opacity-60">not reporting</span> | |
| 154 | + <% {value, pollutant} -> %> | |
| 155 | + <span class="badge badge-accent"><%= value %></span> | |
| 156 | + <span class="text-xs opacity-70 ml-1"><%= pollutant %></span> | |
| 157 | + <% end %> | |
| 158 | + </td> | |
| 159 | + </tr> | |
| 160 | + <% end %> | |
| 161 | + </tbody> | |
| 162 | + </table> | |
| 163 | + </div> | |
| 164 | + <% end %> | |
| 135 | 165 | </div> |
| 136 | 166 | </div> |
| 137 | 167 | </div> |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+32
−2
@@ -78,6 +78,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 78 | 78 | stations = case socket.assigns.source.type do |
| 79 | 79 | :gbfs -> Storage.list_gbfs_station_information(socket.assigns.source_id) |
| 80 | 80 | :gtfs -> socket.assigns.source_id |> Storage.list_stops() |> Enum.map(&stop_as_station/1) |
| 81 | + :aqi -> socket.assigns.source_id |> Storage.list_aqi_stations() |> Enum.map(&site_as_station/1) | |
| 81 | 82 | _ -> [] |
| 82 | 83 | end |
| 83 | 84 |
@@ -201,9 +202,37 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 201 | 202 | } |
| 202 | 203 | end |
| 203 | 204 | |
| 204 | − # GTFS queries key on :stop, GBFS on :stop_id. | |
| 205 | + # AirNow replaces its observations every hour, so a reading and the station | |
| 206 | + # that made it are one record. The name carries the current index, since that | |
| 207 | + # is what you want off a marker without opening it. | |
| 208 | + defp site_as_station(observation) do | |
| 209 | + %{ | |
| 210 | + place: observation.point, | |
| 211 | + station_id: observation.aqsid, | |
| 212 | + name: station_label(observation), | |
| 213 | + short_name: observation.aqsid, | |
| 214 | + capacity: 0, | |
| 215 | + address: observation.reporting_areas |> List.wrap() |> List.first(), | |
| 216 | + lat: observation.lat, | |
| 217 | + lon: observation.lon | |
| 218 | + } | |
| 219 | + end | |
| 220 | + | |
| 221 | + defp station_label(observation) do | |
| 222 | + name = observation.site_name || observation.aqsid | |
| 223 | + | |
| 224 | + case RoomSanctum.Storage.AirNow.HourlyObsData.overall_aqi(observation) do | |
| 225 | + nil -> name | |
| 226 | + {value, pollutant} -> "#{name} - AQI #{value} (#{pollutant})" | |
| 227 | + end | |
| 228 | + end | |
| 229 | + | |
| 230 | + # GTFS queries key on :stop, GBFS on :stop_id, AirNow on :aqsid. | |
| 205 | 231 | defp station_id_of(%{query: nil}), do: nil |
| 206 | − defp station_id_of(%{query: q}), do: Map.get(q, :stop_id) || Map.get(q, :stop) | |
| 232 | + | |
| 233 | + defp station_id_of(%{query: q}), | |
| 234 | + do: Map.get(q, :stop_id) || Map.get(q, :stop) || Map.get(q, :aqsid) | |
| 235 | + | |
| 207 | 236 | defp station_id_of(_), do: nil |
| 208 | 237 | |
| 209 | 238 | defp page_title(:show), do: "Offering Detail" |
@@ -235,6 +264,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 235 | 264 | |
| 236 | 265 | # GTFS names the field :stop, GBFS :stop_id. |
| 237 | 266 | defp station_query_for(:gtfs, id), do: %{"__type__" => "gtfs", "stop" => id} |
| 267 | + defp station_query_for(:aqi, id), do: %{"__type__" => "aqi", "aqsid" => id} | |
| 238 | 268 | defp station_query_for(type, id), do: %{"__type__" => to_string(type), "stop_id" => id} |
| 239 | 269 | |
| 240 | 270 | defp add_station_query(socket, station_id, name) do |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+3
−3
@@ -379,7 +379,7 @@ gl_available_projects={@gitlab_available_projects}
| 379 | 379 | class="" |
| 380 | 380 | height="600px" |
| 381 | 381 | add_query_event="map-add-query" |
| 382 | − queried_station_ids={Enum.map(@queries, fn q -> q.query && (Map.get(q.query, :stop_id) || Map.get(q.query, :stop)) end)} | |
| 382 | + queried_station_ids={Enum.map(@queries, &station_id_of/1)} | |
| 383 | 383 | /> |
| 384 | 384 | </div> |
| 385 | 385 |
@@ -582,7 +582,7 @@ gl_available_projects={@gitlab_available_projects}
| 582 | 582 | route_lines_event={if @source.type == :gtfs, do: "toggle-route-lines"} |
| 583 | 583 | show_route_lines={@show_route_lines} |
| 584 | 584 | source_tint={@source_tint} |
| 585 | − queried_station_ids={Enum.map(@queries, fn q -> q.query && (Map.get(q.query, :stop_id) || Map.get(q.query, :stop)) end)} | |
| 585 | + queried_station_ids={Enum.map(@queries, &station_id_of/1)} | |
| 586 | 586 | focus={%{lat: focus_lat, lng: focus_lon}} |
| 587 | 587 | focus_zoom={16} |
| 588 | 588 | height="360px" |
@@ -683,7 +683,7 @@ gl_available_projects={@gitlab_available_projects}
| 683 | 683 | route_lines_event={if @source.type == :gtfs, do: "toggle-route-lines"} |
| 684 | 684 | show_route_lines={@show_route_lines} |
| 685 | 685 | source_tint={@source_tint} |
| 686 | − queried_station_ids={Enum.map(@queries, fn q -> q.query && (Map.get(q.query, :stop_id) || Map.get(q.query, :stop)) end)} | |
| 686 | + queried_station_ids={Enum.map(@queries, &station_id_of/1)} | |
| 687 | 687 | focus={%{lat: station_lat, lng: station_lon}} |
| 688 | 688 | focus_zoom={16} |
| 689 | 689 | height="360px" |
added
apps/room_sanctum/priv/repo/migrations/20260811150000_normalise_airnow_points.exs
+42
−0
@@ -0,0 +1,42 @@
| 1 | +defmodule RoomSanctum.Repo.Migrations.NormaliseAirnowPoints do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + @moduledoc """ | |
| 5 | + AirNow points were stored {lat, lon}, matching how foci used to be stored. | |
| 6 | + Two transposed geometries compare correctly against each other, so "nearest | |
| 7 | + station to this foci" worked -- until foci were normalised and the pair | |
| 8 | + stopped agreeing, which put Boston's nearest monitor in Mozambique. | |
| 9 | + | |
| 10 | + The observations table is replaced wholesale every hour, so this only matters | |
| 11 | + until the next refresh; the writer is fixed alongside. Migrating anyway so | |
| 12 | + the data is right the moment this ships rather than an hour later. | |
| 13 | + """ | |
| 14 | + | |
| 15 | + def up do | |
| 16 | + for {table, column} <- [ | |
| 17 | + {"airnow_hourly_observations", "point"}, | |
| 18 | + {"airnow_monitoring_sites", "point"} | |
| 19 | + ] do | |
| 20 | + execute(""" | |
| 21 | + UPDATE #{table} | |
| 22 | + SET #{column} = ST_SetSRID( | |
| 23 | + ST_MakePoint(ST_Y(#{column}::geometry), ST_X(#{column}::geometry)), 4326) | |
| 24 | + WHERE #{column} IS NOT NULL | |
| 25 | + """) | |
| 26 | + end | |
| 27 | + end | |
| 28 | + | |
| 29 | + def down do | |
| 30 | + for {table, column} <- [ | |
| 31 | + {"airnow_hourly_observations", "point"}, | |
| 32 | + {"airnow_monitoring_sites", "point"} | |
| 33 | + ] do | |
| 34 | + execute(""" | |
| 35 | + UPDATE #{table} | |
| 36 | + SET #{column} = ST_SetSRID( | |
| 37 | + ST_MakePoint(ST_Y(#{column}::geometry), ST_X(#{column}::geometry)), 4326) | |
| 38 | + WHERE #{column} IS NOT NULL | |
| 39 | + """) | |
| 40 | + end | |
| 41 | + end | |
| 42 | +end |
added
apps/room_sanctum/test/room_sanctum_web/live/airnow_test.exs
+212
−0
@@ -0,0 +1,212 @@
| 1 | +defmodule RoomSanctumWeb.AirNowTest do | |
| 2 | + @moduledoc """ | |
| 3 | + AirNow on the maps. | |
| 4 | + | |
| 5 | + The feed replaces its observations every hour, so a reading and the station | |
| 6 | + that made it are the same record -- "the stations" are just the current | |
| 7 | + observations. | |
| 8 | + """ | |
| 9 | + use RoomSanctumWeb.ConnCase | |
| 10 | + | |
| 11 | + import Phoenix.LiveViewTest | |
| 12 | + | |
| 13 | + alias RoomSanctum.{Accounts, Configuration, Repo, Storage} | |
| 14 | + alias RoomSanctum.Storage.AirNow.HourlyObsData | |
| 15 | + | |
| 16 | + # Home is in Boston; Maputo is 12,000km away and must never be "nearby". | |
| 17 | + @stations [ | |
| 18 | + {"BOS-1", "Kenmore Sq", 42.3489, -71.0977, 52}, | |
| 19 | + {"BOS-2", "North End", 42.3631, -71.0542, 41}, | |
| 20 | + {"FAR-1", "Maputo", -25.9038, 32.6284, 18} | |
| 21 | + ] | |
| 22 | + | |
| 23 | + setup %{conn: conn} do | |
| 24 | + {:ok, user} = | |
| 25 | + Accounts.register_user(%{ | |
| 26 | + email: "air#{System.unique_integer([:positive])}@example.com", | |
| 27 | + password: "hello world!hello world!" | |
| 28 | + }) | |
| 29 | + | |
| 30 | + {:ok, source} = | |
| 31 | + Configuration.create_source(%{ | |
| 32 | + name: "AirNow", notes: "", type: :aqi, enabled: true, | |
| 33 | + user_id: user.id, config: %{"__type__" => "aqi"} | |
| 34 | + }) | |
| 35 | + | |
| 36 | + {:ok, foci} = | |
| 37 | + Configuration.create_foci(%{ | |
| 38 | + name: "Home", user_id: user.id, | |
| 39 | + place: %Geo.Point{coordinates: {-71.0589, 42.3601}, srid: 4326} | |
| 40 | + }) | |
| 41 | + | |
| 42 | + for {aqsid, name, lat, lon, pm} <- @stations do | |
| 43 | + Repo.insert!(%HourlyObsData{ | |
| 44 | + source_id: source.id, aqsid: aqsid, site_name: name, | |
| 45 | + lat: lat, lon: lon, | |
| 46 | + point: %Geo.Point{coordinates: {lon, lat}, srid: 4326}, | |
| 47 | + pm25_aqi: pm, pm25_measured: true, | |
| 48 | + valid_date: ~D[2026-08-11], valid_time: ~T[16:00:00], | |
| 49 | + reporting_areas: [name] | |
| 50 | + }) | |
| 51 | + end | |
| 52 | + | |
| 53 | + %{conn: log_in_user(conn, user), user: user, source: source, foci: foci} | |
| 54 | + end | |
| 55 | + | |
| 56 | + defp foci_query(ctx, name \\ "Air at home") do | |
| 57 | + {:ok, query} = | |
| 58 | + Configuration.create_query(%{ | |
| 59 | + name: name, notes: "", source_id: ctx.source.id, user_id: ctx.user.id, | |
| 60 | + query: %{"__type__" => "aqi", "foci_id" => ctx.foci.id} | |
| 61 | + }) | |
| 62 | + | |
| 63 | + query | |
| 64 | + end | |
| 65 | + | |
| 66 | + describe "finding stations" do | |
| 67 | + test "nearest returns the closest first, and never the far one", ctx do | |
| 68 | + names = Storage.nearest_aqi_stations(ctx.source.id, ctx.foci.id, 2) |> Enum.map(& &1.site_name) | |
| 69 | + | |
| 70 | + assert names == ["North End", "Kenmore Sq"] | |
| 71 | + refute "Maputo" in names | |
| 72 | + end | |
| 73 | + | |
| 74 | + test "every reporting station is listed for the source", ctx do | |
| 75 | + ids = Storage.list_aqi_stations(ctx.source.id) |> Enum.map(& &1.aqsid) |> Enum.sort() | |
| 76 | + | |
| 77 | + assert ids == ["BOS-1", "BOS-2", "FAR-1"] | |
| 78 | + end | |
| 79 | + | |
| 80 | + test "a station can be fetched by its aqs id", ctx do | |
| 81 | + assert [station] = Storage.get_aqi_station(ctx.source.id, "BOS-1") | |
| 82 | + assert station.site_name == "Kenmore Sq" | |
| 83 | + end | |
| 84 | + | |
| 85 | + test "an unknown station is empty, not an error", ctx do | |
| 86 | + assert Storage.get_aqi_station(ctx.source.id, "nope") == [] | |
| 87 | + end | |
| 88 | + end | |
| 89 | + | |
| 90 | + describe "the headline reading" do | |
| 91 | + test "is the worst sub-index, and says which" do | |
| 92 | + assert HourlyObsData.overall_aqi(%{pm25_aqi: 52, ozone_aqi: 38, pm10_aqi: nil, no2_aqi: 12}) == | |
| 93 | + {52, "PM2.5"} | |
| 94 | + | |
| 95 | + assert HourlyObsData.overall_aqi(%{pm25_aqi: 12, ozone_aqi: 90, pm10_aqi: nil, no2_aqi: nil}) == | |
| 96 | + {90, "Ozone"} | |
| 97 | + end | |
| 98 | + | |
| 99 | + test "a station reporting nothing has none" do | |
| 100 | + assert HourlyObsData.overall_aqi(%{pm25_aqi: nil, ozone_aqi: nil, pm10_aqi: nil, no2_aqi: nil}) == | |
| 101 | + nil | |
| 102 | + | |
| 103 | + assert HourlyObsData.overall_aqi(nil) == nil | |
| 104 | + end | |
| 105 | + end | |
| 106 | + | |
| 107 | + describe "a query by station id" do | |
| 108 | + test "is accepted without a foci", ctx do | |
| 109 | + {:ok, query} = | |
| 110 | + Configuration.create_query(%{ | |
| 111 | + name: "Just Kenmore", notes: "", source_id: ctx.source.id, user_id: ctx.user.id, | |
| 112 | + query: %{"__type__" => "aqi", "aqsid" => "BOS-1"} | |
| 113 | + }) | |
| 114 | + | |
| 115 | + assert query.query.aqsid == "BOS-1" | |
| 116 | + assert query.query.foci_id == nil | |
| 117 | + end | |
| 118 | + | |
| 119 | + test "a query naming neither a foci nor a station is refused", ctx do | |
| 120 | + assert {:error, _} = | |
| 121 | + Configuration.create_query(%{ | |
| 122 | + name: "Nowhere", notes: "", source_id: ctx.source.id, user_id: ctx.user.id, | |
| 123 | + query: %{"__type__" => "aqi"} | |
| 124 | + }) | |
| 125 | + end | |
| 126 | + | |
| 127 | + test "a blank station id does not pass for one", ctx do | |
| 128 | + assert {:error, _} = | |
| 129 | + Configuration.create_query(%{ | |
| 130 | + name: "Blank", notes: "", source_id: ctx.source.id, user_id: ctx.user.id, | |
| 131 | + query: %{"__type__" => "aqi", "aqsid" => " "} | |
| 132 | + }) | |
| 133 | + end | |
| 134 | + end | |
| 135 | + | |
| 136 | + describe "the query page" do | |
| 137 | + test "lists the station it answers with, then its neighbours", ctx do | |
| 138 | + {:ok, _live, html} = live(ctx.conn, Routes.query_show_path(ctx.conn, :show, foci_query(ctx))) | |
| 139 | + | |
| 140 | + assert html =~ "North End" | |
| 141 | + assert html =~ "Kenmore Sq" | |
| 142 | + # the nearest is marked out from the rest | |
| 143 | + assert html =~ "fa-location-crosshairs" | |
| 144 | + end | |
| 145 | + | |
| 146 | + test "shows each station's current reading", ctx do | |
| 147 | + {:ok, _live, html} = live(ctx.conn, Routes.query_show_path(ctx.conn, :show, foci_query(ctx))) | |
| 148 | + | |
| 149 | + assert html =~ "52" | |
| 150 | + assert html =~ "41" | |
| 151 | + assert html =~ "PM2.5" | |
| 152 | + end | |
| 153 | + | |
| 154 | + test "puts them on its map", ctx do | |
| 155 | + {:ok, _live, html} = live(ctx.conn, Routes.query_show_path(ctx.conn, :show, foci_query(ctx))) | |
| 156 | + | |
| 157 | + assert length(Regex.scan(~r/marker-station_/, html)) == 3 | |
| 158 | + end | |
| 159 | + | |
| 160 | + test "a station-id query gets neighbours too", ctx do | |
| 161 | + {:ok, query} = | |
| 162 | + Configuration.create_query(%{ | |
| 163 | + name: "Just Kenmore", notes: "", source_id: ctx.source.id, user_id: ctx.user.id, | |
| 164 | + query: %{"__type__" => "aqi", "aqsid" => "BOS-1"} | |
| 165 | + }) | |
| 166 | + | |
| 167 | + {:ok, _live, html} = live(ctx.conn, Routes.query_show_path(ctx.conn, :show, query)) | |
| 168 | + | |
| 169 | + assert html =~ "Kenmore Sq" | |
| 170 | + assert html =~ "North End" | |
| 171 | + end | |
| 172 | + end | |
| 173 | + | |
| 174 | + describe "the source map" do | |
| 175 | + test "shows every station, with its reading on the marker", ctx do | |
| 176 | + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, ctx.source)) | |
| 177 | + send(live.pid, :update_sec) | |
| 178 | + html = render_click(live, "toggle-view", %{}) | |
| 179 | + | |
| 180 | + ids = Regex.scan(~r/marker-station_([A-Z0-9-]+)"/, html) |> Enum.map(&List.last/1) |> Enum.sort() | |
| 181 | + | |
| 182 | + assert ids == ["BOS-1", "BOS-2", "FAR-1"] | |
| 183 | + assert html =~ "AQI 52" | |
| 184 | + end | |
| 185 | + | |
| 186 | + test "a station can be turned into a query from the map", ctx do | |
| 187 | + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, ctx.source)) | |
| 188 | + send(live.pid, :update_sec) | |
| 189 | + render_click(live, "toggle-view", %{}) | |
| 190 | + | |
| 191 | + render_click(live, "map-add-query", %{"station-id" => "BOS-2", "name" => "North End"}) | |
| 192 | + | |
| 193 | + created = | |
| 194 | + Configuration.get_queries(:source, ctx.source.id) |> Enum.find(&(&1.name == "North End")) | |
| 195 | + | |
| 196 | + assert created.query.aqsid == "BOS-2" | |
| 197 | + assert created.query.foci_id == nil | |
| 198 | + end | |
| 199 | + | |
| 200 | + test "a station that already has a query is not offered again", ctx do | |
| 201 | + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, ctx.source)) | |
| 202 | + send(live.pid, :update_sec) | |
| 203 | + render_click(live, "toggle-view", %{}) | |
| 204 | + render_click(live, "map-add-query", %{"station-id" => "BOS-2", "name" => "North End"}) | |
| 205 | + | |
| 206 | + send(live.pid, :update_sec) | |
| 207 | + html = render(live) | |
| 208 | + | |
| 209 | + assert html =~ ~s(data-has-query="1") | |
| 210 | + end | |
| 211 | + end | |
| 212 | +end |
Parents: 395f406