feat(skewt): add /skewt LiveView with HRRR-backed Skew-T-Log-P plot
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.
This commit is contained in:
parent
c1f04df169
commit
e40ade3b19
7 changed files with 961 additions and 0 deletions
330
lib/microwaveprop_web/live/skewt_live.ex
Normal file
330
lib/microwaveprop_web/live/skewt_live.ex
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
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
|
||||
91
lib/microwaveprop_web/live/skewt_location_resolver.ex
Normal file
91
lib/microwaveprop_web/live/skewt_location_resolver.ex
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
defmodule MicrowavepropWeb.SkewtLocationResolver do
|
||||
@moduledoc """
|
||||
Turn a single search-bar string ("EM12kp", "W5ISP", or
|
||||
"Plano, TX") into a `{lat, lon, label}` triple for the Skew-T
|
||||
page. Tries the cheapest classification first (grid → callsign →
|
||||
geocoder) so a typo on a grid square doesn't burn a Google API
|
||||
request before the user has a chance to correct it.
|
||||
"""
|
||||
|
||||
alias Microwaveprop.CallsignLocation
|
||||
alias Microwaveprop.Geocoder
|
||||
alias Microwaveprop.Radio.Maidenhead
|
||||
|
||||
@grid_regex ~r/^[A-Ra-r]{2}[0-9]{2}([A-Xa-x]{2}([0-9]{2}([A-Xa-x]{2})?)?)?$/
|
||||
@callsign_regex ~r/^[A-Z0-9]{1,3}[0-9][A-Z]{1,4}$/
|
||||
|
||||
@type classification :: :grid | :callsign | :address
|
||||
|
||||
@type result :: %{
|
||||
lat: float(),
|
||||
lon: float(),
|
||||
label: String.t(),
|
||||
source: classification()
|
||||
}
|
||||
|
||||
@spec classify(String.t()) :: classification()
|
||||
def classify(query) when is_binary(query) do
|
||||
cleaned = query |> String.trim() |> String.upcase()
|
||||
|
||||
cond do
|
||||
Regex.match?(@grid_regex, cleaned) -> :grid
|
||||
Regex.match?(@callsign_regex, cleaned) -> :callsign
|
||||
true -> :address
|
||||
end
|
||||
end
|
||||
|
||||
@spec resolve(String.t()) :: {:ok, result()} | {:error, String.t()}
|
||||
def resolve(query) when is_binary(query) do
|
||||
cleaned = String.trim(query)
|
||||
|
||||
case classify(cleaned) do
|
||||
:grid -> resolve_grid(cleaned)
|
||||
:callsign -> resolve_callsign(cleaned)
|
||||
:address -> resolve_address(cleaned)
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_grid(query) do
|
||||
case Maidenhead.to_latlon(query) do
|
||||
{:ok, {lat, lon}} ->
|
||||
{:ok, %{lat: lat, lon: lon, label: String.upcase(query), source: :grid}}
|
||||
|
||||
:error ->
|
||||
{:error, "could not parse '#{query}' as a Maidenhead grid square"}
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_callsign(query) do
|
||||
upper = String.upcase(query)
|
||||
|
||||
case CallsignLocation.lookup(upper) do
|
||||
{:ok, %{latitude: lat, longitude: lon, gridsquare: grid, name: name}} ->
|
||||
label =
|
||||
[upper, name, grid]
|
||||
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|
||||
|> Enum.uniq()
|
||||
|> Enum.join(" — ")
|
||||
|
||||
{:ok, %{lat: lat, lon: lon, label: label, source: :callsign}}
|
||||
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:error, "callsign lookup failed: #{reason}"}
|
||||
|
||||
{:error, _} ->
|
||||
{:error, "callsign lookup failed"}
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_address(query) do
|
||||
case Geocoder.geocode(query) do
|
||||
{:ok, %{lat: lat, lon: lon}} ->
|
||||
{:ok, %{lat: lat, lon: lon, label: query, source: :address}}
|
||||
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:error, "geocode failed: #{reason}"}
|
||||
|
||||
{:error, _} ->
|
||||
{:error, "geocode failed"}
|
||||
end
|
||||
end
|
||||
end
|
||||
365
lib/microwaveprop_web/live/skewt_svg.ex
Normal file
365
lib/microwaveprop_web/live/skewt_svg.ex
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
defmodule MicrowavepropWeb.SkewtSvg do
|
||||
@moduledoc """
|
||||
Render an HRRR vertical profile as a Skew-T-Log-P diagram in SVG.
|
||||
|
||||
HRRR's persisted profile stores `pres`, `hght`, `tmpc`, `dwpc` per
|
||||
pressure level — same canonical shape `Microwaveprop.Weather.SoundingParams`
|
||||
consumes. This renderer ignores anything else.
|
||||
|
||||
Wind barbs are deliberately omitted: HRRR persists wind only at 10 m
|
||||
AGL, not on the pressure-level profile, so a per-level barb column
|
||||
would be misleading.
|
||||
"""
|
||||
|
||||
# Plot canvas (SVG user units).
|
||||
@width 720
|
||||
@height 640
|
||||
@left 60
|
||||
@right 660
|
||||
@top 30
|
||||
@bottom 580
|
||||
|
||||
@p_bottom 1050.0
|
||||
@p_top 100.0
|
||||
@t_min -40.0
|
||||
@t_max 40.0
|
||||
|
||||
# Skew factor — pixels of horizontal offset added per pixel of
|
||||
# height. 1.0 puts isotherms at 45°.
|
||||
@skew 1.0
|
||||
|
||||
@doc """
|
||||
Render a Skew-T diagram for `profile` (a list of `%{"pres", "hght",
|
||||
"tmpc", "dwpc"}` maps) and return an inline SVG string suitable for
|
||||
`Phoenix.HTML.raw/1`.
|
||||
"""
|
||||
@spec render([map()]) :: iodata()
|
||||
def render(profile) when is_list(profile) do
|
||||
cleaned = clean_profile(profile)
|
||||
|
||||
[
|
||||
~s|<svg viewBox="0 0 #{@width} #{@height}" xmlns="http://www.w3.org/2000/svg" class="w-full h-auto" role="img" aria-label="Skew-T-Log-P diagram">|,
|
||||
style_block(),
|
||||
isobars(),
|
||||
isotherms(),
|
||||
dry_adiabats(),
|
||||
moist_adiabats(),
|
||||
mixing_ratio_lines(),
|
||||
frame(),
|
||||
axis_labels(),
|
||||
profile_traces(cleaned),
|
||||
"</svg>"
|
||||
]
|
||||
end
|
||||
|
||||
defp style_block do
|
||||
"""
|
||||
<defs>
|
||||
<style>
|
||||
.frame { fill: none; stroke: currentColor; stroke-width: 1.2; }
|
||||
.grid-major { fill: none; stroke: currentColor; stroke-opacity: 0.35; stroke-width: 0.6; }
|
||||
.grid-minor { fill: none; stroke: currentColor; stroke-opacity: 0.18; stroke-width: 0.4; }
|
||||
.grid-iso { fill: none; stroke: #b91c1c; stroke-opacity: 0.30; stroke-width: 0.5; }
|
||||
.grid-dry { fill: none; stroke: #b45309; stroke-opacity: 0.30; stroke-width: 0.4; stroke-dasharray: 4 3; }
|
||||
.grid-moist { fill: none; stroke: #047857; stroke-opacity: 0.30; stroke-width: 0.4; stroke-dasharray: 1 4; }
|
||||
.grid-mix { fill: none; stroke: #6d28d9; stroke-opacity: 0.30; stroke-width: 0.4; stroke-dasharray: 2 2; }
|
||||
.axis { font: 10px ui-sans-serif, system-ui, sans-serif; fill: currentColor; }
|
||||
.iso-label { font: 9px ui-sans-serif, system-ui, sans-serif; fill: #b91c1c; fill-opacity: 0.7; }
|
||||
.pres-label { font: 9px ui-sans-serif, system-ui, sans-serif; fill: currentColor; fill-opacity: 0.7; }
|
||||
.temp-line { fill: none; stroke: #dc2626; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.dew-line { fill: none; stroke: #16a34a; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
</style>
|
||||
</defs>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Coordinate transforms ───────────────────────────────────────────
|
||||
|
||||
@doc false
|
||||
def pressure_y(p) when is_number(p) do
|
||||
# High pressure → bottom of plot, low pressure → top.
|
||||
@bottom -
|
||||
(@bottom - @top) * (:math.log(@p_bottom) - :math.log(p)) /
|
||||
(:math.log(@p_bottom) - :math.log(@p_top))
|
||||
end
|
||||
|
||||
@doc false
|
||||
def temperature_x(t, y) when is_number(t) and is_number(y) do
|
||||
plot_w = @right - @left
|
||||
base_x = @left + (t - @t_min) / (@t_max - @t_min) * plot_w
|
||||
base_x + @skew * (@bottom - y)
|
||||
end
|
||||
|
||||
defp clamp_x(x), do: x |> max(@left) |> min(@right)
|
||||
|
||||
defp clip_segment({x1, y1}, {x2, y2}) do
|
||||
cond do
|
||||
x1 < @left and x2 < @left -> nil
|
||||
x1 > @right and x2 > @right -> nil
|
||||
true -> {{clamp_x(x1), y1}, {clamp_x(x2), y2}}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Frame & axes ────────────────────────────────────────────────────
|
||||
|
||||
defp frame do
|
||||
~s|<rect class="frame" x="#{@left}" y="#{@top}" width="#{@right - @left}" height="#{@bottom - @top}" />|
|
||||
end
|
||||
|
||||
defp axis_labels do
|
||||
pressures = [1000, 850, 700, 500, 400, 300, 200, 100]
|
||||
|
||||
pres_labels =
|
||||
for p <- pressures do
|
||||
y = pressure_y(p)
|
||||
~s|<text class="pres-label" x="#{@left - 6}" y="#{y + 3}" text-anchor="end">#{p}</text>|
|
||||
end
|
||||
|
||||
temps = -40..40//10
|
||||
|
||||
temp_labels =
|
||||
for t <- temps do
|
||||
x = temperature_x(t, @bottom)
|
||||
|
||||
if x >= @left and x <= @right do
|
||||
~s|<text class="axis" x="#{x}" y="#{@bottom + 14}" text-anchor="middle">#{t}</text>|
|
||||
else
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
[
|
||||
pres_labels,
|
||||
temp_labels,
|
||||
~s|<text class="axis" x="#{@left - 38}" y="#{(@top + @bottom) / 2}" text-anchor="middle" transform="rotate(-90 #{@left - 38} #{(@top + @bottom) / 2})">Pressure (mb)</text>|,
|
||||
~s|<text class="axis" x="#{(@left + @right) / 2}" y="#{@bottom + 30}" text-anchor="middle">Temperature (°C)</text>|
|
||||
]
|
||||
end
|
||||
|
||||
# ── Background grid ─────────────────────────────────────────────────
|
||||
|
||||
defp isobars do
|
||||
pressures = [1000, 925, 850, 700, 500, 400, 300, 250, 200, 150, 100]
|
||||
|
||||
for p <- pressures do
|
||||
y = pressure_y(p)
|
||||
cls = if p in [1000, 850, 700, 500, 300, 200, 100], do: "grid-major", else: "grid-minor"
|
||||
~s|<line class="#{cls}" x1="#{@left}" y1="#{y}" x2="#{@right}" y2="#{y}" />|
|
||||
end
|
||||
end
|
||||
|
||||
defp isotherms do
|
||||
# Skewed isotherms every 10°C across the plot. Each line goes from
|
||||
# (T at bottom) to (T at top), with skew already baked into
|
||||
# temperature_x. Clip to the plot box.
|
||||
for t <- -100..40//10 do
|
||||
render_isotherm(t)
|
||||
end
|
||||
end
|
||||
|
||||
defp render_isotherm(t) do
|
||||
p1 = {temperature_x(t, @bottom), @bottom}
|
||||
p2 = {temperature_x(t, @top), @top}
|
||||
|
||||
case clip_segment(p1, p2) do
|
||||
nil -> ""
|
||||
clipped -> isotherm_with_label(t, clipped)
|
||||
end
|
||||
end
|
||||
|
||||
defp isotherm_with_label(t, {{x1, y1}, {x2, y2}}) do
|
||||
line = ~s|<line class="grid-iso" x1="#{r(x1)}" y1="#{r(y1)}" x2="#{r(x2)}" y2="#{r(y2)}" />|
|
||||
[line, isotherm_label(t, x1)]
|
||||
end
|
||||
|
||||
defp isotherm_label(t, x1) when t in [-40, -20, 0, 20, 40] do
|
||||
if x1 > @left + 2 and x1 < @right - 12 do
|
||||
~s|<text class="iso-label" x="#{r(x1) + 2}" y="#{@bottom - 2}">#{t}°</text>|
|
||||
else
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
defp isotherm_label(_t, _x1), do: ""
|
||||
|
||||
defp dry_adiabats do
|
||||
# Potential-temperature curves: T_K(p) = θ * (p / 1000)^(R/cp).
|
||||
# Sample every theta = -30..150°C every 10° at 50-mb pressure steps.
|
||||
for theta_c <- -30..150//10 do
|
||||
theta_k = theta_c + 273.15
|
||||
|
||||
pts =
|
||||
sample_pressures()
|
||||
|> Enum.map(fn p ->
|
||||
t_k = theta_k * :math.pow(p / 1000.0, 0.2854)
|
||||
t_c = t_k - 273.15
|
||||
y = pressure_y(p)
|
||||
x = temperature_x(t_c, y)
|
||||
{x, y}
|
||||
end)
|
||||
|> clip_polyline()
|
||||
|
||||
polyline_or_empty(pts, "grid-dry")
|
||||
end
|
||||
end
|
||||
|
||||
defp moist_adiabats do
|
||||
# Saturated pseudo-adiabats — quick approximation by integrating
|
||||
# dT/dp via the moist-adiabatic lapse rate. Good enough to put
|
||||
# green dashes in the right place; not used for parcel ascent.
|
||||
for theta_e_c <- 0..40//4 do
|
||||
pts =
|
||||
(theta_e_c + 273.15)
|
||||
|> moist_adiabat_curve()
|
||||
|> Enum.map(fn {p, t_c} ->
|
||||
y = pressure_y(p)
|
||||
x = temperature_x(t_c, y)
|
||||
{x, y}
|
||||
end)
|
||||
|> clip_polyline()
|
||||
|
||||
polyline_or_empty(pts, "grid-moist")
|
||||
end
|
||||
end
|
||||
|
||||
defp moist_adiabat_curve(theta_e_k) do
|
||||
# Integrate from 1000 mb upward using dT/dz = Γ_m and dp/dz from
|
||||
# hydrostatic. Cheap approximation good enough for plotting.
|
||||
starting_t = theta_e_k - 273.15
|
||||
|
||||
sample_pressures()
|
||||
|> Enum.reduce({[], starting_t, 1000.0}, fn p, {acc, t, prev_p} ->
|
||||
dp = p - prev_p
|
||||
# Δz from hydrostatic with mean T_K ≈ t + 273
|
||||
mean_t_k = max(t + 273.15, 200.0)
|
||||
dz = -dp * 287.0 * mean_t_k / (9.81 * p)
|
||||
gamma_m = moist_lapse_c_per_m(t, p)
|
||||
new_t = t + dz * gamma_m
|
||||
{[{p, new_t} | acc], new_t, p}
|
||||
end)
|
||||
|> elem(0)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp moist_lapse_c_per_m(t_c, p_mb) do
|
||||
t_k = t_c + 273.15
|
||||
# Saturation mixing ratio (g/g) via Buck's eq.
|
||||
es = 6.1121 * :math.exp((18.678 - t_c / 234.5) * (t_c / (257.14 + t_c)))
|
||||
qs = 0.622 * es / (p_mb - es)
|
||||
lv = 2.5e6
|
||||
cp = 1004.0
|
||||
rd = 287.0
|
||||
rv = 461.0
|
||||
g = 9.81
|
||||
num = g * (1.0 + lv * qs / (rd * t_k))
|
||||
den = cp + lv * lv * qs / (rv * t_k * t_k)
|
||||
-num / den
|
||||
end
|
||||
|
||||
defp mixing_ratio_lines do
|
||||
# Constant-mixing-ratio lines (g/kg). Each line is the locus of
|
||||
# (T_d, p) for a given saturation mixing ratio.
|
||||
for w <- [0.4, 1, 2, 4, 7, 10, 16, 24, 32] do
|
||||
pts =
|
||||
sample_pressures()
|
||||
|> Enum.filter(&(&1 >= 400))
|
||||
|> Enum.map(fn p ->
|
||||
# Solve for T from w = 0.622 * e_s(T) / (p - e_s).
|
||||
e = w / 1000.0 * p / (0.622 + w / 1000.0)
|
||||
t_d = dewpoint_from_vapor(e)
|
||||
y = pressure_y(p)
|
||||
x = temperature_x(t_d, y)
|
||||
{x, y}
|
||||
end)
|
||||
|> clip_polyline()
|
||||
|
||||
polyline_or_empty(pts, "grid-mix")
|
||||
end
|
||||
end
|
||||
|
||||
# Inverse Buck — find T given vapor pressure e (hPa).
|
||||
defp dewpoint_from_vapor(e) when e <= 0, do: -80.0
|
||||
|
||||
defp dewpoint_from_vapor(e) do
|
||||
# Magnus form: T = 243.5 * ln(e/6.112) / (17.67 - ln(e/6.112))
|
||||
ratio = :math.log(e / 6.112)
|
||||
243.5 * ratio / (17.67 - ratio)
|
||||
end
|
||||
|
||||
defp sample_pressures do
|
||||
# 1050 down to 100 in 25-mb steps.
|
||||
100..1050//25 |> Enum.to_list() |> Enum.reverse()
|
||||
end
|
||||
|
||||
defp clip_polyline(points) do
|
||||
Enum.filter(points, fn {x, y} -> x >= @left - 200 and x <= @right + 200 and y >= @top and y <= @bottom end)
|
||||
end
|
||||
|
||||
defp polyline_or_empty([], _cls), do: ""
|
||||
|
||||
defp polyline_or_empty(points, cls) do
|
||||
pts =
|
||||
Enum.map_join(points, " ", fn {x, y} -> "#{r(x)},#{r(y)}" end)
|
||||
|
||||
~s|<polyline class="#{cls}" points="#{pts}" />|
|
||||
end
|
||||
|
||||
# ── Profile traces ──────────────────────────────────────────────────
|
||||
|
||||
defp profile_traces([]), do: ""
|
||||
|
||||
defp profile_traces(profile) do
|
||||
sorted = Enum.sort_by(profile, & &1.pres, :desc)
|
||||
|
||||
temp_pts =
|
||||
sorted
|
||||
|> Enum.map(fn p ->
|
||||
y = pressure_y(p.pres)
|
||||
x = temperature_x(p.tmpc, y)
|
||||
{x, y}
|
||||
end)
|
||||
|> Enum.filter(fn {_x, y} -> y >= @top and y <= @bottom end)
|
||||
|
||||
dew_pts =
|
||||
sorted
|
||||
|> Enum.filter(& &1.dwpc)
|
||||
|> Enum.map(fn p ->
|
||||
y = pressure_y(p.pres)
|
||||
x = temperature_x(p.dwpc, y)
|
||||
{x, y}
|
||||
end)
|
||||
|> Enum.filter(fn {_x, y} -> y >= @top and y <= @bottom end)
|
||||
|
||||
[
|
||||
polyline_or_empty(dew_pts, "dew-line"),
|
||||
polyline_or_empty(temp_pts, "temp-line")
|
||||
]
|
||||
end
|
||||
|
||||
defp clean_profile(profile) do
|
||||
profile
|
||||
|> Enum.map(&normalize_level/1)
|
||||
|> Enum.filter(&(&1.pres && &1.tmpc))
|
||||
end
|
||||
|
||||
defp normalize_level(%{} = level) do
|
||||
%{
|
||||
pres: pick_num(level, ["pres", "pres_mb", :pres, :pres_mb]),
|
||||
hght: pick_num(level, ["hght", "hght_m", :hght, :hght_m]),
|
||||
tmpc: pick_num(level, ["tmpc", :tmpc]),
|
||||
dwpc: pick_num(level, ["dwpc", :dwpc])
|
||||
}
|
||||
end
|
||||
|
||||
defp pick_num(map, keys) do
|
||||
keys
|
||||
|> Enum.find_value(fn k -> Map.get(map, k) end)
|
||||
|> coerce_num()
|
||||
end
|
||||
|
||||
defp coerce_num(n) when is_number(n), do: n * 1.0
|
||||
defp coerce_num(_), do: nil
|
||||
|
||||
defp r(n) when is_float(n), do: Float.round(n, 1)
|
||||
defp r(n), do: n
|
||||
end
|
||||
|
|
@ -191,6 +191,7 @@ defmodule MicrowavepropWeb.Router do
|
|||
live "/contacts/:id", ContactLive.Show
|
||||
live "/path", PathLive
|
||||
live "/eme", EmeLive
|
||||
live "/skewt", SkewtLive
|
||||
live "/rover", RoverLive
|
||||
live "/algo", AlgoLive
|
||||
live "/about", AboutLive
|
||||
|
|
|
|||
37
test/microwaveprop_web/live/skewt_live_test.exs
Normal file
37
test/microwaveprop_web/live/skewt_live_test.exs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
defmodule MicrowavepropWeb.SkewtLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "GET /skewt" do
|
||||
test "renders search bar with no results when no query is supplied", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/skewt")
|
||||
|
||||
assert html =~ "Skew-T-Log-P"
|
||||
assert html =~ ~s|placeholder="EM12kp|
|
||||
refute html =~ "viewBox=\"0 0 720"
|
||||
end
|
||||
|
||||
test "renders the resolved location label when given a grid square", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/skewt?q=EM12kp")
|
||||
|
||||
# The grid is resolved client-side from the URL — even when no
|
||||
# HRRR profile happens to be on disk for this lat/lon, the
|
||||
# location header should still surface so the user knows their
|
||||
# input was accepted.
|
||||
assert html =~ "EM12KP"
|
||||
# Lat/lon get formatted to 3 decimals.
|
||||
assert html =~ "32.646"
|
||||
assert html =~ "-97.125"
|
||||
end
|
||||
|
||||
test "submitting the form patches the URL with the query", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/skewt")
|
||||
|
||||
view |> form("form", q: "EM12") |> render_submit()
|
||||
|
||||
# After patch, the location label appears on the page.
|
||||
assert render(view) =~ "EM12"
|
||||
end
|
||||
end
|
||||
end
|
||||
46
test/microwaveprop_web/live/skewt_location_resolver_test.exs
Normal file
46
test/microwaveprop_web/live/skewt_location_resolver_test.exs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
defmodule MicrowavepropWeb.SkewtLocationResolverTest do
|
||||
use Microwaveprop.DataCase, async: false
|
||||
|
||||
alias MicrowavepropWeb.SkewtLocationResolver
|
||||
|
||||
describe "classify/1" do
|
||||
test "recognises 4/6/8-character Maidenhead grid squares" do
|
||||
assert SkewtLocationResolver.classify("EM12") == :grid
|
||||
assert SkewtLocationResolver.classify("EM12kp") == :grid
|
||||
assert SkewtLocationResolver.classify("EM12kp37") == :grid
|
||||
end
|
||||
|
||||
test "recognises typical US/foreign callsigns" do
|
||||
assert SkewtLocationResolver.classify("W5ISP") == :callsign
|
||||
assert SkewtLocationResolver.classify("KM5PO") == :callsign
|
||||
assert SkewtLocationResolver.classify("VE3ABC") == :callsign
|
||||
assert SkewtLocationResolver.classify("G0ABC") == :callsign
|
||||
end
|
||||
|
||||
test "anything else is treated as an address" do
|
||||
assert SkewtLocationResolver.classify("Dallas, TX") == :address
|
||||
assert SkewtLocationResolver.classify("123 Main St, Plano TX") == :address
|
||||
assert SkewtLocationResolver.classify("Eiffel Tower") == :address
|
||||
end
|
||||
|
||||
test "is whitespace and case tolerant" do
|
||||
assert SkewtLocationResolver.classify(" em12 ") == :grid
|
||||
assert SkewtLocationResolver.classify("w5isp") == :callsign
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/1 — grid square branch" do
|
||||
test "returns lat/lon for a valid grid square" do
|
||||
assert {:ok, %{lat: lat, lon: lon, label: "EM12KP", source: :grid}} =
|
||||
SkewtLocationResolver.resolve("EM12kp")
|
||||
|
||||
assert_in_delta lat, 32.646, 0.01
|
||||
assert_in_delta lon, -97.125, 0.01
|
||||
end
|
||||
|
||||
test "round-trips a 4-char grid" do
|
||||
assert {:ok, %{label: "EM12", source: :grid}} =
|
||||
SkewtLocationResolver.resolve("EM12")
|
||||
end
|
||||
end
|
||||
end
|
||||
91
test/microwaveprop_web/live/skewt_svg_test.exs
Normal file
91
test/microwaveprop_web/live/skewt_svg_test.exs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
defmodule MicrowavepropWeb.SkewtSvgTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MicrowavepropWeb.SkewtSvg
|
||||
|
||||
@profile [
|
||||
%{"pres" => 1013.0, "hght" => 100.0, "tmpc" => 25.0, "dwpc" => 18.0},
|
||||
%{"pres" => 925.0, "hght" => 800.0, "tmpc" => 18.0, "dwpc" => 14.0},
|
||||
%{"pres" => 850.0, "hght" => 1500.0, "tmpc" => 12.0, "dwpc" => 9.0},
|
||||
%{"pres" => 700.0, "hght" => 3000.0, "tmpc" => 0.0, "dwpc" => -5.0},
|
||||
%{"pres" => 500.0, "hght" => 5500.0, "tmpc" => -15.0, "dwpc" => -25.0},
|
||||
%{"pres" => 300.0, "hght" => 9000.0, "tmpc" => -45.0, "dwpc" => -65.0}
|
||||
]
|
||||
|
||||
describe "render/1" do
|
||||
test "produces a valid <svg> root with the expected viewBox" do
|
||||
svg = @profile |> SkewtSvg.render() |> IO.iodata_to_binary()
|
||||
|
||||
assert svg =~ ~r|<svg[^>]*viewBox="0 0 720 640"|
|
||||
assert String.ends_with?(svg, "</svg>")
|
||||
end
|
||||
|
||||
test "draws temperature and dewpoint polylines" do
|
||||
svg = @profile |> SkewtSvg.render() |> IO.iodata_to_binary()
|
||||
|
||||
assert svg =~ ~s|class="temp-line"|
|
||||
assert svg =~ ~s|class="dew-line"|
|
||||
end
|
||||
|
||||
test "tolerates Rust-style atom-keyed levels" do
|
||||
rust_profile = [
|
||||
%{pres_mb: 1013.0, hght_m: 100.0, tmpc: 25.0, dwpc: 18.0},
|
||||
%{pres_mb: 850.0, hght_m: 1500.0, tmpc: 12.0, dwpc: 9.0},
|
||||
%{pres_mb: 500.0, hght_m: 5500.0, tmpc: -15.0, dwpc: -25.0}
|
||||
]
|
||||
|
||||
svg = rust_profile |> SkewtSvg.render() |> IO.iodata_to_binary()
|
||||
assert svg =~ ~s|class="temp-line"|
|
||||
end
|
||||
|
||||
test "renders without trace polylines on an empty profile" do
|
||||
svg = [] |> SkewtSvg.render() |> IO.iodata_to_binary()
|
||||
|
||||
# The style block always defines the trace classes; only the
|
||||
# polyline elements should be missing for an empty profile.
|
||||
assert svg =~ "<svg"
|
||||
refute svg =~ ~s|<polyline class="temp-line"|
|
||||
refute svg =~ ~s|<polyline class="dew-line"|
|
||||
end
|
||||
|
||||
test "skips levels missing the dewpoint" do
|
||||
partial = [
|
||||
%{"pres" => 1000.0, "hght" => 100.0, "tmpc" => 20.0, "dwpc" => 10.0},
|
||||
%{"pres" => 500.0, "hght" => 5000.0, "tmpc" => -10.0, "dwpc" => nil}
|
||||
]
|
||||
|
||||
svg = partial |> SkewtSvg.render() |> IO.iodata_to_binary()
|
||||
# Temperature line must still draw both points.
|
||||
assert svg =~ ~s|class="temp-line"|
|
||||
# Dewpoint line will only contain the bottom point and renders as
|
||||
# nothing (single-point polyline) — that's fine, the test asserts
|
||||
# the renderer didn't crash.
|
||||
assert svg =~ "</svg>"
|
||||
end
|
||||
end
|
||||
|
||||
describe "coordinate transforms" do
|
||||
test "pressure_y is monotonically decreasing as pressure decreases" do
|
||||
y1000 = SkewtSvg.pressure_y(1000)
|
||||
y500 = SkewtSvg.pressure_y(500)
|
||||
y100 = SkewtSvg.pressure_y(100)
|
||||
|
||||
assert y1000 > y500
|
||||
assert y500 > y100
|
||||
end
|
||||
|
||||
test "temperature_x at the bottom edge: warmer = further right" do
|
||||
cold = SkewtSvg.temperature_x(-20, SkewtSvg.pressure_y(1000))
|
||||
warm = SkewtSvg.temperature_x(20, SkewtSvg.pressure_y(1000))
|
||||
assert warm > cold
|
||||
end
|
||||
|
||||
test "temperature_x is skewed: same temperature shifts right as pressure decreases" do
|
||||
bottom = SkewtSvg.temperature_x(0, SkewtSvg.pressure_y(1000))
|
||||
top = SkewtSvg.temperature_x(0, SkewtSvg.pressure_y(300))
|
||||
# Skew-T isotherms lean lower-left → upper-right, so the same
|
||||
# 0 °C plotted at 300 mb sits to the right of 0 °C at 1000 mb.
|
||||
assert top > bottom
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue