prop/lib/microwaveprop_web/live/skewt_live.ex
Graham McIntire 3a81d05b4e
perf(skewt): load profile + sounding asynchronously via start_async
URL-parameter loads (`/skewt?q=…`) were blocking the initial mount
on three serial steps: geocoder/grid resolve, ProfilesFile or
HrrrProfileLookup read, and the SkewtParams/SoundingParams/SkewtSvg
derivation. The address path in particular hits an external API,
which made the page take "forever" to load on first paint.

Restructure SkewtLive so the page chrome (header, search bar) renders
synchronously on mount, and the heavy work runs in a single
`start_async(:load_skewt, …)` task that calls SkewtLocationResolver,
walks the on-disk profile store with a DB fallback, and produces the
SVG. A new `loading?` assign drives a small spinner + "Resolving
location and loading HRRR profile…" message that shows while the task
is in flight; previous data stays on screen so navigation between
forecast hours doesn't blank out the diagram.

handle_async/3 covers three landings:

  * `{:ok, {:ok, result}}` — apply assigns from the resolver
  * `{:ok, {:error, reason}}` — surface as the existing error alert
    (geocoder failure, unknown callsign, etc.)
  * `{:exit, reason}` — log a warning and show a generic retry message

start_async/3 cancels any prior task with the same name on each call,
so a rapid-fire URL patch (e.g. clicking through forecast-hour
buttons before the first response returns) never lands stale data.

Tests: extended SkewtLiveTest from 3 to 4 cases — the URL-param
initial render now asserts the chrome + spinner appear pre-async,
and a separate case `render_async/2`s the view to confirm the
location label arrives once the task completes. 4/4 green.
2026-04-25 14:36:30 -05:00

528 lines
17 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,
loading?: false
)}
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
@impl true
def handle_async(:load_skewt, {:ok, {:ok, result}}, socket) do
{:noreply,
assign(socket,
location: result.location,
error: nil,
valid_times: result.valid_times,
selected_valid_time: result.selected_valid_time,
profile: result.profile,
derived: result.derived,
svg: result.svg,
loading?: false
)}
end
def handle_async(:load_skewt, {:ok, {:error, reason}}, socket) do
{:noreply,
assign(socket,
location: nil,
error: reason,
valid_times: [],
selected_valid_time: nil,
profile: nil,
derived: nil,
svg: nil,
loading?: false
)}
end
def handle_async(:load_skewt, {:exit, reason}, socket) do
require Logger
Logger.warning("SkewtLive async exit: #{inspect(reason)}")
{:noreply,
assign(socket,
error: "Sorry, something went wrong loading this profile. Try again.",
loading?: false
)}
end
# ── Refresh logic ──────────────────────────────────────────────────
# The page chrome (header + search bar) renders synchronously on
# mount; everything else (geocoding, HRRR file/DB read, sounding
# parameter derivation, SVG render) lands via `:load_skewt` async.
# Repeated calls under the same key cancel any in-flight task, so
# rapid URL patches don't race.
defp refresh(socket, "", _time) do
assign(socket,
query: "",
location: nil,
error: nil,
valid_times: [],
selected_valid_time: nil,
profile: nil,
derived: nil,
svg: nil,
loading?: false
)
end
defp refresh(socket, query, requested_time) do
socket
|> assign(
query: query,
error: nil,
loading?: true
)
|> start_async(:load_skewt, fn -> resolve_and_load(query, requested_time) end)
end
defp resolve_and_load(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
{:ok,
%{
location: location,
valid_times: valid_times,
selected_valid_time: chosen,
profile: profile,
derived: derived,
svg: svg
}}
{:error, reason} ->
{:error, reason}
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 @loading? do %>
<div class="mt-4 flex items-center gap-3 text-sm opacity-80">
<span class="loading loading-spinner loading-sm"></span>
<span>Resolving location and loading HRRR profile…</span>
</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