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.
This commit is contained in:
Graham McIntire 2026-04-25 13:17:56 -05:00
parent 7ca9381ba6
commit a4cd7632eb
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 496 additions and 10 deletions

View file

@ -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<SkewtHook>
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

View file

@ -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

View file

@ -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 %>
<div class="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 bg-base-100 rounded-box p-3 border border-base-300">
<div
id="skewt-container"
phx-hook="Skewt"
phx-update="ignore"
data-profile={Jason.encode!(@profile)}
data-geometry={Jason.encode!(SkewtSvg.geometry())}
class="lg:col-span-2 bg-base-100 rounded-box p-3 border border-base-300"
>
{Phoenix.HTML.raw(@svg)}
</div>
<div class="bg-base-200 rounded-box p-4 text-sm space-y-3">

View file

@ -48,10 +48,31 @@ defmodule MicrowavepropWeb.SkewtSvg do
frame(),
axis_labels(),
profile_traces(cleaned),
cursor_layer(),
"</svg>"
]
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
"""
<g class="skewt-cursor" data-cursor style="display:none">
<line class="skewt-cursor-line" x1="#{@left}" x2="#{@right}" y1="0" y2="0"/>
<circle class="skewt-cursor-dot-t" data-cursor-dot-t cx="0" cy="0" r="3"/>
<circle class="skewt-cursor-dot-d" data-cursor-dot-d cx="0" cy="0" r="3"/>
<rect class="skewt-cursor-readout-bg" data-cursor-readout-bg x="0" y="0" width="148" height="62" rx="3"/>
<text class="skewt-cursor-readout" data-cursor-readout x="0" y="0">
<tspan data-cursor-line-1 x="0" dy="14"></tspan>
<tspan data-cursor-line-2 x="0" dy="14"></tspan>
<tspan data-cursor-line-3 x="0" dy="14"></tspan>
<tspan data-cursor-line-4 x="0" dy="14"></tspan>
</text>
</g>
"""
end
defp style_block do
"""
<defs>
@ -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; }
</style>
</defs>
"""
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

View file

@ -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