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:
parent
7ca9381ba6
commit
a4cd7632eb
5 changed files with 496 additions and 10 deletions
184
assets/js/app.ts
184
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<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
|
||||
|
|
|
|||
112
lib/microwaveprop/weather/hrrr_profile_lookup.ex
Normal file
112
lib/microwaveprop/weather/hrrr_profile_lookup.ex
Normal 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
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
119
test/microwaveprop/weather/hrrr_profile_lookup_test.exs
Normal file
119
test/microwaveprop/weather/hrrr_profile_lookup_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue