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.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 derived = SoundingParams.derive(profile) svg = SkewtSvg.render(profile) {profile, derived, svg} end end end defp extract_profile(cell) do cell[:profile] || cell["profile"] || [] 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)}

Derived parameters

<%= if @derived do %>
<%= for {label, value} <- derived_rows(@derived) do %>
{label}
{value}
<% 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 %> <% 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 defp derived_rows(d) do [ {"Surface T", fmt_temp(d[:surface_temp_c])}, {"Surface Td", fmt_temp(d[:surface_dewpoint_c])}, {"Surface P", fmt_num(d[:surface_pressure_mb], "mb")}, {"Surface N", fmt_num(d[:surface_refractivity], "")}, {"Min dN/dh", fmt_num(d[:min_refractivity_gradient], "/km")}, {"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], "")}, {"Lifted index", fmt_num(d[:lifted_index], "")}, {"Ducting", if(d[:ducting_detected], do: "yes", else: "no")}, {"Levels", to_string(d[:level_count] || 0)} ] 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 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