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.
112 lines
3.9 KiB
Elixir
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
|