From a4cd7632ebeb3575551512e0643fa1eeeaf99bda Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 13:17:56 -0500 Subject: [PATCH] feat(skewt): hover crosshair + DB fallback for missing on-disk profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- assets/js/app.ts | 184 +++++++++++++++++- .../weather/hrrr_profile_lookup.ex | 112 +++++++++++ lib/microwaveprop_web/live/skewt_live.ex | 40 +++- lib/microwaveprop_web/live/skewt_svg.ex | 51 +++++ .../weather/hrrr_profile_lookup_test.exs | 119 +++++++++++ 5 files changed, 496 insertions(+), 10 deletions(-) create mode 100644 lib/microwaveprop/weather/hrrr_profile_lookup.ex create mode 100644 test/microwaveprop/weather/hrrr_profile_lookup_test.exs diff --git a/assets/js/app.ts b/assets/js/app.ts index 8c60c078..3b03abee 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -124,11 +124,193 @@ const CommaNumber = { } } +// Skew-T crosshair: tracks mouse Y, inverts pressure_y to find the +// pressure level under the cursor, interpolates T/Td/height from the +// profile array (passed in via data-profile JSON) and updates the +// crosshair line + readout that SkewtSvg emits with display:none. +interface SkewtHook extends ViewHook { + svg: SVGSVGElement | null + cursor: SVGElement | null + line: SVGLineElement | null + dotT: SVGCircleElement | null + dotD: SVGCircleElement | null + bg: SVGRectElement | null + text: SVGTextElement | null + lines: (SVGTSpanElement | null)[] + geom: SkewtGeom + normalized: SkewtLevel[] + onMove(e: MouseEvent): void + onLeave(): void + pressureAtY(y: number): number + temperatureX(t: number, y: number): number + interp(field: "tmpc" | "dwpc" | "hght", p: number): number | null + clientToViewBox(e: MouseEvent): {x: number, y: number} | null + handleMove(e: MouseEvent): void +} + +interface SkewtGeom { + width: number; height: number + left: number; right: number; top: number; bottom: number + p_bottom: number; p_top: number + t_min: number; t_max: number + skew: number +} + +interface SkewtLevel { + pres: number + hght: number + tmpc: number | null + dwpc: number | null +} + +const Skewt = { + mounted(this: SkewtHook) { + this.svg = this.el.querySelector("svg") + this.cursor = this.el.querySelector("[data-cursor]") + this.line = this.el.querySelector(".skewt-cursor-line") + this.dotT = this.el.querySelector("[data-cursor-dot-t]") + this.dotD = this.el.querySelector("[data-cursor-dot-d]") + this.bg = this.el.querySelector("[data-cursor-readout-bg]") + this.text = this.el.querySelector("[data-cursor-readout]") + this.lines = [ + this.el.querySelector("[data-cursor-line-1]"), + this.el.querySelector("[data-cursor-line-2]"), + this.el.querySelector("[data-cursor-line-3]"), + this.el.querySelector("[data-cursor-line-4]"), + ] + + let rawProfile: any[] = [] + try { rawProfile = JSON.parse(this.el.dataset.profile || "[]") } catch { rawProfile = [] } + try { this.geom = JSON.parse(this.el.dataset.geometry || "{}") } catch { this.geom = {} as SkewtGeom } + + this.normalized = [] + for (const lvl of rawProfile) { + const pres = +(lvl.pres ?? lvl.pres_mb) + const hght = +(lvl.hght ?? lvl.hght_m) + const tmpc = lvl.tmpc, dwpc = lvl.dwpc + if (Number.isFinite(pres) && Number.isFinite(hght)) { + this.normalized.push({ + pres, hght, + tmpc: Number.isFinite(tmpc) ? +tmpc : null, + dwpc: Number.isFinite(dwpc) ? +dwpc : null, + }) + } + } + this.normalized.sort((a, b) => b.pres - a.pres) + + this.onMove = (e: MouseEvent) => this.handleMove(e) + this.onLeave = () => { if (this.cursor) (this.cursor as SVGElement).style.display = "none" } + if (this.svg) { + this.svg.addEventListener("mousemove", this.onMove) + this.svg.addEventListener("mouseleave", this.onLeave) + } + }, + + destroyed(this: SkewtHook) { + if (this.svg) { + this.svg.removeEventListener("mousemove", this.onMove) + this.svg.removeEventListener("mouseleave", this.onLeave) + } + }, + + pressureAtY(this: SkewtHook, y: number) { + const g = this.geom + const lnB = Math.log(g.p_bottom) + const lnT = Math.log(g.p_top) + const frac = (g.bottom - y) / (g.bottom - g.top) + return Math.exp(lnB - frac * (lnB - lnT)) + }, + + temperatureX(this: SkewtHook, t: number, y: number) { + const g = this.geom + const plotW = g.right - g.left + const baseX = g.left + ((t - g.t_min) / (g.t_max - g.t_min)) * plotW + return baseX + g.skew * (g.bottom - y) + }, + + interp(this: SkewtHook, field: "tmpc" | "dwpc" | "hght", p: number): number | null { + const prof = this.normalized + if (prof.length === 0) return null + if (p >= prof[0].pres) return prof[0][field] as number | null + if (p <= prof[prof.length - 1].pres) return prof[prof.length - 1][field] as number | null + for (let i = 0; i < prof.length - 1; i++) { + const a = prof[i], b = prof[i + 1] + if (p <= a.pres && p >= b.pres) { + const av = a[field] as number | null, bv = b[field] as number | null + if (av == null || bv == null) return av ?? bv + const f = (a.pres - p) / (a.pres - b.pres) + return av + f * (bv - av) + } + } + return null + }, + + clientToViewBox(this: SkewtHook, e: MouseEvent) { + if (!this.svg) return null + const ctm = this.svg.getScreenCTM() + if (!ctm) return null + const pt = this.svg.createSVGPoint() + pt.x = e.clientX + pt.y = e.clientY + const local = pt.matrixTransform(ctm.inverse()) + return {x: local.x, y: local.y} + }, + + handleMove(this: SkewtHook, e: MouseEvent) { + if (this.normalized.length === 0) return + const v = this.clientToViewBox(e) + if (!v) return + const g = this.geom + if (v.y < g.top || v.y > g.bottom) { + if (this.cursor) (this.cursor as SVGElement).style.display = "none" + return + } + + const p = this.pressureAtY(v.y) + const tC = this.interp("tmpc", p) + const tdC = this.interp("dwpc", p) + const hgtM = this.interp("hght", p) + + if (this.line) { this.line.setAttribute("y1", String(v.y)); this.line.setAttribute("y2", String(v.y)) } + + if (tC != null && this.dotT) { + this.dotT.setAttribute("cx", String(this.temperatureX(tC, v.y))) + this.dotT.setAttribute("cy", String(v.y)) + this.dotT.style.display = "" + } else if (this.dotT) { + this.dotT.style.display = "none" + } + if (tdC != null && this.dotD) { + this.dotD.setAttribute("cx", String(this.temperatureX(tdC, v.y))) + this.dotD.setAttribute("cy", String(v.y)) + this.dotD.style.display = "" + } else if (this.dotD) { + this.dotD.style.display = "none" + } + + const boxX = g.left + 6 + const boxY = g.top + 6 + if (this.bg) { this.bg.setAttribute("x", String(boxX)); this.bg.setAttribute("y", String(boxY)) } + if (this.text) { this.text.setAttribute("x", String(boxX + 6)); this.text.setAttribute("y", String(boxY + 4)) } + this.lines.forEach(el => el?.setAttribute("x", String(boxX + 6))) + + const ftStr = (hgtM != null && Number.isFinite(hgtM)) ? Math.round(hgtM * 3.28084).toLocaleString() : "—" + const mStr = (hgtM != null && Number.isFinite(hgtM)) ? Math.round(hgtM).toLocaleString() : "—" + const fmt = (val: number | null) => val == null || !Number.isFinite(val) ? "—" : `${val.toFixed(1)}°C` + if (this.lines[0]) this.lines[0]!.textContent = `${p.toFixed(1)} mb` + if (this.lines[1]) this.lines[1]!.textContent = `${mStr} m / ${ftStr} ft` + if (this.lines[2]) this.lines[2]!.textContent = `T ${fmt(tC)}` + if (this.lines[3]) this.lines[3]!.textContent = `Td ${fmt(tdC)}` + + if (this.cursor) (this.cursor as SVGElement).style.display = "" + } +} as Partial + const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: initLiveStash({_csrf_token: csrfToken}), - hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti, EmeGlobe}, + hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti, EmeGlobe, Skewt}, }) // live_table rows are dead on their own — wire up whole-row clicks to diff --git a/lib/microwaveprop/weather/hrrr_profile_lookup.ex b/lib/microwaveprop/weather/hrrr_profile_lookup.ex new file mode 100644 index 00000000..66f499f7 --- /dev/null +++ b/lib/microwaveprop/weather/hrrr_profile_lookup.ex @@ -0,0 +1,112 @@ +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 diff --git a/lib/microwaveprop_web/live/skewt_live.ex b/lib/microwaveprop_web/live/skewt_live.ex index bce7ea84..c32f3793 100644 --- a/lib/microwaveprop_web/live/skewt_live.ex +++ b/lib/microwaveprop_web/live/skewt_live.ex @@ -12,6 +12,7 @@ defmodule MicrowavepropWeb.SkewtLive do use MicrowavepropWeb, :live_view alias Microwaveprop.Propagation.ProfilesFile + alias Microwaveprop.Weather.HrrrProfileLookup alias Microwaveprop.Weather.SoundingParams alias MicrowavepropWeb.SkewtLocationResolver alias MicrowavepropWeb.SkewtSvg @@ -68,7 +69,7 @@ defmodule MicrowavepropWeb.SkewtLive do defp refresh(socket, query, requested_time) do case SkewtLocationResolver.resolve(query) do {:ok, %{lat: lat, lon: lon} = location} -> - valid_times = available_valid_times() + valid_times = available_valid_times(lat, lon) chosen = pick_valid_time(valid_times, requested_time) @@ -107,7 +108,9 @@ defmodule MicrowavepropWeb.SkewtLive do end defp load_profile(valid_time, lat, lon) do - case ProfilesFile.read_point(valid_time, lat, lon) do + cell = ProfilesFile.read_point(valid_time, lat, lon) || HrrrProfileLookup.read_point_near(valid_time, lat, lon) + + case cell do nil -> {nil, nil, nil} @@ -128,7 +131,7 @@ defmodule MicrowavepropWeb.SkewtLive do cell[:profile] || cell["profile"] || [] end - defp available_valid_times do + defp available_valid_times(lat, lon) do # The chain produces analysis + 18 forecast hours every wall-clock # hour. By the time the next chain finishes the previous chain's # analysis is up to 70 min old, and during a missed cycle it can be @@ -138,11 +141,23 @@ defmodule MicrowavepropWeb.SkewtLive do past_cutoff = DateTime.add(now, -3 * 3600, :second) future_cutoff = DateTime.add(now, 18 * 3600, :second) - ProfilesFile.list_valid_times() - |> Enum.filter(fn t -> - DateTime.compare(t, past_cutoff) != :lt and DateTime.compare(t, future_cutoff) != :gt - end) - |> Enum.sort(DateTime) + on_disk = + Enum.filter(ProfilesFile.list_valid_times(), fn t -> + DateTime.compare(t, past_cutoff) != :lt and DateTime.compare(t, future_cutoff) != :gt + end) + + case on_disk do + [] -> + # On-disk store is empty for this window — common in dev (no NFS + # mount, no local Rust pipeline) and during missed-cycle windows + # in prod. Fall back to whatever HRRR profiles the DB has near + # the location, regardless of how old. Better to render historic + # data than a "no profiles stored" stub. + HrrrProfileLookup.list_valid_times_near(lat, lon) + + times -> + Enum.sort(times, DateTime) + end end defp pick_valid_time([], _requested), do: nil @@ -243,7 +258,14 @@ defmodule MicrowavepropWeb.SkewtLive do <%= if @svg do %>
-
+
{Phoenix.HTML.raw(@svg)}
diff --git a/lib/microwaveprop_web/live/skewt_svg.ex b/lib/microwaveprop_web/live/skewt_svg.ex index dde4ad99..ff13565a 100644 --- a/lib/microwaveprop_web/live/skewt_svg.ex +++ b/lib/microwaveprop_web/live/skewt_svg.ex @@ -48,10 +48,31 @@ defmodule MicrowavepropWeb.SkewtSvg do frame(), axis_labels(), profile_traces(cleaned), + cursor_layer(), "" ] end + # Crosshair layer the JS hook (`assets/js/skewt_hook.js`) drives. + # Initially `display: none`; the hook flips display + updates element + # coordinates on `mousemove`. + defp cursor_layer do + """ + + """ + end + defp style_block do """ @@ -68,11 +89,41 @@ defmodule MicrowavepropWeb.SkewtSvg do .pres-label { font: 9px ui-sans-serif, system-ui, sans-serif; fill: currentColor; fill-opacity: 0.7; } .temp-line { fill: none; stroke: #dc2626; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } .dew-line { fill: none; stroke: #16a34a; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } + .skewt-cursor-line { stroke: currentColor; stroke-opacity: 0.5; stroke-width: 0.8; stroke-dasharray: 3 3; pointer-events: none; } + .skewt-cursor-dot-t { fill: #dc2626; pointer-events: none; } + .skewt-cursor-dot-d { fill: #16a34a; pointer-events: none; } + .skewt-cursor-readout-bg { fill: rgba(15,23,42,0.85); pointer-events: none; } + .skewt-cursor-readout { font: 11px ui-monospace, SFMono-Regular, monospace; fill: #f8fafc; pointer-events: none; } """ end + # ── Geometry exposed to the hover hook ────────────────────────────── + + @doc """ + Plot geometry constants the JS hover hook needs to invert pixel-Y + back to pressure and to project temperature values onto the skewed + diagram. Returned as a plain map so it serializes to JSON via + `Jason.encode!/1` straight onto a `data-` attribute. + """ + @spec geometry() :: map() + def geometry do + %{ + width: @width, + height: @height, + left: @left, + right: @right, + top: @top, + bottom: @bottom, + p_bottom: @p_bottom, + p_top: @p_top, + t_min: @t_min, + t_max: @t_max, + skew: @skew + } + end + # ── Coordinate transforms ─────────────────────────────────────────── @doc false diff --git a/test/microwaveprop/weather/hrrr_profile_lookup_test.exs b/test/microwaveprop/weather/hrrr_profile_lookup_test.exs new file mode 100644 index 00000000..687b50c8 --- /dev/null +++ b/test/microwaveprop/weather/hrrr_profile_lookup_test.exs @@ -0,0 +1,119 @@ +defmodule Microwaveprop.Weather.HrrrProfileLookupTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Weather.HrrrProfile + alias Microwaveprop.Weather.HrrrProfileLookup + + describe "list_valid_times_near/3" do + test "returns valid_times of every profile within the spatial tolerance" do + insert_profile!(%{lat: 32.625, lon: -97.125, valid_time: ~U[2026-04-25 14:00:00Z]}) + insert_profile!(%{lat: 32.625, lon: -97.125, valid_time: ~U[2026-04-25 15:00:00Z]}) + # Far cell — must be excluded. + insert_profile!(%{lat: 40.000, lon: -80.000, valid_time: ~U[2026-04-25 14:00:00Z]}) + + assert [t1, t2] = HrrrProfileLookup.list_valid_times_near(32.65, -97.13, []) + assert DateTime.to_iso8601(t1) == "2026-04-25T14:00:00Z" + assert DateTime.to_iso8601(t2) == "2026-04-25T15:00:00Z" + end + + test "limit option caps the number of times returned (most recent first kept)" do + for h <- 0..4 do + valid_time = DateTime.add(~U[2026-04-25 12:00:00Z], h * 3600, :second) + insert_profile!(%{lat: 32.625, lon: -97.125, valid_time: valid_time}) + end + + times = HrrrProfileLookup.list_valid_times_near(32.65, -97.13, limit: 3) + assert length(times) == 3 + # Capped to the 3 most recent — but still sorted ascending in the + # returned list so SkewtLive's selector renders left-to-right. + assert Enum.map(times, &DateTime.to_iso8601/1) == [ + "2026-04-25T14:00:00Z", + "2026-04-25T15:00:00Z", + "2026-04-25T16:00:00Z" + ] + end + + test "tolerance option widens the spatial search box" do + insert_profile!(%{lat: 32.625, lon: -97.125, valid_time: ~U[2026-04-25 14:00:00Z]}) + # 0.20° away, outside the default 0.07° box but inside 0.25°. + insert_profile!(%{lat: 32.825, lon: -97.125, valid_time: ~U[2026-04-25 15:00:00Z]}) + + tight = HrrrProfileLookup.list_valid_times_near(32.625, -97.125, tolerance_deg: 0.07) + wide = HrrrProfileLookup.list_valid_times_near(32.625, -97.125, tolerance_deg: 0.25) + assert length(tight) == 1 + assert length(wide) == 2 + end + end + + describe "read_point_near/3" do + test "returns the profile of the nearest cell at the requested valid_time" do + insert_profile!(%{ + lat: 32.625, + lon: -97.125, + valid_time: ~U[2026-04-25 14:00:00Z], + profile: [ + %{"pres" => 1000.0, "hght" => 100.0, "tmpc" => 20.0, "dwpc" => 15.0}, + %{"pres" => 850.0, "hght" => 1500.0, "tmpc" => 12.0, "dwpc" => 9.0} + ], + surface_temp_c: 20.5, + surface_dewpoint_c: 15.0, + surface_pressure_mb: 1013.0 + }) + + assert %{} = cell = HrrrProfileLookup.read_point_near(~U[2026-04-25 14:00:00Z], 32.65, -97.13) + # Returned cell preserves the profile array shape SkewtSvg expects. + # Postgres jsonb collapses 1000.0 → 1000 on round-trip; accept + # either since the renderer/normalizer handles both. + [%{"pres" => pres} | _] = cell[:profile] + assert pres == 1000.0 or pres == 1000 + # Surface scalars are surfaced for SoundingParams.derive/1 reuse. + assert cell[:surface_temp_c] == 20.5 + assert cell[:surface_pressure_mb] == 1013.0 + end + + test "returns nil if no profile is within tolerance at that valid_time" do + assert HrrrProfileLookup.read_point_near(~U[2026-04-25 14:00:00Z], 32.65, -97.13) == nil + end + + test "picks the spatially closest cell when multiple match" do + insert_profile!(%{ + lat: 32.65, + lon: -97.13, + valid_time: ~U[2026-04-25 14:00:00Z], + profile: [%{"pres" => 1000.0, "hght" => 100.0, "tmpc" => 25.0, "dwpc" => 18.0}] + }) + + insert_profile!(%{ + lat: 32.69, + lon: -97.18, + valid_time: ~U[2026-04-25 14:00:00Z], + profile: [%{"pres" => 1000.0, "hght" => 100.0, "tmpc" => 30.0, "dwpc" => 25.0}] + }) + + cell = HrrrProfileLookup.read_point_near(~U[2026-04-25 14:00:00Z], 32.65, -97.13) + # Closest cell is the first one (exact match). + assert [%{"tmpc" => 25.0}] = cell[:profile] + end + end + + defp insert_profile!(attrs) do + defaults = %{ + profile: [%{"pres" => 1000.0, "hght" => 100.0, "tmpc" => 25.0, "dwpc" => 18.0}], + run_time: nil, + hpbl_m: 800.0, + pwat_mm: 25.0, + surface_temp_c: 25.0, + surface_dewpoint_c: 18.0, + surface_pressure_mb: 1013.0, + surface_refractivity: 320.0, + min_refractivity_gradient: -90.0, + ducting_detected: false, + duct_characteristics: [], + is_grid_point: false + } + + %HrrrProfile{} + |> HrrrProfile.changeset(Map.merge(defaults, attrs)) + |> Microwaveprop.Repo.insert!() + end +end