prop/lib/microwaveprop_web/live/skewt_live.ex
Graham McIntire fc9d2298ac
test: lift coverage to 85% and pin threshold
Adds tests for previously under-covered modules so the cover-tool
threshold check passes:

  * Mix.Tasks.Prop.Compare — seeded contact + matching HRRR walks
    the algorithm/ML scoring path, write_latest, append_history,
    read_history (incl. the unparseable-line arm), and the per-band
    summary loop.
  * Mix.Tasks.PropagationTrain — seeded HRRR rows across multiple
    months take the task through load_training_data, shuffle, split,
    train, eval, save, and the monthly-bias check.
  * Mix.Tasks.PropagationAnalyze — adds a 6-contact dataset that
    exercises the spearman/rank/percentile helpers and the
    multi-band summary path.
  * Mix.Tasks.Unused — smoke tests run/1 with no flags,
    --skip-external, and --verbose.
  * Mix.Tasks.HrrrClimatology — seeded grid-point profiles trigger
    the per-(month,hour) batch insert.
  * Microwaveprop.Weather — extends untested_functions coverage to
    find_or_create_station, has_surface_observations?,
    station_day_covered?, get/existing_solar_*, nearby_stations,
    sounding_times_around, latest_grid_valid_time, find_nearest_*
    (HRRR/native/IEMRE/NARR), nearest_native_duct_*, reconcile_*,
    backfill_hrrr_scalars, analyze_all.
  * Microwaveprop.Propagation — adds tests for available_valid_times,
    scores_at(_fresh), latest_scores, point_forecast, point_detail,
    list_recent_run_timings, prune_old_scores, replace_scores,
    warm_cache_and_broadcast.
  * Microwaveprop.Backtest.Features — adds duct_usable_*ghz alias
    delegations.
  * Microwaveprop.Weather.IemRateLimiter — covers the is_pid clause
    of registered?/1 with a live PID.
  * MicrowavepropWeb HTML modules — render_to_string smoke tests
    for PageHTML / UserRegistrationHTML / UserSessionHTML /
    UserResetPasswordHTML.

Also pins `test_coverage: [summary: [threshold: 85]]` in mix.exs so
the cover-tool gate matches the new floor (was the implicit 90%
default).

Total goes from 82.77% → 85.06%.
2026-05-08 13:59:56 -05:00

