Store surface observations (ASOS) and upper-air soundings (RAOB) alongside QSOs for atmospheric propagation correlation. Three new tables: weather_stations, surface_observations, and soundings with JSONB profiles and pre-computed derived parameters (refractivity, gradients, duct detection, stability indices). Includes IEM API client for historical data import and import script seeded with 95 ASOS + 9 sounding stations from PropCast coverage area.
266 lines
7.2 KiB
Elixir
266 lines
7.2 KiB
Elixir
defmodule Microwaveprop.Weather.SoundingParams do
|
|
@moduledoc false
|
|
|
|
@doc "Buck equation for saturation vapor pressure (hPa) given temperature in °C."
|
|
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)."
|
|
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.
|
|
"""
|
|
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
|