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""" <.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 @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