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.
357 lines
12 KiB
Elixir
357 lines
12 KiB
Elixir
defmodule MicrowavepropWeb.SkewtLive do
|
|
@moduledoc """
|
|
`/skewt` interactive Skew-T-Log-P diagram.
|
|
|
|
The user types an address, Maidenhead grid square, or callsign into
|
|
the search bar; the page resolves it to lat/lon, snaps to the HRRR
|
|
grid, and renders the latest analysis profile as a Skew-T plus the
|
|
derived stability / refractivity stats. A time selector switches
|
|
between the analysis hour and each available forecast hour out to
|
|
HRRR's f18 horizon.
|
|
"""
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Propagation.ProfilesFile
|
|
alias Microwaveprop.Weather.HrrrProfileLookup
|
|
alias Microwaveprop.Weather.SoundingParams
|
|
alias MicrowavepropWeb.SkewtLocationResolver
|
|
alias MicrowavepropWeb.SkewtSvg
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Skew-T",
|
|
query: "",
|
|
location: nil,
|
|
error: nil,
|
|
valid_times: [],
|
|
selected_valid_time: nil,
|
|
profile: nil,
|
|
derived: nil,
|
|
svg: nil
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _uri, socket) do
|
|
query = String.trim(params["q"] || "")
|
|
requested_time = parse_time(params["valid_time"])
|
|
|
|
{:noreply, refresh(socket, query, requested_time)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("search", %{"q" => q}, socket) do
|
|
{:noreply, push_patch(socket, to: ~p"/skewt?#{[q: q]}")}
|
|
end
|
|
|
|
def handle_event("select_time", %{"valid_time" => iso}, socket) do
|
|
params = [q: socket.assigns.query, valid_time: iso]
|
|
{:noreply, push_patch(socket, to: ~p"/skewt?#{params}")}
|
|
end
|
|
|
|
# ── Refresh logic ──────────────────────────────────────────────────
|
|
|
|
defp refresh(socket, "", _time) do
|
|
assign(socket,
|
|
query: "",
|
|
location: nil,
|
|
error: nil,
|
|
valid_times: [],
|
|
selected_valid_time: nil,
|
|
profile: nil,
|
|
derived: nil,
|
|
svg: nil
|
|
)
|
|
end
|
|
|
|
defp refresh(socket, query, requested_time) do
|
|
case SkewtLocationResolver.resolve(query) do
|
|
{:ok, %{lat: lat, lon: lon} = location} ->
|
|
valid_times = available_valid_times(lat, lon)
|
|
|
|
chosen = pick_valid_time(valid_times, requested_time)
|
|
|
|
{profile, derived, svg} =
|
|
case chosen do
|
|
nil ->
|
|
{nil, nil, nil}
|
|
|
|
time ->
|
|
load_profile(time, lat, lon)
|
|
end
|
|
|
|
assign(socket,
|
|
query: query,
|
|
location: location,
|
|
error: nil,
|
|
valid_times: valid_times,
|
|
selected_valid_time: chosen,
|
|
profile: profile,
|
|
derived: derived,
|
|
svg: svg
|
|
)
|
|
|
|
{:error, reason} ->
|
|
assign(socket,
|
|
query: query,
|
|
location: nil,
|
|
error: reason,
|
|
valid_times: [],
|
|
selected_valid_time: nil,
|
|
profile: nil,
|
|
derived: nil,
|
|
svg: nil
|
|
)
|
|
end
|
|
end
|
|
|
|
defp load_profile(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}
|
|
|
|
%{} = cell ->
|
|
profile = extract_profile(cell)
|
|
|
|
if profile == [] do
|
|
{nil, nil, nil}
|
|
else
|
|
derived = SoundingParams.derive(profile)
|
|
svg = SkewtSvg.render(profile)
|
|
{profile, derived, svg}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp extract_profile(cell) do
|
|
cell[:profile] || cell["profile"] || []
|
|
end
|
|
|
|
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
|
|
# ~2 h old. A 1-hour past cutoff would leave the page empty while
|
|
# the chain catches up — keep 3 h so a stale analysis still shows.
|
|
now = DateTime.utc_now()
|
|
past_cutoff = DateTime.add(now, -3 * 3600, :second)
|
|
future_cutoff = DateTime.add(now, 18 * 3600, :second)
|
|
|
|
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
|
|
|
|
defp pick_valid_time(times, nil) do
|
|
# Default = the most recent valid_time at or before "now". If
|
|
# everything is in the future (cold start), fall back to the
|
|
# earliest available.
|
|
now = DateTime.utc_now()
|
|
|
|
times
|
|
|> Enum.filter(fn t -> DateTime.compare(t, now) != :gt end)
|
|
|> case do
|
|
[] -> List.first(times)
|
|
past -> Enum.max(past, DateTime)
|
|
end
|
|
end
|
|
|
|
defp pick_valid_time(times, %DateTime{} = requested) do
|
|
case Enum.find(times, &(DateTime.compare(&1, requested) == :eq)) do
|
|
nil -> pick_valid_time(times, nil)
|
|
match -> match
|
|
end
|
|
end
|
|
|
|
defp parse_time(nil), do: nil
|
|
defp parse_time(""), do: nil
|
|
|
|
defp parse_time(iso) when is_binary(iso) do
|
|
case DateTime.from_iso8601(iso) do
|
|
{:ok, dt, _} -> DateTime.truncate(dt, :second)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
# ── Render ─────────────────────────────────────────────────────────
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
|
<.header>
|
|
Skew-T-Log-P
|
|
<:subtitle>
|
|
Vertical atmospheric profile from the latest HRRR analysis (or any
|
|
available forecast hour) for an address, grid square, or callsign.
|
|
</:subtitle>
|
|
</.header>
|
|
|
|
<form phx-submit="search" class="mt-4 flex gap-2">
|
|
<input
|
|
type="text"
|
|
name="q"
|
|
value={@query}
|
|
placeholder="EM12kp, W5ISP, or '123 Main St, Plano TX'"
|
|
class="input input-bordered flex-1"
|
|
autocomplete="off"
|
|
autofocus
|
|
/>
|
|
<button type="submit" class="btn btn-primary">Plot</button>
|
|
</form>
|
|
|
|
<%= if @error do %>
|
|
<div role="alert" class="alert alert-error mt-4">
|
|
<p>{@error}</p>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if @location do %>
|
|
<div class="mt-4 flex flex-wrap items-baseline gap-4 text-sm">
|
|
<span class="font-semibold">{@location.label}</span>
|
|
<span class="opacity-70">
|
|
{fmt_coord(@location.lat)}, {fmt_coord(@location.lon)}
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if @valid_times != [] do %>
|
|
<div class="mt-4 flex flex-wrap gap-2">
|
|
<%= for t <- @valid_times do %>
|
|
<button
|
|
type="button"
|
|
phx-click="select_time"
|
|
phx-value-valid_time={DateTime.to_iso8601(t)}
|
|
class={[
|
|
"btn btn-xs",
|
|
if(@selected_valid_time && DateTime.compare(t, @selected_valid_time) == :eq,
|
|
do: "btn-primary",
|
|
else: "btn-outline"
|
|
)
|
|
]}
|
|
>
|
|
{time_button_label(t, @valid_times)}
|
|
</button>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if @svg do %>
|
|
<div class="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<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">
|
|
<h3 class="font-semibold text-base">Derived parameters</h3>
|
|
<%= if @derived do %>
|
|
<dl class="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
|
<%= for {label, value} <- derived_rows(@derived) do %>
|
|
<dt class="opacity-70">{label}</dt>
|
|
<dd class="font-mono text-right">{value}</dd>
|
|
<% end %>
|
|
</dl>
|
|
|
|
<%= if @derived[:ducting_detected] && @derived[:ducts] != [] do %>
|
|
<div class="mt-3 pt-3 border-t border-base-300 space-y-1">
|
|
<h4 class="font-semibold">Ducts</h4>
|
|
<%= for duct <- @derived.ducts do %>
|
|
<div class="font-mono text-xs">
|
|
base {round(duct["base"])} m → top {round(duct["top"])} m, ΔM {duct["strength"]}
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<%= if @location && @selected_valid_time do %>
|
|
<div role="alert" class="alert alert-warning mt-6">
|
|
<p>
|
|
No HRRR profile is stored for this point at {fmt_time(@selected_valid_time)}.
|
|
Profiles are written by the hourly chain and only kept for the active
|
|
forecast window.
|
|
</p>
|
|
</div>
|
|
<% else %>
|
|
<%= if @location && @valid_times == [] do %>
|
|
<div role="alert" class="alert alert-info mt-6">
|
|
<p>
|
|
No HRRR profiles are currently stored. Wait for the next hourly
|
|
chain to publish, or check the propagation pipeline status.
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
defp fmt_coord(c) when is_number(c), do: :erlang.float_to_binary(c * 1.0, decimals: 3)
|
|
defp fmt_coord(_), do: "—"
|
|
|
|
defp fmt_time(%DateTime{} = t) do
|
|
Calendar.strftime(t, "%Y-%m-%d %H:%MZ")
|
|
end
|
|
|
|
defp time_button_label(%DateTime{} = t, all_times) do
|
|
base = Enum.min(all_times, DateTime)
|
|
delta_h = div(DateTime.diff(t, base, :second), 3600)
|
|
IO.iodata_to_binary("f#{:io_lib.format("~2..0B", [delta_h])} · #{Calendar.strftime(t, "%H:%MZ")}")
|
|
end
|
|
|
|
defp derived_rows(d) do
|
|
[
|
|
{"Surface T", fmt_temp(d[:surface_temp_c])},
|
|
{"Surface Td", fmt_temp(d[:surface_dewpoint_c])},
|
|
{"Surface P", fmt_num(d[:surface_pressure_mb], "mb")},
|
|
{"Surface N", fmt_num(d[:surface_refractivity], "")},
|
|
{"Min dN/dh", fmt_num(d[:min_refractivity_gradient], "/km")},
|
|
{"PWAT", fmt_num(d[:precipitable_water_mm], "mm")},
|
|
{"BL depth", fmt_num(d[:boundary_layer_depth_m], "m")},
|
|
{"K-index", fmt_num(d[:k_index], "")},
|
|
{"Lifted index", fmt_num(d[:lifted_index], "")},
|
|
{"Ducting", if(d[:ducting_detected], do: "yes", else: "no")},
|
|
{"Levels", to_string(d[:level_count] || 0)}
|
|
]
|
|
end
|
|
|
|
defp fmt_temp(nil), do: "—"
|
|
defp fmt_temp(v) when is_number(v), do: "#{Float.round(v * 1.0, 1)} °C"
|
|
|
|
defp fmt_num(nil, _suffix), do: "—"
|
|
defp fmt_num(v, ""), do: format_value(v)
|
|
defp fmt_num(v, suffix), do: "#{format_value(v)} #{suffix}"
|
|
|
|
defp format_value(v) when is_float(v), do: v |> Float.round(1) |> :erlang.float_to_binary(decimals: 1)
|
|
defp format_value(v) when is_integer(v), do: Integer.to_string(v)
|
|
defp format_value(v), do: to_string(v)
|
|
end
|