prop/lib/microwaveprop/weather/hrrr_profile_lookup.ex
Graham McIntire a4cd7632eb
feat(skewt): hover crosshair + DB fallback for missing on-disk profiles
Two follow-ups to the earlier /skewt work:

1. Interactive crosshair (matches the SPC-viewer feel from the
   reference screenshot). Mousing over the diagram now drops a
   horizontal pressure cursor with a top-left readout box showing the
   pressure at the cursor's altitude (mb), the height in m and ft,
   and the linearly interpolated temperature and dewpoint at that
   level. Two coloured dots track the T and Td traces along the
   cursor so the user can read the spread visually.

   Mechanics: SkewtSvg.geometry/0 exposes the plot geometry (left,
   right, top, bottom, p_bottom, p_top, t_min, t_max, skew) as JSON;
   SkewtSvg emits an initially-hidden <g class="skewt-cursor"> layer.
   A new Skewt hook in assets/js/app.ts inverts pressure_y to recover
   pressure from the cursor's viewBox-Y, walks the (descending-pres-
   sorted) profile to interp T/Td/height, and updates the elements.
   The hook also handles mouseleave to hide the cursor and uses the
   SVG's screen-CTM so the math stays correct under any container
   aspect-ratio resize.

2. DB fallback for `available_valid_times`/`load_profile`. When the
   on-disk `ProfilesFile` has nothing for the location-and-window
   (true on dev, transient on prod between chain runs), SkewtLive
   now falls back to `HrrrProfileLookup.{list_valid_times_near,
   read_point_near}/3` against the `hrrr_profiles` Postgres table.
   The lookup uses the same ±0.07° spatial tolerance and the
   `(lat, lon, valid_time)` unique index that `Weather.find_nearest_
   hrrr/3` already relies on, so the queries are index-only.

   Returned shape matches `ProfilesFile.read_point/3` so SkewtLive
   reads from either source transparently. NaiveDateTime → UTC
   normalisation is centralised in `ensure_utc/1` so the LiveView
   sees `%DateTime{time_zone: "Etc/UTC"}` regardless of which side
   produced the value.

Tests: 6/6 new HrrrProfileLookupTest cases (list/limit/tolerance,
read nearest cell, nil for far cells, picks closest among matches),
3/3 SkewtLiveTest still green, 2,894 total Elixir tests + 221
properties green via mix test (one pre-existing flaky test in
admin/contact_edit_live re-runs clean). mix credo --strict clean.
mix assets.build clean.
2026-04-25 13:17:58 -05:00

112 lines
3.9 KiB
Elixir

defmodule Microwaveprop.Weather.HrrrProfileLookup do
@moduledoc """
Read-side lookups against the `hrrr_profiles` table for clients that
cannot use the on-disk `Propagation.ProfilesFile` store — typically
the dev environment (no NFS mount, no live grid pipeline) or any
point query for a location the grid file doesn't cover.
Uses the same ±0.07° spatial bounding-box tolerance that
`Weather.find_nearest_hrrr/3` uses (one HRRR grid cell at 0.125° is
~0.07° at mid-latitudes). The 4-column `(lat, lon, valid_time)`
unique index makes the bbox + ORDER BY (squared distance) plan
index-only.
"""
import Ecto.Query
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrProfile
@default_tolerance_deg 0.07
@default_limit 32
@doc """
Distinct `valid_time`s with at least one HRRR profile within
`tolerance_deg` of the point, sorted ascending. The `:limit` keeps
the most recent N (then re-sorts ascending so the SkewtLive time
selector renders left-to-right in chronological order).
Options:
* `:tolerance_deg` — default `#{@default_tolerance_deg}`
* `:limit` — default `#{@default_limit}`
"""
@spec list_valid_times_near(float(), float(), keyword()) :: [DateTime.t()]
def list_valid_times_near(lat, lon, opts \\ []) when is_number(lat) and is_number(lon) do
tolerance = Keyword.get(opts, :tolerance_deg, @default_tolerance_deg)
limit = Keyword.get(opts, :limit, @default_limit)
query =
from p in HrrrProfile,
where:
p.lat >= ^(lat - tolerance) and p.lat <= ^(lat + tolerance) and
p.lon >= ^(lon - tolerance) and p.lon <= ^(lon + tolerance),
group_by: p.valid_time,
order_by: [desc: p.valid_time],
limit: ^limit,
select: p.valid_time
query
|> Repo.all()
|> Enum.map(&ensure_utc/1)
|> Enum.sort(DateTime)
end
@doc """
Returns the spatially closest HRRR profile within `tolerance_deg`
for the requested `valid_time`, or `nil` if none. Returned shape
matches what `Propagation.ProfilesFile.read_point/3` yields so
SkewtLive can substitute one for the other transparently.
Options:
* `:tolerance_deg` — default `#{@default_tolerance_deg}`
"""
@spec read_point_near(DateTime.t(), float(), float(), keyword()) :: map() | nil
def read_point_near(%DateTime{} = valid_time, lat, lon, opts \\ []) when is_number(lat) and is_number(lon) do
tolerance = Keyword.get(opts, :tolerance_deg, @default_tolerance_deg)
naive = DateTime.to_naive(valid_time)
query =
from p in HrrrProfile,
where:
p.valid_time == ^naive and
p.lat >= ^(lat - tolerance) and p.lat <= ^(lat + tolerance) and
p.lon >= ^(lon - tolerance) and p.lon <= ^(lon + tolerance),
# squared-Euclidean distance is monotonic with great-circle at
# this scale and avoids a trig call per row
order_by: [
asc: fragment("(? - ?)*(? - ?) + (? - ?)*(? - ?)", p.lat, ^lat, p.lat, ^lat, p.lon, ^lon, p.lon, ^lon)
],
limit: 1
case Repo.one(query) do
nil -> nil
%HrrrProfile{} = row -> to_cell(row)
end
end
defp to_cell(%HrrrProfile{} = row) do
%{
lat: row.lat,
lon: row.lon,
valid_time: ensure_utc(row.valid_time),
profile: row.profile || [],
hpbl_m: row.hpbl_m,
pwat_mm: row.pwat_mm,
surface_temp_c: row.surface_temp_c,
surface_dewpoint_c: row.surface_dewpoint_c,
surface_pressure_mb: row.surface_pressure_mb,
surface_refractivity: row.surface_refractivity,
min_refractivity_gradient: row.min_refractivity_gradient,
ducting_detected: row.ducting_detected,
duct_characteristics: row.duct_characteristics
}
end
defp ensure_utc(%DateTime{time_zone: "Etc/UTC"} = dt), do: dt
defp ensure_utc(%DateTime{} = dt), do: DateTime.shift_zone!(dt, "Etc/UTC")
defp ensure_utc(%NaiveDateTime{} = ndt) do
{:ok, dt} = DateTime.from_naive(ndt, "Etc/UTC")
dt
end
end