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