Three additions that bring the diagram closer to the SPC sounding
viewer's full-information chart:
* Wet-bulb temperature trace, plotted as a thinner blue line under
the dewpoint trace. Comes from a Stipanuk-style bracket between
the level's dewpoint and dry-bulb temperatures (already used by
`SkewtParams` for the wet-bulb-zero level — now public so the
renderer can call it per level).
* Environment virtual-temperature trace and parcel virtual-T
trajectory, both as thin pinkish dashed lines. The parcel
virtual-T is the actual buoyancy curve CAPE integrates against,
so showing it next to the dry-bulb parcel ascent makes the
moisture correction visible. New `SkewtParams.virtual_temp_c/2`
and `/3` helpers, plus `SkewtParams.mixing_ratio/2`.
* Critical-level rail on the right edge. Each of LCL / LFC / EL /
0 °C / WBZ that resolves on the current profile gets a short
horizontal tick at the right of the plot and a `<label> <p> mb`
tag just outside it — matches the SPC chart's `143 mb (mix)` /
`158 mb (mix)` annotation column. SkewtLive computes the
pressures (and converts the height-keyed 0 °C and WBZ from
SkewtParams back to pressure via `pressure_at_height/2`) before
handing them to `SkewtSvg.render/2`.
Wind-derived overlays (hodograph, wind barbs) remain off — HRRR
profile data is wind-free above 10 m AGL.
mix test: 27 / 27 across the four /skewt-related test files,
2,902 / 2,902 + 221 properties on the full suite. mix credo --strict
clean (one cyclomatic-complexity refactor extracted along the way).
574 lines
19 KiB
Elixir
574 lines
19 KiB
Elixir
defmodule Microwaveprop.Weather.SkewtParams do
|
||
@moduledoc """
|
||
Derived sounding parameters for the `/skewt` page — the same set the
|
||
SPC sounding viewer publishes in its `*.txt` companion file
|
||
(`https://www.spc.noaa.gov/exper/soundings/`):
|
||
|
||
* Lifting condensation level (`lcl_pressure_mb`, `lcl_temp_c`,
|
||
`lcl_height_m`).
|
||
* Level of free convection (`lfc_pressure_mb`, `lfc_height_m`).
|
||
* Equilibrium level (`el_pressure_mb`, `el_height_m`).
|
||
* Surface-based CAPE/CIN (`sbcape`, `sbcin` in J/kg).
|
||
* Three-kilometre CAPE (`cape_3km`).
|
||
* Lifted index (`lifted_index`, parcel-environment T diff at 500 mb).
|
||
* Freezing level and wet-bulb-zero level (`freezing_level_m`,
|
||
`wbz_m`).
|
||
* Downdraft CAPE (`dcape`).
|
||
* Layer lapse rates (`lapse_rate_sfc_3km_c_per_km`,
|
||
`lapse_rate_700_500_c_per_km`, `lapse_rate_850_500_c_per_km`).
|
||
* `parcel_trace` — the surface parcel's pressure/temperature
|
||
trajectory through the diagram, used by `SkewtSvg.render/1` to
|
||
draw the dashed parcel-ascent overlay.
|
||
|
||
Wind-derived indices (SRH, BWD, Bunkers motion, SCP, STP, SHIP) are
|
||
*not* produced because HRRR persists wind only at 10 m AGL — there
|
||
is no per-pressure-level wind to compute them from. Operators wanting
|
||
shear-based products should use NUCAPS / SPC mesoanalysis instead.
|
||
|
||
Inputs are the same canonical profile shape `SoundingParams.derive/1`
|
||
consumes: `[%{"pres", "hght", "tmpc", "dwpc"}, ...]` (string OR atom
|
||
keys; a `:hght_m` / `"hght_m"` alias for height is also accepted).
|
||
"""
|
||
|
||
@r_d 287.05
|
||
@r_v 461.5
|
||
@c_p 1004.0
|
||
@g 9.80665
|
||
@epsilon @r_d / @r_v
|
||
# Buck's saturation-vapor formula constants (kept in this module so
|
||
# we don't have to leak them out of SoundingParams).
|
||
@buck_a 6.1121
|
||
@buck_b 17.502
|
||
@buck_c 240.97
|
||
# Approximate L_v at 0 °C in J/kg. Tropospheric variation across
|
||
# parcel temperatures is ≤4 % which is well below CAPE bin noise.
|
||
@l_v 2.5e6
|
||
|
||
@nil_params %{
|
||
lcl_pressure_mb: nil,
|
||
lcl_temp_c: nil,
|
||
lcl_height_m: nil,
|
||
lfc_pressure_mb: nil,
|
||
lfc_height_m: nil,
|
||
el_pressure_mb: nil,
|
||
el_height_m: nil,
|
||
sbcape: nil,
|
||
sbcin: nil,
|
||
cape_3km: nil,
|
||
lifted_index: nil,
|
||
freezing_level_m: nil,
|
||
wbz_m: nil,
|
||
dcape: nil,
|
||
lapse_rate_sfc_3km_c_per_km: nil,
|
||
lapse_rate_700_500_c_per_km: nil,
|
||
lapse_rate_850_500_c_per_km: nil,
|
||
parcel_trace: []
|
||
}
|
||
|
||
@doc """
|
||
Returns the SPC-style parameter map for `profile`. See module
|
||
docstring for field-by-field semantics. Returns `@nil_params` for
|
||
empty / single-level inputs.
|
||
"""
|
||
@spec derive([map()]) :: map()
|
||
def derive(profile) when is_list(profile) do
|
||
levels = profile |> Enum.map(&normalize_level/1) |> Enum.reject(&is_nil/1)
|
||
|
||
case Enum.sort_by(levels, & &1.pres, :desc) do
|
||
[] -> @nil_params
|
||
[_only] -> @nil_params
|
||
[surface | _] = sorted -> compute(surface, sorted)
|
||
end
|
||
end
|
||
|
||
defp compute(surface, sorted_desc) do
|
||
{p_lcl, t_lcl} = lcl(surface.tmpc, surface.dwpc, surface.pres)
|
||
parcel_trace = parcel_trace(surface, p_lcl, t_lcl, sorted_desc)
|
||
{sbcape, sbcin} = cape_cin(parcel_trace, sorted_desc)
|
||
cape_3km = cape_layer(parcel_trace, sorted_desc, surface.hght, surface.hght + 3000.0)
|
||
{lfc_pressure, el_pressure} = lfc_el(parcel_trace, sorted_desc)
|
||
|
||
{lfc_height, el_height} =
|
||
{height_at_pressure(sorted_desc, lfc_pressure), height_at_pressure(sorted_desc, el_pressure)}
|
||
|
||
%{
|
||
lcl_pressure_mb: p_lcl,
|
||
lcl_temp_c: t_lcl,
|
||
lcl_height_m: height_at_pressure(sorted_desc, p_lcl),
|
||
lfc_pressure_mb: lfc_pressure,
|
||
lfc_height_m: lfc_height,
|
||
el_pressure_mb: el_pressure,
|
||
el_height_m: el_height,
|
||
sbcape: sbcape,
|
||
sbcin: sbcin,
|
||
cape_3km: cape_3km,
|
||
lifted_index: lifted_index(parcel_trace, sorted_desc),
|
||
freezing_level_m: temperature_height_crossing(sorted_desc, & &1.tmpc, 0.0),
|
||
wbz_m: temperature_height_crossing(sorted_desc, &wet_bulb_c/1, 0.0),
|
||
dcape: dcape(sorted_desc),
|
||
lapse_rate_sfc_3km_c_per_km: lapse_rate_height(sorted_desc, surface.hght, surface.hght + 3000.0),
|
||
lapse_rate_700_500_c_per_km: lapse_rate_pressures(sorted_desc, 700.0, 500.0),
|
||
lapse_rate_850_500_c_per_km: lapse_rate_pressures(sorted_desc, 850.0, 500.0),
|
||
parcel_trace: parcel_trace
|
||
}
|
||
end
|
||
|
||
# ── Lifting Condensation Level (Bolton 1980 eq 22) ──────────────────
|
||
|
||
@doc """
|
||
Lifting condensation level pressure and temperature.
|
||
|
||
Inputs:
|
||
* `temp_c` — surface temperature, °C
|
||
* `dewpoint_c` — surface dewpoint, °C
|
||
* `pressure_mb` — surface pressure, mb
|
||
|
||
Returns `{p_lcl_mb, t_lcl_c}`. Saturated air (T = T_d) returns the
|
||
surface pressure unchanged.
|
||
"""
|
||
@spec lcl(number(), number(), number()) :: {float(), float()}
|
||
def lcl(temp_c, dewpoint_c, pressure_mb) when is_number(temp_c) and is_number(dewpoint_c) and is_number(pressure_mb) do
|
||
t_k = temp_c + 273.15
|
||
td_k = dewpoint_c + 273.15
|
||
|
||
# Bolton (1980) eq 22: T at LCL.
|
||
t_lcl_k = 1.0 / (1.0 / (td_k - 56.0) + :math.log(t_k / td_k) / 800.0) + 56.0
|
||
|
||
# Poisson relation between two adiabatic levels.
|
||
p_lcl = pressure_mb * :math.pow(t_lcl_k / t_k, @c_p / @r_d)
|
||
|
||
{p_lcl, t_lcl_k - 273.15}
|
||
end
|
||
|
||
# ── Parcel ascent ───────────────────────────────────────────────────
|
||
|
||
# Returns a list of `%{pres, tmpc}` for the surface parcel, walking
|
||
# from surface pressure up through every environmental level above
|
||
# the LCL using a saturated-adiabatic step in pressure.
|
||
defp parcel_trace(surface, p_lcl, t_lcl, sorted_desc) do
|
||
surface_step = %{pres: surface.pres, tmpc: surface.tmpc}
|
||
lcl_step = %{pres: min(p_lcl, surface.pres), tmpc: t_lcl}
|
||
|
||
above_lcl =
|
||
sorted_desc
|
||
|> Enum.filter(&(&1.pres < p_lcl))
|
||
|> Enum.sort_by(& &1.pres, :desc)
|
||
|
||
{trace_above, _} =
|
||
Enum.map_reduce(above_lcl, lcl_step, fn level, prev ->
|
||
t_next = saturated_adiabatic_step(prev.tmpc, prev.pres, level.pres)
|
||
step = %{pres: level.pres, tmpc: t_next}
|
||
{step, step}
|
||
end)
|
||
|
||
Enum.uniq_by([surface_step, lcl_step | trace_above], & &1.pres)
|
||
end
|
||
|
||
# Integrate dT/d(ln p) = γ_sat × R_d × T / g across the pressure
|
||
# interval [p1, p2] in 5 mb steps using a forward-Euler scheme.
|
||
# Coarse but stable; the residual error is well below CAPE-bin noise.
|
||
defp saturated_adiabatic_step(t_c_start, p_start, p_end) do
|
||
n_steps = max(1, trunc((p_start - p_end) / 5.0))
|
||
dp = (p_end - p_start) / n_steps
|
||
|
||
1..n_steps
|
||
|> Enum.reduce({t_c_start, p_start}, fn _, {t_c, p} ->
|
||
t_k = t_c + 273.15
|
||
gamma_s = saturated_lapse_rate_per_pressure(t_k, p)
|
||
t_next_k = t_k + gamma_s * dp
|
||
{t_next_k - 273.15, p + dp}
|
||
end)
|
||
|> elem(0)
|
||
end
|
||
|
||
# γ_sat expressed as dT/dp (K/mb), derived from the standard moist
|
||
# adiabatic lapse rate dT/dz combined with hydrostatic dz/dp =
|
||
# -R_d × T_v / (g × p).
|
||
defp saturated_lapse_rate_per_pressure(t_k, p_mb) do
|
||
e_s = saturation_vapor_pressure_mb(t_k - 273.15)
|
||
r_s = @epsilon * e_s / max(p_mb - e_s, 1.0e-3)
|
||
|
||
num = 1.0 + @l_v * r_s / (@r_d * t_k)
|
||
den = @c_p + @l_v * @l_v * r_s * @epsilon / (@r_d * t_k * t_k)
|
||
gamma_z = @g * num / den
|
||
|
||
# dz/dp ≈ -R_d × T / (g × p) (mb)
|
||
dz_dp = -@r_d * t_k / (@g * p_mb)
|
||
-gamma_z * dz_dp
|
||
end
|
||
|
||
defp saturation_vapor_pressure_mb(t_c) do
|
||
@buck_a * :math.exp(@buck_b * t_c / (@buck_c + t_c))
|
||
end
|
||
|
||
# ── CAPE / CIN ──────────────────────────────────────────────────────
|
||
|
||
defp cape_cin(parcel_trace, sorted_desc) do
|
||
pairs = parcel_environment_pairs(parcel_trace, sorted_desc)
|
||
|
||
{cape, cin} =
|
||
Enum.reduce(layer_increments(pairs), {0.0, 0.0}, fn {avg_diff, dlnp}, {cape, cin} ->
|
||
delta = @r_d * avg_diff * dlnp
|
||
|
||
cond do
|
||
avg_diff > 0 -> {cape + delta, cin}
|
||
avg_diff < 0 -> {cape, cin + delta}
|
||
true -> {cape, cin}
|
||
end
|
||
end)
|
||
|
||
{round_j(cape), round_j(cin)}
|
||
end
|
||
|
||
defp cape_layer(_parcel, _profile, lo, hi) when not (is_number(lo) and is_number(hi)), do: nil
|
||
|
||
defp cape_layer(parcel_trace, sorted_desc, lo_height_m, hi_height_m) do
|
||
pairs =
|
||
parcel_trace
|
||
|> parcel_environment_pairs(sorted_desc)
|
||
|> Enum.filter(fn p -> p.height >= lo_height_m and p.height <= hi_height_m end)
|
||
|
||
cape =
|
||
pairs
|
||
|> layer_increments()
|
||
|> Enum.reduce(0.0, fn {avg_diff, dlnp}, acc ->
|
||
if avg_diff > 0, do: acc + @r_d * avg_diff * dlnp, else: acc
|
||
end)
|
||
|
||
round_j(cape)
|
||
end
|
||
|
||
# Build a list of {pressure, parcel_T_K, env_T_K, height} pairs at
|
||
# every environmental pressure level the parcel trace also covers.
|
||
defp parcel_environment_pairs(parcel_trace, sorted_desc) do
|
||
parcel_by_pres = Map.new(parcel_trace, &{&1.pres, &1.tmpc})
|
||
|
||
Enum.flat_map(sorted_desc, fn level ->
|
||
case Map.fetch(parcel_by_pres, level.pres) do
|
||
{:ok, parcel_t} ->
|
||
[
|
||
%{
|
||
pres: level.pres,
|
||
parcel_t_k: parcel_t + 273.15,
|
||
env_t_k: level.tmpc + 273.15,
|
||
diff: parcel_t - level.tmpc,
|
||
height: level.hght
|
||
}
|
||
]
|
||
|
||
:error ->
|
||
[]
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp layer_increments(pairs) do
|
||
pairs
|
||
|> Enum.sort_by(& &1.pres, :desc)
|
||
|> Enum.chunk_every(2, 1, :discard)
|
||
|> Enum.map(fn [a, b] ->
|
||
avg = (a.diff + b.diff) / 2.0
|
||
dlnp = :math.log(a.pres / b.pres)
|
||
{avg, dlnp}
|
||
end)
|
||
end
|
||
|
||
# ── LFC, EL ─────────────────────────────────────────────────────────
|
||
|
||
defp lfc_el(parcel_trace, sorted_desc) do
|
||
pairs =
|
||
parcel_trace
|
||
|> parcel_environment_pairs(sorted_desc)
|
||
|> Enum.sort_by(& &1.pres, :desc)
|
||
|
||
pos_pairs = Enum.filter(pairs, &(&1.diff > 0))
|
||
|
||
case pos_pairs do
|
||
[] ->
|
||
{nil, nil}
|
||
|
||
[first | _] ->
|
||
last = List.last(pos_pairs)
|
||
# LFC = bottom of the first contiguous positive-area region.
|
||
# EL = top of the last positive-area region. Coarse but
|
||
# matches what SPC publishes for single-parcel ascent.
|
||
{first.pres, last.pres}
|
||
end
|
||
end
|
||
|
||
# ── Lifted index ────────────────────────────────────────────────────
|
||
|
||
defp lifted_index(parcel_trace, sorted_desc) do
|
||
parcel_t = pressure_lookup_temp(parcel_trace, 500.0)
|
||
env_t = pressure_lookup_temp(sorted_desc, 500.0)
|
||
|
||
case {parcel_t, env_t} do
|
||
{nil, _} -> nil
|
||
{_, nil} -> nil
|
||
{pt, et} -> Float.round(et - pt, 1)
|
||
end
|
||
end
|
||
|
||
defp pressure_lookup_temp(levels, target_p) do
|
||
sorted = Enum.sort_by(levels, & &1.pres, :desc)
|
||
|
||
cond do
|
||
sorted == [] -> nil
|
||
target_p > List.first(sorted).pres -> List.first(sorted).tmpc
|
||
target_p < List.last(sorted).pres -> List.last(sorted).tmpc
|
||
true -> interp_temp_at_pressure(sorted, target_p)
|
||
end
|
||
end
|
||
|
||
defp interp_temp_at_pressure(sorted, target_p) do
|
||
sorted
|
||
|> Enum.chunk_every(2, 1, :discard)
|
||
|> Enum.find_value(fn [a, b] ->
|
||
if target_p <= a.pres and target_p >= b.pres do
|
||
f = (a.pres - target_p) / max(a.pres - b.pres, 1.0e-6)
|
||
a.tmpc + f * (b.tmpc - a.tmpc)
|
||
end
|
||
end)
|
||
end
|
||
|
||
# ── Freezing / wet-bulb-zero / DCAPE ────────────────────────────────
|
||
|
||
defp temperature_height_crossing(sorted_desc, fun, target_c) do
|
||
sorted_desc
|
||
|> Enum.chunk_every(2, 1, :discard)
|
||
|> Enum.find_value(fn [a, b] ->
|
||
ta = fun.(a)
|
||
tb = fun.(b)
|
||
|
||
cond do
|
||
ta == nil or tb == nil ->
|
||
nil
|
||
|
||
(ta - target_c) * (tb - target_c) > 0 ->
|
||
nil
|
||
|
||
ta == tb ->
|
||
a.hght
|
||
|
||
true ->
|
||
f = (ta - target_c) / (ta - tb)
|
||
a.hght + f * (b.hght - a.hght)
|
||
end
|
||
end)
|
||
end
|
||
|
||
@doc """
|
||
Wet-bulb temperature (°C) for a level using a Stipanuk-style
|
||
iterative bracket between dewpoint and dry bulb. Returns `nil` if
|
||
either temperature is missing.
|
||
|
||
Accepts a level map with `tmpc`, `dwpc`, and `pres` keys (the same
|
||
normalized shape `derive/1` consumes).
|
||
"""
|
||
@spec wet_bulb_c(map()) :: float() | nil
|
||
def wet_bulb_c(%{tmpc: t, dwpc: t_d, pres: _p}) when t == nil or t_d == nil, do: nil
|
||
|
||
def wet_bulb_c(%{tmpc: t, dwpc: t_d, pres: p}) do
|
||
bisect_wet_bulb(t, t_d, p)
|
||
end
|
||
|
||
def wet_bulb_c(_), do: nil
|
||
|
||
@doc """
|
||
Mixing ratio (kg/kg) at a given dewpoint and pressure, via the Buck
|
||
saturation-vapor formula evaluated at the dewpoint temperature.
|
||
Returns `nil` for non-numeric inputs.
|
||
"""
|
||
@spec mixing_ratio(number() | nil, number() | nil) :: float() | nil
|
||
def mixing_ratio(dewpoint_c, pressure_mb) when is_number(dewpoint_c) and is_number(pressure_mb) do
|
||
e = saturation_vapor_pressure_mb(dewpoint_c)
|
||
@epsilon * e / max(pressure_mb - e, 1.0e-3)
|
||
end
|
||
|
||
def mixing_ratio(_, _), do: nil
|
||
|
||
@doc """
|
||
Virtual temperature (°C) for a parcel/level. Adds the moisture
|
||
correction `(1 + 0.61 r)` to the dry-bulb temperature, where `r` is
|
||
the mixing ratio in kg/kg.
|
||
|
||
Two calling forms:
|
||
|
||
* `virtual_temp_c(temp_c, dewpoint_c, pressure_mb)` — environment
|
||
virtual temperature (uses the actual mixing ratio from the
|
||
dewpoint).
|
||
* `virtual_temp_c(temp_c, pressure_mb)` — parcel virtual temperature
|
||
assuming saturation (uses the saturation mixing ratio at the
|
||
parcel's own temperature). This is what CAPE uses above the LCL.
|
||
"""
|
||
@spec virtual_temp_c(number(), number(), number()) :: float()
|
||
def virtual_temp_c(temp_c, dewpoint_c, pressure_mb) do
|
||
r = mixing_ratio(dewpoint_c, pressure_mb) || 0.0
|
||
(temp_c + 273.15) * (1.0 + 0.61 * r) - 273.15
|
||
end
|
||
|
||
@spec virtual_temp_c(number(), number()) :: float()
|
||
def virtual_temp_c(temp_c, pressure_mb) do
|
||
virtual_temp_c(temp_c, temp_c, pressure_mb)
|
||
end
|
||
|
||
defp bisect_wet_bulb(t, t_d, _p) when t == t_d, do: t
|
||
|
||
defp bisect_wet_bulb(t, t_d, p) do
|
||
e = saturation_vapor_pressure_mb(t_d)
|
||
|
||
f = fn t_w ->
|
||
e_s = saturation_vapor_pressure_mb(t_w)
|
||
e_s - e - p * @c_p / (@epsilon * @l_v) * (t - t_w)
|
||
end
|
||
|
||
bisect(f, t_d, t, 30)
|
||
end
|
||
|
||
defp bisect(_f, lo, hi, 0), do: (lo + hi) / 2.0
|
||
|
||
defp bisect(f, lo, hi, iter) do
|
||
mid = (lo + hi) / 2.0
|
||
val_lo = f.(lo)
|
||
val_mid = f.(mid)
|
||
|
||
cond do
|
||
abs(val_mid) < 1.0e-3 -> mid
|
||
val_lo * val_mid < 0 -> bisect(f, lo, mid, iter - 1)
|
||
true -> bisect(f, mid, hi, iter - 1)
|
||
end
|
||
end
|
||
|
||
defp dcape(sorted_desc) do
|
||
# Pick the level with minimum θ_e in the layer 700-400 mb as the
|
||
# downdraft source. Saturate the parcel there, descend moist-
|
||
# adiabatically to the surface, and integrate negative buoyancy.
|
||
candidates =
|
||
Enum.filter(sorted_desc, fn l -> l.pres >= 400.0 and l.pres <= 700.0 end)
|
||
|
||
case candidates do
|
||
[] ->
|
||
nil
|
||
|
||
_ ->
|
||
source = Enum.min_by(candidates, &theta_e(&1.tmpc, &1.dwpc, &1.pres))
|
||
descend_dcape(source, sorted_desc)
|
||
end
|
||
end
|
||
|
||
defp descend_dcape(source, sorted_desc) do
|
||
descent_targets =
|
||
sorted_desc
|
||
|> Enum.filter(&(&1.pres >= source.pres))
|
||
|> Enum.sort_by(& &1.pres, :asc)
|
||
|
||
{traj, _} =
|
||
Enum.map_reduce(descent_targets, %{pres: source.pres, tmpc: source.dwpc}, fn lvl, prev ->
|
||
t_next = saturated_adiabatic_step(prev.tmpc, prev.pres, lvl.pres)
|
||
step = %{pres: lvl.pres, tmpc: t_next, env_t: lvl.tmpc, height: lvl.hght}
|
||
{step, %{pres: lvl.pres, tmpc: t_next}}
|
||
end)
|
||
|
||
integral =
|
||
traj
|
||
|> Enum.chunk_every(2, 1, :discard)
|
||
|> Enum.reduce(0.0, fn [a, b], acc ->
|
||
diff = (a.env_t - a.tmpc + b.env_t - b.tmpc) / 2.0
|
||
dlnp = :math.log(b.pres / a.pres)
|
||
|
||
if diff > 0, do: acc + @r_d * diff * dlnp, else: acc
|
||
end)
|
||
|
||
round_j(integral)
|
||
end
|
||
|
||
defp theta_e(t_c, td_c, p_mb) do
|
||
t_k = t_c + 273.15
|
||
e = saturation_vapor_pressure_mb(td_c)
|
||
r = @epsilon * e / max(p_mb - e, 1.0e-3)
|
||
theta = t_k * :math.pow(1000.0 / p_mb, @r_d / @c_p)
|
||
theta * :math.exp(@l_v * r / (@c_p * t_k))
|
||
end
|
||
|
||
# ── Lapse rates ─────────────────────────────────────────────────────
|
||
|
||
defp lapse_rate_height(sorted_desc, lo_m, hi_m) do
|
||
case {temp_at_height(sorted_desc, lo_m), temp_at_height(sorted_desc, hi_m)} do
|
||
{t_lo, t_hi} when is_number(t_lo) and is_number(t_hi) ->
|
||
Float.round((t_lo - t_hi) / ((hi_m - lo_m) / 1000.0), 2)
|
||
|
||
_ ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp lapse_rate_pressures(sorted_desc, p_lo, p_hi) do
|
||
t_lo = pressure_lookup_temp(sorted_desc, p_lo)
|
||
t_hi = pressure_lookup_temp(sorted_desc, p_hi)
|
||
h_lo = height_at_pressure(sorted_desc, p_lo)
|
||
h_hi = height_at_pressure(sorted_desc, p_hi)
|
||
|
||
cond do
|
||
not (is_number(t_lo) and is_number(t_hi)) -> nil
|
||
not (is_number(h_lo) and is_number(h_hi)) -> nil
|
||
h_hi == h_lo -> nil
|
||
true -> Float.round((t_lo - t_hi) / ((h_hi - h_lo) / 1000.0), 2)
|
||
end
|
||
end
|
||
|
||
defp temp_at_height(sorted_desc, target_m) do
|
||
sorted_desc
|
||
|> Enum.chunk_every(2, 1, :discard)
|
||
|> Enum.find_value(fn [a, b] ->
|
||
if a.hght <= target_m and target_m <= b.hght and b.hght != a.hght do
|
||
f = (target_m - a.hght) / (b.hght - a.hght)
|
||
a.tmpc + f * (b.tmpc - a.tmpc)
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp height_at_pressure(_sorted, nil), do: nil
|
||
|
||
defp height_at_pressure(sorted_desc, target_p) do
|
||
sorted_desc
|
||
|> Enum.chunk_every(2, 1, :discard)
|
||
|> Enum.find_value(fn [a, b] ->
|
||
if target_p <= a.pres and target_p >= b.pres and a.pres != b.pres do
|
||
f = (a.pres - target_p) / (a.pres - b.pres)
|
||
a.hght + f * (b.hght - a.hght)
|
||
end
|
||
end)
|
||
end
|
||
|
||
# ── Profile shape normaliser ────────────────────────────────────────
|
||
|
||
defp normalize_level(level) when is_map(level) do
|
||
pres = number(get_any(level, ["pres", "pres_mb", :pres, :pres_mb]))
|
||
hght = number(get_any(level, ["hght", "hght_m", :hght, :hght_m]))
|
||
tmpc = number(get_any(level, ["tmpc", :tmpc]))
|
||
dwpc = number(get_any(level, ["dwpc", :dwpc]))
|
||
|
||
if is_number(pres) and is_number(hght) and is_number(tmpc) do
|
||
%{pres: pres, hght: hght, tmpc: tmpc, dwpc: dwpc}
|
||
end
|
||
end
|
||
|
||
defp normalize_level(_), do: nil
|
||
|
||
defp get_any(_map, []), do: nil
|
||
|
||
defp get_any(map, [key | rest]) do
|
||
case Map.fetch(map, key) do
|
||
{:ok, v} -> v
|
||
:error -> get_any(map, rest)
|
||
end
|
||
end
|
||
|
||
defp number(nil), do: nil
|
||
defp number(x) when is_integer(x), do: x * 1.0
|
||
defp number(x) when is_float(x), do: x
|
||
defp number(_), do: nil
|
||
|
||
defp round_j(nil), do: nil
|
||
defp round_j(v) when is_number(v), do: Float.round(v * 1.0, 1)
|
||
end
|