prop/lib/microwaveprop_web/live/skewt_live.ex
Graham McIntire e40ade3b19
feat(skewt): add /skewt LiveView with HRRR-backed Skew-T-Log-P plot
Single search bar accepts an address, Maidenhead grid square, or
callsign; resolves it to lat/lon via Geocoder / Maidenhead /
CallsignLocation (cheapest classification first), snaps to the HRRR
grid via the existing ProfilesFile reader, and renders an SVG
Skew-T-Log-P with isobars, skewed isotherms, dry/moist adiabats, and
mixing-ratio lines. A button row picks between every available
forecast hour (now → f18); default is the most recent analysis.
Right pane lists derived stability and refractivity stats from
SoundingParams.derive (PWAT, BL depth, K-index, lifted index, min
dN/dh, ducting status + per-duct base/top/ΔM).

Renderer is server-side SVG so the page works without JS and serializes
into the LiveView payload as a single tag. Wind barbs are deliberately
omitted — HRRR's persisted profile carries wind only at 10 m AGL.
2026-04-25 12:13:46 -05:00

330 lines
10 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.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()
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
case ProfilesFile.read_point(valid_time, lat, lon) 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 do
now = DateTime.utc_now()
past_cutoff = DateTime.add(now, -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)
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 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