defmodule MicrowavepropWeb.SkewT do @moduledoc """ Skew-T log-P diagram renderer. Builds the full set of SVG primitives (isobars, isotherms, dry adiabats, saturation mixing ratio lines, and temperature/dewpoint traces) for a single HRRR or NARR pressure-level profile so the contact detail template can draw it declaratively. ## Meteorology * Magnus formula for saturation vapor pressure: `es(T) = 6.112 * exp(17.67 * T / (T + 243.5))` with T in °C. * Saturation mixing ratio: `ws = 622 * es / (p - es)` in g/kg. * Dry adiabat (constant potential temperature θ, K): `T(p) = θ * (p / 1000)^(R/cp)` with `R/cp ≈ 0.2854`. ## Skew transform Pressure is on a log axis (top to bottom, larger p at the bottom). Isotherms are skewed 45° by shifting each (T, p) point right as pressure decreases, so a vertical line on the chart corresponds to a temperature that rises with height — a hallmark of the classic skew-T log-P diagram. """ @dew_point_line_color "#16a34a" @temp_line_color "#dc2626" @dry_adiabat_thetas_k 250..360//10 @mixing_ratios_g_kg [0.4, 1.0, 2.0, 4.0, 8.0, 12.0, 16.0, 20.0] @type projected :: {x :: float(), y :: float()} @type chart :: %{ width: number(), height: number(), padding_top: number(), padding_bottom: number(), padding_left: number(), padding_right: number(), plot_width: number(), plot_height: number(), t_min: number(), t_max: number(), p_bot: number(), p_top: number(), skew: number() } @default_opts [ width: 560, height: 620, padding_top: 24, padding_bottom: 36, padding_left: 44, padding_right: 20, t_min: -40.0, t_max: 40.0, p_bot: 1050.0, p_top: 100.0, skew: 0.55 ] @doc """ Build a chart config from overrideable keyword options. Returned struct has the plot dimensions pre-computed. """ @spec chart_config(keyword()) :: chart() def chart_config(opts \\ []) do opts = Keyword.merge(@default_opts, opts) width = Keyword.fetch!(opts, :width) height = Keyword.fetch!(opts, :height) padding_top = Keyword.fetch!(opts, :padding_top) padding_bottom = Keyword.fetch!(opts, :padding_bottom) padding_left = Keyword.fetch!(opts, :padding_left) padding_right = Keyword.fetch!(opts, :padding_right) plot_width = width - padding_left - padding_right plot_height = height - padding_top - padding_bottom %{ width: width, height: height, padding_top: padding_top, padding_bottom: padding_bottom, padding_left: padding_left, padding_right: padding_right, plot_width: plot_width, plot_height: plot_height, t_min: Keyword.fetch!(opts, :t_min), t_max: Keyword.fetch!(opts, :t_max), p_bot: Keyword.fetch!(opts, :p_bot), p_top: Keyword.fetch!(opts, :p_top), skew: Keyword.fetch!(opts, :skew) } end @doc """ Build the skew-T primitives for `profile` — a list of maps with string keys `"pres"`, `"tmpc"`, `"dwpc"`, and optionally `"hght"`. Returns `nil` when the profile is missing or empty. """ @spec build([map()] | nil, keyword()) :: map() | nil def build(profile, opts \\ []) def build(nil, _), do: nil def build([], _), do: nil def build(profile, opts) when is_list(profile) do chart = chart_config(opts) t_points = profile |> Enum.filter(fn l -> is_number(l["tmpc"]) and is_number(l["pres"]) end) |> Enum.sort_by(&(-&1["pres"])) |> Enum.map(fn l -> project(l["tmpc"], l["pres"], chart) end) td_points = profile |> Enum.filter(fn l -> is_number(l["dwpc"]) and is_number(l["pres"]) end) |> Enum.sort_by(&(-&1["pres"])) |> Enum.map(fn l -> project(l["dwpc"], l["pres"], chart) end) %{ width: chart.width, height: chart.height, padding_top: chart.padding_top, padding_bottom: chart.padding_bottom, padding_left: chart.padding_left, padding_right: chart.padding_right, plot_width: chart.plot_width, plot_height: chart.plot_height, isobars: isobars(chart), isotherms: isotherms(chart), dry_adiabats: dry_adiabats(chart), mixing_ratio_lines: mixing_ratio_lines(chart), t_points: t_points, td_points: td_points, t_path: points_to_path(t_points), td_path: points_to_path(td_points), temp_color: @temp_line_color, dewpoint_color: @dew_point_line_color } end # ── Meteorology ────────────────────────────────────────────── @doc """ Saturation vapor pressure (hPa / mb) for temperature `t_c` in °C. Magnus formula with the Tetens coefficients used by IFS/GFS. """ @spec saturation_vapor_pressure(number()) :: float() def saturation_vapor_pressure(t_c) when is_number(t_c) do 6.112 * :math.exp(17.67 * t_c / (t_c + 243.5)) end @doc """ Invert the saturation-mixing-ratio relation: given a mixing ratio `ws_g_kg` and pressure `p_mb`, return the temperature (°C) whose saturation vapor pressure yields that mixing ratio. """ @spec temperature_from_mixing_ratio(number(), number()) :: float() def temperature_from_mixing_ratio(ws_g_kg, p_mb) when is_number(ws_g_kg) and is_number(p_mb) and ws_g_kg > 0 do es = ws_g_kg * p_mb / (622.0 + ws_g_kg) ln = :math.log(es / 6.112) 243.5 * ln / (17.67 - ln) end @doc """ Temperature (°C) along the dry adiabat with potential temperature `theta_k` (K) at pressure `p_mb` (mb). """ @spec dry_adiabat_temperature(number(), number()) :: float() def dry_adiabat_temperature(theta_k, p_mb) when is_number(theta_k) and is_number(p_mb) do theta_k * :math.pow(p_mb / 1000.0, 0.2854) - 273.15 end # ── Projection ─────────────────────────────────────────────── @doc """ Project a `(t_c, p_mb)` point onto chart pixel coordinates. """ @spec project(number(), number(), chart()) :: projected() def project(t_c, p_mb, chart) when is_number(t_c) and is_number(p_mb) do p_ratio = pressure_ratio(p_mb, chart) y = chart.padding_top + (1.0 - p_ratio) * chart.plot_height x_base = chart.padding_left + (t_c - chart.t_min) / (chart.t_max - chart.t_min) * chart.plot_width x = x_base + chart.skew * p_ratio * chart.plot_width {x, y} end defp pressure_ratio(p_mb, chart) do # 0 at p_bot (chart bottom), 1 at p_top (chart top). Log-P scale. :math.log(chart.p_bot / p_mb) / :math.log(chart.p_bot / chart.p_top) end # ── Background curves ──────────────────────────────────────── @isobar_levels_mb [1000, 850, 700, 500, 400, 300, 250, 200, 150, 100] defp isobars(chart) do for p <- @isobar_levels_mb, p <= chart.p_bot and p >= chart.p_top do {_x, y} = project(0.0, p * 1.0, chart) %{ y: y, x_start: chart.padding_left, x_end: chart.padding_left + chart.plot_width, label: "#{p}", label_y: y + 3 } end end defp isotherms(chart) do # Draw isotherms every 10°C across the data area. Each one is a # straight line between its (t, p_bot) and (t, p_top) projections, # clipped to the plot rectangle by the svg viewport. for t_c <- -120..40//10 do {x_bot, y_bot} = project(t_c * 1.0, chart.p_bot, chart) {x_top, y_top} = project(t_c * 1.0, chart.p_top, chart) label_x = if t_c >= chart.t_min and t_c <= chart.t_max do {x, _} = project(t_c * 1.0, chart.p_bot, chart) x end %{ x1: x_bot, y1: y_bot, x2: x_top, y2: y_top, t_c: t_c, label_x: label_x, label_y: chart.padding_top + chart.plot_height + 14 } end end defp dry_adiabats(chart) do pressures = log_pressure_samples(chart.p_bot, chart.p_top, 24) for theta <- @dry_adiabat_thetas_k do points = Enum.map(pressures, fn p -> t = dry_adiabat_temperature(theta * 1.0, p) project(t, p, chart) end) %{theta_k: theta, path: points_to_path(points)} end end defp mixing_ratio_lines(chart) do # Mixing ratio isohumes only make sense in the lower troposphere. pressures = log_pressure_samples(chart.p_bot, 400.0, 18) for ws <- @mixing_ratios_g_kg do points = Enum.map(pressures, fn p -> t = temperature_from_mixing_ratio(ws, p) project(t, p, chart) end) %{ws_g_kg: ws, path: points_to_path(points)} end end defp log_pressure_samples(p_from, p_to, count) do log_from = :math.log(p_from) log_to = :math.log(p_to) step = (log_to - log_from) / (count - 1) for i <- 0..(count - 1) do :math.exp(log_from + i * step) end end defp points_to_path([]), do: "" defp points_to_path([{x0, y0} | rest]) do initial = "M#{format_coord(x0)},#{format_coord(y0)}" body = Enum.map_join(rest, " ", fn {x, y} -> "L#{format_coord(x)},#{format_coord(y)}" end) if body == "", do: initial, else: initial <> " " <> body end defp format_coord(n) when is_number(n), do: :erlang.float_to_binary(n * 1.0, decimals: 2) end