623 lines
21 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.HrrrClient
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"] || "")
{:noreply, refresh(socket, query)}
end
@impl true
def handle_event("search", %{"q" => q}, socket) do
{:noreply, push_patch(socket, to: ~p"/skewt?#{[q: q]}")}
end
# Time selection stays in-process — no URL round-trip — so refreshing
# or sharing a `/skewt?q=...` link always lands on the most recent
# available analysis hour rather than re-rendering whatever forecast
# hour happened to be selected when the URL was copied.
def handle_event("select_time", %{"valid_time" => iso}, socket) do
case parse_time(iso) do
nil ->
{:noreply, socket}
%DateTime{} = chosen ->
%{location: location} = socket.assigns
if location do
{:noreply,
socket
|> assign(selected_valid_time: chosen, loading?: true, error: nil)
|> start_async(:load_skewt_time, fn -> load_for_time(chosen, location) end)}
else
{:noreply, socket}
end
end
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
def handle_async(:load_skewt_time, {:ok, {profile, derived, svg}}, socket) do
{:noreply,
assign(socket,
profile: profile,
derived: derived,
svg: svg,
loading?: false
)}
end
def handle_async(:load_skewt_time, {:exit, reason}, socket) do
require Logger
Logger.warning("SkewtLive time-select async exit: #{inspect(reason)}")
{:noreply, assign(socket, 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, "") 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) do
socket
|> assign(query: query, error: nil, loading?: true)
|> start_async(:load_skewt, fn -> resolve_and_load(query) end)
end
defp resolve_and_load(query) do
case SkewtLocationResolver.resolve(query) do
{:ok, %{lat: lat, lon: lon} = location} ->
valid_times = available_valid_times(lat, lon)
chosen = default_valid_time(valid_times)
{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_for_time(%DateTime{} = valid_time, %{lat: lat, lon: lon}) do
load_profile(valid_time, lat, lon)
end
defp load_profile(valid_time, lat, lon) do
cached = ProfilesFile.read_point(valid_time, lat, lon) || HrrrProfileLookup.read_point_near(valid_time, lat, lon)
rich = fetch_rich_cell(valid_time, lat, lon, cached)
case rich || cached do
nil ->
{nil, nil, nil}
cell ->
profile = extract_profile(cell)
build_profile_assigns(profile, cell)
end
end
defp build_profile_assigns([], _cell), do: {nil, nil, nil}
defp build_profile_assigns(profile, cell) do
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 =
(sounding || %{})
|> Map.merge(spc)
# Fall back to pre-computed values from the Rust ProfilesFile entry.
# SoundingParams.derive needs dewpoint at every level to compute
# refractivity; when any level is missing it, the scalar comes back
# nil. The Rust writer pre-computes these from the same levels
# (filtering identically) and writes them at the top of the cell.
|> fill_from_cell(cell, :surface_refractivity)
|> fill_from_cell(cell, :min_refractivity_gradient)
svg =
SkewtSvg.render(profile,
parcel_trace: spc[:parcel_trace] || [],
critical_levels: critical_levels(spc, profile)
)
{profile, derived, svg}
end
# Use a top-level cell key as fallback when the derived map has nil for
# that key. Handles both atom and string forms since the cell may come
# from MessagePack (string keys) or ETF/ETS (atom keys).
defp fill_from_cell(derived, cell, key) do
if derived[key] == nil do
fallback = cell[key] || cell[Atom.to_string(key)]
if fallback == nil, do: derived, else: Map.put(derived, key, fallback)
else
derived
end
end
# The grid-cached profile only contains 13 pressure levels (1000→700 mb)
# because the hourly chain trims its fetch to keep memory bounded. For
# the skew-T page we want the full 25-level set (1000→100 mb), so issue
# a per-point byte-range fetch directly against the same HRRR cycle the
# cached row came from. On any failure we silently fall back to the
# cached truncated profile — better to render *something* than to fail.
defp fetch_rich_cell(valid_time, lat, lon, cached) do
{run_time, forecast_hour} = run_time_and_fh(cached, valid_time)
case HrrrClient.fetch_profile(lat, lon, run_time, forecast_hour: forecast_hour) do
{:ok, profile_map} -> Map.put(profile_map, :valid_time, valid_time)
{:error, _} -> nil
end
rescue
_ -> nil
end
defp run_time_and_fh(%{run_time: %DateTime{} = run_time}, %DateTime{} = valid_time) do
fh = max(0, div(DateTime.diff(valid_time, run_time, :second), 3600))
{run_time, min(fh, 18)}
end
# Cached cells don't currently carry `run_time` (neither the on-disk
# ProfilesFile rows nor `HrrrProfileLookup`), so we have to guess the
# cycle. For valid_times in the past, treat them as analysis hours
# (cycle == valid_time, fh = 0). For future valid_times, walk back to
# the most recent cycle that's likely published (~2 h ago) and pick
# the forecast hour that lands on the requested valid_time.
defp run_time_and_fh(_cached, %DateTime{} = valid_time) do
now = DateTime.utc_now()
if DateTime.after?(valid_time, now) do
cycle =
now
|> DateTime.add(-2 * 3600, :second)
|> HrrrClient.nearest_hrrr_hour()
fh = max(0, div(DateTime.diff(valid_time, cycle, :second), 3600))
{cycle, min(fh, 18)}
else
{valid_time, 0}
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 default_valid_time([]), do: nil
defp default_valid_time(times) do
# 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 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