prop/lib/microwaveprop_web/live/skewt_live.ex
Graham McIntire 77bc894f56
feat(skewt): add wet-bulb / virtual-T traces and right-edge level rail
Three additions that bring the diagram closer to the SPC sounding
viewer's full-information chart:

  * Wet-bulb temperature trace, plotted as a thinner blue line under
    the dewpoint trace. Comes from a Stipanuk-style bracket between
    the level's dewpoint and dry-bulb temperatures (already used by
    `SkewtParams` for the wet-bulb-zero level — now public so the
    renderer can call it per level).

  * Environment virtual-temperature trace and parcel virtual-T
    trajectory, both as thin pinkish dashed lines. The parcel
    virtual-T is the actual buoyancy curve CAPE integrates against,
    so showing it next to the dry-bulb parcel ascent makes the
    moisture correction visible. New `SkewtParams.virtual_temp_c/2`
    and `/3` helpers, plus `SkewtParams.mixing_ratio/2`.

  * Critical-level rail on the right edge. Each of LCL / LFC / EL /
    0 °C / WBZ that resolves on the current profile gets a short
    horizontal tick at the right of the plot and a `<label> <p> mb`
    tag just outside it — matches the SPC chart's `143 mb (mix)` /
    `158 mb (mix)` annotation column. SkewtLive computes the
    pressures (and converts the height-keyed 0 °C and WBZ from
    SkewtParams back to pressure via `pressure_at_height/2`) before
    handing them to `SkewtSvg.render/2`.

Wind-derived overlays (hodograph, wind barbs) remain off — HRRR
profile data is wind-free above 10 m AGL.

mix test: 27 / 27 across the four /skewt-related test files,
2,902 / 2,902 + 221 properties on the full suite. mix credo --strict
clean (one cyclomatic-complexity refactor extracted along the way).
2026-04-25 14:24:28 -05:00

