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) end end defp build_profile_assigns([]), do: {nil, nil, nil} defp build_profile_assigns(profile) 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 = Map.merge(sounding || %{}, spc) svg = SkewtSvg.render(profile, parcel_trace: spc[:parcel_trace] || [], critical_levels: critical_levels(spc, profile) ) {profile, derived, svg} 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""" <.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.
<%= if @error do %> <% end %> <%= if @loading? do %>
Resolving location and loading HRRR profile…
<% end %> <%= if @location do %>
{@location.label} {fmt_coord(@location.lat)}, {fmt_coord(@location.lon)}
<% end %> <%= if @valid_times != [] do %>
<%= for t <- @valid_times do %> <% end %>
<% end %> <%= if @svg do %>
{Phoenix.HTML.raw(@svg)}

Sounding parameters

<%= if @derived do %> <%= for {section_title, rows} <- derived_sections(@derived) do %>

{section_title}

<%= for {label, value} <- rows do %>
{label}
{value}
<% end %>
<% end %> <%= if @derived[:ducting_detected] && @derived[:ducts] != [] do %>

Ducts

<%= for duct <- @derived.ducts do %>
base {round(duct["base"])} m → top {round(duct["top"])} m, ΔM {duct["strength"]}
<% end %>
<% end %>

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.

<% end %>
<% else %> <%= if @location && @selected_valid_time do %> <% else %> <%= if @location && @valid_times == [] do %> <% end %> <% end %> <% end %>
""" 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", [ {"Sfc–3 km", fmt_num(d[:lapse_rate_sfc_3km_c_per_km], "°C/km")}, {"700–500 mb", fmt_num(d[:lapse_rate_700_500_c_per_km], "°C/km")}, {"850–500 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