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. """ alias Microwaveprop.Weather.SkewtParams # Plot canvas (SVG user units). The right margin (right edge of the # plot box → SVG edge) is sized to fit the longest critical-level # label rail entry, e.g. `LCL 925 mb`, without clipping. @width 820 @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 # Minimum vertical gap between two stacked critical-level label # baselines (px in SVG user units). 14 leaves the 10 px text plus a # hair of breathing room — anything tighter starts overlapping # descenders. @critical_label_min_dy 14 # 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`. When `:parcel_trace` is supplied (typically from `Microwaveprop.Weather.SkewtParams.derive/1`) a dashed grey parcel- ascent line is overlaid on the temperature trace, matching the surface-parcel rendition the SPC sounding viewer publishes. """ @spec render([map()], keyword()) :: iodata() def render(profile, opts \\ []) when is_list(profile) do cleaned = clean_profile(profile) parcel = Keyword.get(opts, :parcel_trace, []) critical = Keyword.get(opts, :critical_levels, []) plot_w = @right - @left plot_h = @bottom - @top [ ~s||, style_block(), # Define a rectangular clip the plot interior shares so any # grid/trace polyline whose vertices stretch past the frame # (e.g. skewed isotherms exiting the right edge near the top) # is visually cropped. The frame and axis labels live *outside* # this group, so the box outline still reads clean. ~s||, ~s||, isobars(), isotherms(), dry_adiabats(), moist_adiabats(), mixing_ratio_lines(), virtual_temp_trace(cleaned), wet_bulb_trace(cleaned), profile_traces(cleaned), parcel_trace_line(parcel), parcel_virtual_trace(parcel), "", frame(), axis_labels(), critical_level_markers(critical), cursor_layer(), "" ] end # ── Wet-bulb / virtual-T traces (NOAA SPC supplementary lines) ───── defp wet_bulb_trace([]), do: "" defp wet_bulb_trace(profile) do pts = profile |> Enum.sort_by(& &1.pres, :desc) |> Enum.flat_map(fn lvl -> case SkewtParams.wet_bulb_c(%{ tmpc: lvl.tmpc, dwpc: lvl.dwpc, pres: lvl.pres }) do nil -> [] t_w -> [{temperature_x(t_w, pressure_y(lvl.pres)), pressure_y(lvl.pres)}] end end) |> clip_polyline() polyline_or_empty(pts, "wet-bulb-line") end defp virtual_temp_trace([]), do: "" defp virtual_temp_trace(profile) do pts = profile |> Enum.sort_by(& &1.pres, :desc) |> Enum.map(fn lvl -> t_v = SkewtParams.virtual_temp_c(lvl.tmpc, lvl.dwpc, lvl.pres) y = pressure_y(lvl.pres) {temperature_x(t_v, y), y} end) |> clip_polyline() polyline_or_empty(pts, "virtual-temp-line") end # Parcel ascent uses the saturated mixing ratio at its own # temperature above the LCL — that's the virtual-T trajectory used # in the actual CAPE integration. SPC draws this as a thin red # dashed line slightly to the right of the dry-bulb parcel trace. defp parcel_virtual_trace([]), do: "" defp parcel_virtual_trace(trace) do pts = trace |> Enum.sort_by(& &1.pres, :desc) |> Enum.map(fn %{pres: p, tmpc: t} -> t_v = SkewtParams.virtual_temp_c(t, p) y = pressure_y(p) {temperature_x(t_v, y), y} end) |> clip_polyline() polyline_or_empty(pts, "parcel-virtual-line") end # Critical-level annotations on the right edge — each entry is a # `%{label, pressure_mb}` map; the label appears outside the frame # at the right so it doesn't obscure the trace, with a short # in-plot tick. Mirrors SPC's `143 mb`, `158 mb (mix)`, etc. rail. defp critical_level_markers([]), do: "" # Walk the levels in y order and bump any text whose baseline would # land within @critical_label_min_dy of the previous one. The tick # always sits at the level's true y; only the label gets nudged with # a thin leader so close levels (e.g. LCL/LFC, 0 °C/WBZ) read cleanly. defp critical_level_markers(levels) do levels |> Enum.flat_map(fn %{label: label, pressure_mb: p} when is_number(p) -> y = pressure_y(p) if y >= @top and y <= @bottom, do: [{label, p, y}], else: [] _ -> [] end) |> Enum.sort_by(fn {_lbl, _p, y} -> y end) |> Enum.map_reduce(@top * 1.0, fn {label, p, y}, last_text_y -> text_y = max(y, last_text_y + @critical_label_min_dy) iodata = render_critical_marker(label, p, y, text_y) {iodata, text_y} end) |> elem(0) end defp render_critical_marker(label, pressure_mb, y, text_y) do leader = if abs(text_y - y) > 0.5 do ~s|| else "" end [ ~s||, leader, ~s|#{label} #{round(pressure_mb)} mb| ] end # Surface-parcel ascent line (dashed grey), matching the SPC viewer's # convention. The trace is a list of `%{pres, tmpc}` from surface up # through every level above the LCL. defp parcel_trace_line([]), do: "" defp parcel_trace_line(trace) do pts = trace |> Enum.sort_by(& &1.pres, :desc) |> Enum.map(fn %{pres: p, tmpc: t} -> y = pressure_y(p) {temperature_x(t, y), y} end) |> clip_polyline() case pts do [] -> "" _ -> ~s|| end end defp points_str(points) do Enum.map_join(points, " ", fn {x, y} -> "#{r(x)},#{r(y)}" end) end # Crosshair layer the JS hook (`assets/js/skewt_hook.js`) drives. # Initially `display: none`; the hook flips display + updates element # coordinates on `mousemove`. defp cursor_layer do """ """ end # NOAA SPC Skew-T-Log-P colour set, copied from the SPC sounding viewer # (https://www.spc.noaa.gov/exper/soundings/) so the chart reads the # same way as the public reference product: # # * Temperature trace .................. solid red (#ff0000) # * Dewpoint trace ..................... solid green (#00aa00) # * Isotherms .......................... dashed teal (#22d3a4-ish) # * Dry adiabats ....................... solid orange (#f59e0b) # * Moist (saturated) adiabats ......... dashed sky-blue (#38bdf8) # * Mixing-ratio lines ................. dotted darker green (#16a34a), # values 0.4–20 g/kg, drawn only # in the lower atmosphere # * Isobars ............................ light grey horizontal lines defp style_block do """ """ end # ── Geometry exposed to the hover hook ────────────────────────────── @doc """ Plot geometry constants the JS hover hook needs to invert pixel-Y back to pressure and to project temperature values onto the skewed diagram. Returned as a plain map so it serializes to JSON via `Jason.encode!/1` straight onto a `data-` attribute. """ @spec geometry() :: map() def geometry do %{ width: @width, height: @height, left: @left, right: @right, top: @top, bottom: @bottom, p_bottom: @p_bottom, p_top: @p_top, t_min: @t_min, t_max: @t_max, skew: @skew } 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 # NOAA SPC mixing-ratio set, in g/kg. Same values quoted along the # bottom of the SPC sounding charts (0.4 / 0.7 / 1 / 2 / 3 / 5 / 8 / # 12 / 16 / 20). Lines drawn from the surface up to 600 mb only — # above that the saturation values get unhelpfully close together. @mixing_ratios [0.4, 0.7, 1, 2, 3, 5, 8, 12, 16, 20] @mix_top_pres 600.0 @mix_label_pres 700.0 defp mixing_ratio_lines do Enum.map(@mixing_ratios, fn w -> pts = sample_pressures() |> Enum.filter(&(&1 >= @mix_top_pres)) |> 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() label = mix_label(w) [polyline_or_empty(pts, "grid-mix"), label] end) end # Anchor each mixing-ratio line with its g/kg value at ~700 mb, # mirroring SPC's row of green numbers along the diagram's lower band. defp mix_label(w) do e = w / 1000.0 * @mix_label_pres / (0.622 + w / 1000.0) t_d = dewpoint_from_vapor(e) y = pressure_y(@mix_label_pres) x = temperature_x(t_d, y) if x >= @left and x <= @right do label = if w >= 1 do w |> trunc() |> Integer.to_string() else :erlang.float_to_binary(w * 1.0, decimals: 1) end ~s|#{label}| else "" 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