476 lines
16 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.SkewtParams
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
sounding = SoundingParams.derive(profile)
spc = SkewtParams.derive(profile)
# Merge so the LiveView template only has to read one map.
# SoundingParams keys (atoms) and SkewtParams keys (atoms)
# are disjoint by construction.
derived = Map.merge(sounding || %{}, spc)
svg =
SkewtSvg.render(profile,
parcel_trace: spc[:parcel_trace] || [],
critical_levels: critical_levels(spc, profile)
)
{profile, derived, svg}
end
end
end
defp extract_profile(cell) do
cell[:profile] || cell["profile"] || []
end
# Right-edge critical-level markers, mirroring SPC's annotation rail.
# Pressure → label is enough; SkewtSvg uses pressure_y/1 to position.
# 0 °C and wet-bulb-zero come back from SkewtParams as heights, so
# we walk the profile to recover the corresponding pressure.
defp critical_levels(spc, profile) do
Enum.flat_map(
[
{"LCL", spc[:lcl_pressure_mb]},
{"LFC", spc[:lfc_pressure_mb]},
{"EL", spc[:el_pressure_mb]},
{"0 °C", pressure_at_height(profile, spc[:freezing_level_m])},
{"WBZ", pressure_at_height(profile, spc[:wbz_m])}
],
fn
{label, p} when is_number(p) -> [%{label: label, pressure_mb: p}]
_ -> []
end
)
end
defp pressure_at_height(_profile, nil), do: nil
defp pressure_at_height(profile, target_m) when is_number(target_m) do
profile
|> Enum.map(&extract_pres_hght/1)
|> Enum.filter(&(is_number(&1.pres) and is_number(&1.hght)))
|> Enum.sort_by(& &1.pres, :desc)
|> Enum.chunk_every(2, 1, :discard)
|> Enum.find_value(&interpolate_pressure_at_height(&1, target_m))
end
defp extract_pres_hght(lvl) do
%{
pres: lvl[:pres] || lvl["pres"] || lvl[:pres_mb] || lvl["pres_mb"],
hght: lvl[:hght] || lvl["hght"] || lvl[:hght_m] || lvl["hght_m"]
}
end
defp interpolate_pressure_at_height([a, b], target_m) do
cond do
a.hght == b.hght -> nil
(a.hght - target_m) * (b.hght - target_m) > 0 -> nil
true -> a.pres + (target_m - a.hght) / (b.hght - a.hght) * (b.pres - a.pres)
end
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-4">
<h3 class="font-semibold text-base">Sounding parameters</h3>
<%= if @derived do %>
<%= for {section_title, rows} <- derived_sections(@derived) do %>
<div class="space-y-1">
<h4 class="font-semibold text-xs uppercase tracking-wide opacity-70">
{section_title}
</h4>
<dl class="grid grid-cols-2 gap-x-3 gap-y-1">
<%= for {label, value} <- rows do %>
<dt class="opacity-70">{label}</dt>
<dd class="font-mono text-right">{value}</dd>
<% end %>
</dl>
</div>
<% end %>
<%= if @derived[:ducting_detected] && @derived[:ducts] != [] do %>
<div class="pt-2 border-t border-base-300 space-y-1">
<h4 class="font-semibold text-xs uppercase tracking-wide opacity-70">
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 %>
<p class="pt-2 border-t border-base-300 text-xs opacity-60 leading-relaxed">
Wind-derived indices (SRH, BWD, Bunkers motion, SCP/STP/SHIP)
aren't shown — HRRR persists wind only at 10 m AGL, so per-
level shear can't be computed from this data source.
</p>
<% 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
# Grouped derived-parameter sections rendered on the right side of
# the page. Mirrors the SPC viewer's "parcel / level / lapse rate /
# moisture / refractivity" layout so users moving between the two
# tools don't have to context-switch.
defp derived_sections(d) do
[
{"Surface",
[
{"T", fmt_temp(d[:surface_temp_c])},
{"Td", fmt_temp(d[:surface_dewpoint_c])},
{"P", fmt_num(d[:surface_pressure_mb], "mb")}
]},
{"Convective parcel",
[
{"SBCAPE", fmt_num(d[:sbcape], "J/kg")},
{"SBCIN", fmt_num(d[:sbcin], "J/kg")},
{"3 km CAPE", fmt_num(d[:cape_3km], "J/kg")},
{"Lifted index", fmt_num(d[:lifted_index], "")},
{"DCAPE", fmt_num(d[:dcape], "J/kg")}
]},
{"Levels",
[
{"LCL", fmt_pressure_height(d[:lcl_pressure_mb], d[:lcl_height_m])},
{"LFC", fmt_pressure_height(d[:lfc_pressure_mb], d[:lfc_height_m])},
{"EL", fmt_pressure_height(d[:el_pressure_mb], d[:el_height_m])},
{"0 °C", fmt_height(d[:freezing_level_m])},
{"Wet-bulb 0", fmt_height(d[:wbz_m])}
]},
{"Lapse rates",
[
{"Sfc3 km", fmt_num(d[:lapse_rate_sfc_3km_c_per_km], "°C/km")},
{"700500 mb", fmt_num(d[:lapse_rate_700_500_c_per_km], "°C/km")},
{"850500 mb", fmt_num(d[:lapse_rate_850_500_c_per_km], "°C/km")}
]},
{"Moisture / stability",
[
{"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], "")}
]},
{"Refractivity",
[
{"Surface N", fmt_num(d[:surface_refractivity], "")},
{"Min dN/dh", fmt_num(d[:min_refractivity_gradient], "/km")},
{"Ducting", if(d[:ducting_detected], do: "yes", else: "no")}
]}
]
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 fmt_height(nil), do: ""
defp fmt_height(v) when is_number(v) do
"#{round(v)} m / #{round(v * 3.28084)} ft"
end
defp fmt_pressure_height(nil, _), do: ""
defp fmt_pressure_height(p, nil), do: "#{round(p)} mb"
defp fmt_pressure_height(p, h) when is_number(p) and is_number(h) do
"#{round(p)} mb · #{round(h)} m"
end
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