Single search bar accepts an address, Maidenhead grid square, or callsign; resolves it to lat/lon via Geocoder / Maidenhead / CallsignLocation (cheapest classification first), snaps to the HRRR grid via the existing ProfilesFile reader, and renders an SVG Skew-T-Log-P with isobars, skewed isotherms, dry/moist adiabats, and mixing-ratio lines. A button row picks between every available forecast hour (now → f18); default is the most recent analysis. Right pane lists derived stability and refractivity stats from SoundingParams.derive (PWAT, BL depth, K-index, lifted index, min dN/dh, ducting status + per-duct base/top/ΔM). Renderer is server-side SVG so the page works without JS and serializes into the LiveView payload as a single tag. Wind barbs are deliberately omitted — HRRR's persisted profile carries wind only at 10 m AGL.
330 lines
10 KiB
Elixir
330 lines
10 KiB
Elixir
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.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()
|
|
|
|
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
|
|
case ProfilesFile.read_point(valid_time, lat, lon) 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 do
|
|
now = DateTime.utc_now()
|
|
past_cutoff = DateTime.add(now, -3600, :second)
|
|
future_cutoff = DateTime.add(now, 18 * 3600, :second)
|
|
|
|
ProfilesFile.list_valid_times()
|
|
|> Enum.filter(fn t ->
|
|
DateTime.compare(t, past_cutoff) != :lt and DateTime.compare(t, future_cutoff) != :gt
|
|
end)
|
|
|> Enum.sort(DateTime)
|
|
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 @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 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-3">
|
|
<h3 class="font-semibold text-base">Derived parameters</h3>
|
|
<%= if @derived do %>
|
|
<dl class="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
|
<%= for {label, value} <- derived_rows(@derived) do %>
|
|
<dt class="opacity-70">{label}</dt>
|
|
<dd class="font-mono text-right">{value}</dd>
|
|
<% end %>
|
|
</dl>
|
|
|
|
<%= if @derived[:ducting_detected] && @derived[:ducts] != [] do %>
|
|
<div class="mt-3 pt-3 border-t border-base-300 space-y-1">
|
|
<h4 class="font-semibold">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 %>
|
|
<% 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
|
|
|
|
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
|