prop/test/microwaveprop_web/live/skewt_svg_test.exs
Graham McIntire e40ade3b19
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.
2026-04-25 12:13:46 -05:00

91 lines
3.3 KiB
Elixir

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