From e40ade3b194a4568240d2bee08486e86d50337c6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 12:04:10 -0500 Subject: [PATCH] feat(skewt): add /skewt LiveView with HRRR-backed Skew-T-Log-P plot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop_web/live/skewt_live.ex | 330 ++++++++++++++++ .../live/skewt_location_resolver.ex | 91 +++++ lib/microwaveprop_web/live/skewt_svg.ex | 365 ++++++++++++++++++ lib/microwaveprop_web/router.ex | 1 + .../live/skewt_live_test.exs | 37 ++ .../live/skewt_location_resolver_test.exs | 46 +++ .../microwaveprop_web/live/skewt_svg_test.exs | 91 +++++ 7 files changed, 961 insertions(+) create mode 100644 lib/microwaveprop_web/live/skewt_live.ex create mode 100644 lib/microwaveprop_web/live/skewt_location_resolver.ex create mode 100644 lib/microwaveprop_web/live/skewt_svg.ex create mode 100644 test/microwaveprop_web/live/skewt_live_test.exs create mode 100644 test/microwaveprop_web/live/skewt_location_resolver_test.exs create mode 100644 test/microwaveprop_web/live/skewt_svg_test.exs diff --git a/lib/microwaveprop_web/live/skewt_live.ex b/lib/microwaveprop_web/live/skewt_live.ex new file mode 100644 index 00000000..faffd049 --- /dev/null +++ b/lib/microwaveprop_web/live/skewt_live.ex @@ -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""" + + <.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 diff --git a/lib/microwaveprop_web/live/skewt_location_resolver.ex b/lib/microwaveprop_web/live/skewt_location_resolver.ex new file mode 100644 index 00000000..e0c67f80 --- /dev/null +++ b/lib/microwaveprop_web/live/skewt_location_resolver.ex @@ -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 diff --git a/lib/microwaveprop_web/live/skewt_svg.ex b/lib/microwaveprop_web/live/skewt_svg.ex new file mode 100644 index 00000000..dde4ad99 --- /dev/null +++ b/lib/microwaveprop_web/live/skewt_svg.ex @@ -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||, + style_block(), + isobars(), + isotherms(), + dry_adiabats(), + moist_adiabats(), + mixing_ratio_lines(), + frame(), + axis_labels(), + profile_traces(cleaned), + "" + ] + end + + defp style_block do + """ + + + + """ + 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|| + 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|#{p}| + end + + temps = -40..40//10 + + temp_labels = + for t <- temps do + x = temperature_x(t, @bottom) + + if x >= @left and x <= @right do + ~s|#{t}| + else + "" + end + end + + [ + pres_labels, + temp_labels, + ~s|Pressure (mb)|, + ~s|Temperature (°C)| + ] + 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|| + 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, 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|#{t}°| + 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|| + 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 diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 112fc209..9d147487 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -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 diff --git a/test/microwaveprop_web/live/skewt_live_test.exs b/test/microwaveprop_web/live/skewt_live_test.exs new file mode 100644 index 00000000..5ee77191 --- /dev/null +++ b/test/microwaveprop_web/live/skewt_live_test.exs @@ -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 diff --git a/test/microwaveprop_web/live/skewt_location_resolver_test.exs b/test/microwaveprop_web/live/skewt_location_resolver_test.exs new file mode 100644 index 00000000..401a287d --- /dev/null +++ b/test/microwaveprop_web/live/skewt_location_resolver_test.exs @@ -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 diff --git a/test/microwaveprop_web/live/skewt_svg_test.exs b/test/microwaveprop_web/live/skewt_svg_test.exs new file mode 100644 index 00000000..01ab3303 --- /dev/null +++ b/test/microwaveprop_web/live/skewt_svg_test.exs @@ -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 root with the expected viewBox" do + svg = @profile |> SkewtSvg.render() |> IO.iodata_to_binary() + + assert svg =~ ~r|]*viewBox="0 0 720 640"| + assert String.ends_with?(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 =~ " 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 =~ "" + 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