prop/lib/microwaveprop/weather/sounding_params.ex
Graham McIntire 51e390959c Add dialyzer specs and types across the codebase
277 @spec/@type annotations added to 58 files covering all public
APIs: contexts (propagation, radio, weather, terrain, beacons,
commercial), GRIB2 decoders, terrain analysis, duct detection,
rain scatter, CSV/ADIF import, weather clients, and all Ecto
schemas. Dialyzer passes with 0 errors.
2026-04-12 08:55:04 -05:00

269 lines
7.3 KiB
Elixir

defmodule Microwaveprop.Weather.SoundingParams do
@moduledoc false
@doc "Buck equation for saturation vapor pressure (hPa) given temperature in °C."
@spec sat_vap_pres(float()) :: float()
def sat_vap_pres(t_c) do
6.1121 * :math.exp((18.678 - t_c / 234.5) * (t_c / (257.14 + t_c)))
end
@doc "Mixing ratio (g/kg) from dewpoint (°C) and pressure (hPa)."
@spec mixing_ratio(float(), float()) :: float()
def mixing_ratio(t_c, p_mb) do
e = sat_vap_pres(t_c)
622.0 * e / (p_mb - e)
end
@doc """
Derive atmospheric propagation parameters from a sounding profile.
Profile is a list of maps with keys: "pres", "hght", "tmpc", "dwpc", "drct", "sknt".
Returns nil if fewer than 3 valid levels.
"""
@spec derive([map()]) :: map() | nil
def derive(profile) when is_list(profile) do
sorted =
profile
|> Enum.filter(fn p -> p["pres"] != nil and p["tmpc"] != nil and p["hght"] != nil end)
|> Enum.sort_by(fn p -> p["pres"] end, :desc)
if length(sorted) < 3 do
nil
else
do_derive(sorted)
end
end
defp do_derive(sorted) do
sfc = hd(sorted)
sfc_hght = sfc["hght"]
refract_profile = compute_refractivity_profile(sorted, sfc_hght)
dn_dh = compute_gradients(refract_profile)
inversions = detect_inversions(sorted, sfc_hght)
ducts = detect_ducts(refract_profile)
k_index = compute_k_index(sorted)
li = compute_lifted_index(sorted, sfc)
pw = compute_precipitable_water(sorted)
bl_depth = compute_boundary_layer_depth(sorted, sfc)
min_grad = find_min_gradient(dn_dh)
sfc_n =
case refract_profile do
[first | _] -> first.n
_ -> nil
end
%{
level_count: length(sorted),
surface_pressure_mb: sfc["pres"],
surface_temp_c: sfc["tmpc"],
surface_dewpoint_c: sfc["dwpc"] || sfc["tmpc"] - 10,
surface_refractivity: sfc_n,
min_refractivity_gradient: min_grad,
boundary_layer_depth_m: bl_depth,
precipitable_water_mm: pw,
k_index: k_index,
lifted_index: li,
ducting_detected: ducts != [],
ducts: ducts,
duct_characteristics: if(ducts == [], do: nil, else: ducts),
inversions: inversions,
profile: sorted
}
end
defp compute_refractivity_profile(sorted, sfc_hght) do
sorted
|> Enum.filter(fn p -> p["dwpc"] != nil end)
|> Enum.map(fn p ->
t_k = p["tmpc"] + 273.15
e = sat_vap_pres(p["dwpc"])
n = 77.6 * p["pres"] / t_k + 3.73e5 * e / (t_k * t_k)
h_agl = p["hght"] - sfc_hght
m = n + 0.157 * h_agl
%{
pres: p["pres"],
h_agl: h_agl,
h_km: h_agl / 1000.0,
tmpc: p["tmpc"],
dwpc: p["dwpc"],
n: n,
m: m
}
end)
end
defp compute_gradients(refract_profile) do
refract_profile
|> Enum.chunk_every(2, 1, :discard)
|> Enum.flat_map(fn [prev, curr] ->
dh_km = (curr.h_agl - prev.h_agl) / 1000.0
if abs(dh_km) < 0.01 do
[]
else
dn = curr.n - prev.n
[%{h_agl: (curr.h_agl + prev.h_agl) / 2.0, gradient: dn / dh_km}]
end
end)
end
defp detect_inversions(sorted, sfc_hght) do
raw_inversions =
sorted
|> Enum.chunk_every(2, 1, :discard)
|> Enum.flat_map(fn [lower, upper] ->
if upper["tmpc"] > lower["tmpc"] do
base = lower["hght"] - sfc_hght
top = upper["hght"] - sfc_hght
strength = upper["tmpc"] - lower["tmpc"]
dh_100m = (top - base) / 100.0
rate = if dh_100m > 0, do: strength / dh_100m, else: 0.0
[%{base: base, top: top, strength: strength, rate: rate}]
else
[]
end
end)
# Merge adjacent layers within 200m gap
merged =
raw_inversions
|> Enum.reduce([], fn inv, acc ->
case acc do
[] ->
[inv]
[last | rest] ->
if inv.base <= last.top + 200 do
merged_inv = %{last | top: inv.top, strength: last.strength + inv.strength}
[merged_inv | rest]
else
[inv | acc]
end
end
end)
|> Enum.reverse()
# Keep only meaningful inversions: strength >= 0.5°C AND base below 5000m AGL
Enum.filter(merged, fn inv -> inv.strength >= 0.5 and inv.base < 5000 end)
end
defp detect_ducts(refract_profile) do
{ducts, in_duct_state} =
refract_profile
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce({[], nil}, fn [prev, curr], {ducts, duct_state} ->
dm = curr.m - prev.m
case {dm < 0, duct_state} do
{true, nil} ->
# Entering a duct
{ducts, %{base: prev.h_agl, base_m: prev.m}}
{false, %{base: base, base_m: base_m}} ->
# Exiting a duct
top = prev.h_agl
strength = base_m - prev.m
if strength > 2 do
duct = %{"base" => base, "top" => top, "strength" => Float.round(strength, 1)}
{[duct | ducts], nil}
else
{ducts, nil}
end
_ ->
{ducts, duct_state}
end
end)
# Handle case where profile ends while still in a duct
ducts =
case in_duct_state do
%{base: _base, base_m: _base_m} ->
# Duct didn't close — discard (no clear top)
ducts
nil ->
ducts
end
Enum.reverse(ducts)
end
defp compute_k_index(sorted) do
p850 = nearest_level(sorted, 850)
p700 = nearest_level(sorted, 700)
p500 = nearest_level(sorted, 500)
with %{"tmpc" => t850} when t850 != nil <- p850,
%{"tmpc" => t700} when t700 != nil <- p700,
%{"tmpc" => t500} when t500 != nil <- p500 do
td850 = p850["dwpc"] || -30.0
td700 = p700["dwpc"] || -30.0
t850 - t500 + td850 - (t700 - td700)
else
_ -> nil
end
end
defp compute_lifted_index(sorted, sfc) do
p500 = nearest_level(sorted, 500)
case p500 do
%{"tmpc" => t500, "hght" => h500} when t500 != nil and h500 != nil ->
sfc_tmpc = sfc["tmpc"]
sfc_hght = sfc["hght"]
# Simplified LI: T500 - (Tsfc - lapse_rate * height_diff)
t500 - (sfc_tmpc - (h500 - sfc_hght) * 0.00976)
_ ->
nil
end
end
defp compute_precipitable_water(sorted) do
sorted
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce(0.0, fn [lower, upper], pw ->
if lower["dwpc"] != nil and upper["dwpc"] != nil do
mr1 = mixing_ratio(lower["dwpc"], lower["pres"])
mr2 = mixing_ratio(upper["dwpc"], upper["pres"])
dp = lower["pres"] - upper["pres"]
pw + (mr1 + mr2) / 2.0 * dp / 9.81 / 10.0
else
pw
end
end)
end
defp compute_boundary_layer_depth(sorted, sfc) do
sfc_hght = sfc["hght"]
sfc_tmpc = sfc["tmpc"]
theta_sfc = sfc_tmpc + 273.15 + 9.8 * (sfc_hght / 1000.0)
sorted
|> tl()
|> Enum.find_value(fn p ->
theta = p["tmpc"] + 273.15 + 9.8 * (p["hght"] / 1000.0)
if theta > theta_sfc + 2 do
p["hght"] - sfc_hght
end
end)
end
defp find_min_gradient([]), do: nil
defp find_min_gradient(dn_dh) do
dn_dh
|> Enum.min_by(fn %{gradient: g} -> g end)
|> Map.get(:gradient)
end
defp nearest_level(sorted, target_pres) do
Enum.min_by(sorted, fn p -> abs(p["pres"] - target_pres) end)
end
end