defmodule Microwaveprop.Propagation.BandConfig do @moduledoc """ Data-driven band configuration for the propagation scoring algorithm. This is the single source of truth for all scoring parameters: weights, thresholds, seasonal tables, and band-specific coefficients. When the algorithm is tuned or new bands are added, only this module changes. """ alias Microwaveprop.Propagation.BandWeights # Default weight vector. Originally fit by gradient descent on 5,000 QSOs # 2026-04-11 (loss 0.42 → 0.12) and retained as the 10 GHz prior. Bands # with ≥200 HRRR-matched contacts in the 2026-04-18 full-corpus # correlation analysis carry per-band `:weights` overrides in # `@band_configs` — see `algo.md` Part 2d. Bands without enough data # (50/144 MHz with 0 contacts, 47+ GHz with <200 matches) inherit this # vector. @weights %{ humidity: 0.1262, time_of_day: 0.0380, td_depression: 0.1010, refractivity: 0.0986, sky: 0.0841, season: 0.1134, wind: 0.0841, rain: 0.1431, pressure: 0.0967, pwat: 0.1147 } @sunrise_table [7.4, 7.3, 7.0, 6.7, 6.35, 6.25, 6.35, 6.65, 6.9, 7.1, 7.35, 7.45] @tiers [ %{min_score: 80, label: "EXCELLENT", color: "#059669"}, %{min_score: 65, label: "GOOD", color: "#0d9488"}, %{min_score: 50, label: "MARGINAL", color: "#ca8a04"}, %{min_score: 33, label: "POOR", color: "#ea580c"}, %{min_score: 0, label: "NEGLIGIBLE", color: "#dc2626"} ] @humidity_beneficial_thresholds [{4, 55}, {7, 70}, {10, 82}, {14, 90}, {18, 95}, {22, 88}] @humidity_beneficial_default 75 # Thresholds calibrated for HRRR-derived gradients (coarser than sounding data). # HRRR percentiles: p1=-230, p5=-162, p10=-130, p25=-94, p50=-70, p75=-53, p95=-40 @refractivity_thresholds [ {-200, 98, 85}, {-150, 92, 80}, {-100, 82, 72}, {-75, 68, 62}, {-55, 55, 55}, {-40, 48, 48} ] @refractivity_default {42, 42} @band_configs %{ # VHF / UHF bands share the tropo-ducting physics of low microwave # (humidity and refractivity gradient build ducts the same way), # so rain attenuation and gas absorption are effectively zero and # humidity is :beneficial. The difference from 902+ is mostly in # the achievable range — a 2m duct can reach 2500+ km under # exceptional conditions (e.g. documented Hawaii ↔ California # openings). These do NOT capture sporadic-E, F2, or meteor scatter, # which need ionospheric data we don't yet ingest. 6m is especially # affected — most 6m DX is via Es / F2, not tropo. 50 => %{ freq_mhz: 50, label: "50 MHz", o2_db_km: 0.0, h2o_coeff: 0.0, humidity_effect: :beneficial, humidity_penalty: 0.0, rain_k: 0.0, rain_alpha: 1.0, seasonal_base: %{ 1 => 30, 2 => 32, 3 => 25, 4 => 50, 5 => 70, 6 => 95, 7 => 95, 8 => 70, 9 => 65, 10 => 70, 11 => 60, 12 => 25 }, seasonal_adj: %{}, typical_range_km: 400, extended_range_km: 1500, exceptional_range_km: 4000 }, 144 => %{ freq_mhz: 144, label: "144 MHz", o2_db_km: 0.0, h2o_coeff: 0.0, humidity_effect: :beneficial, humidity_penalty: 0.0, rain_k: 0.0, rain_alpha: 1.0, seasonal_base: %{ 1 => 38, 2 => 40, 3 => 22, 4 => 55, 5 => 68, 6 => 90, 7 => 95, 8 => 75, 9 => 78, 10 => 88, 11 => 78, 12 => 25 }, seasonal_adj: %{}, typical_range_km: 350, extended_range_km: 900, exceptional_range_km: 2500 }, 222 => %{ freq_mhz: 222, label: "222 MHz", o2_db_km: 0.0, h2o_coeff: 0.0, humidity_effect: :beneficial, humidity_penalty: 0.0, rain_k: 0.0, rain_alpha: 1.0, # Per-band weights from 2026-04-18 full-corpus correlation (n=5,392 # matched). Moisture (pwat, humidity) dominate; rain and time-of-day # suppressed vs the 10 GHz baseline. weights: %{ humidity: 0.1593, time_of_day: 0.0350, td_depression: 0.1250, refractivity: 0.1401, sky: 0.0706, season: 0.1276, wind: 0.0706, rain: 0.0120, pwat: 0.1916, pressure: 0.0681 }, seasonal_base: %{ 1 => 38, 2 => 40, 3 => 22, 4 => 55, 5 => 68, 6 => 90, 7 => 95, 8 => 75, 9 => 78, 10 => 88, 11 => 78, 12 => 25 }, seasonal_adj: %{}, typical_range_km: 320, extended_range_km: 800, exceptional_range_km: 2000 }, 432 => %{ freq_mhz: 432, label: "432 MHz", o2_db_km: 0.0, h2o_coeff: 0.0, humidity_effect: :beneficial, humidity_penalty: 0.0, rain_k: 0.0, rain_alpha: 1.0, # Per-band weights from 2026-04-18 full-corpus correlation (n=6,583). weights: %{ humidity: 0.2061, time_of_day: 0.0329, td_depression: 0.1200, refractivity: 0.1186, sky: 0.0663, season: 0.1106, wind: 0.0663, rain: 0.0113, pwat: 0.1870, pressure: 0.0809 }, seasonal_base: %{ 1 => 38, 2 => 40, 3 => 22, 4 => 55, 5 => 68, 6 => 90, 7 => 95, 8 => 75, 9 => 78, 10 => 88, 11 => 78, 12 => 25 }, seasonal_adj: %{}, typical_range_km: 300, extended_range_km: 700, exceptional_range_km: 1800 }, 902 => %{ freq_mhz: 902, label: "902 MHz", o2_db_km: 0.006, h2o_coeff: 0.0, humidity_effect: :beneficial, humidity_penalty: 0.0, rain_k: 0.000, rain_alpha: 1.0, # Per-band weights from 2026-04-18 full-corpus correlation (n=1,317). weights: %{ humidity: 0.2201, time_of_day: 0.0437, td_depression: 0.1102, refractivity: 0.0888, sky: 0.0783, season: 0.1197, wind: 0.0783, rain: 0.0133, pwat: 0.1635, pressure: 0.0839 }, seasonal_base: %{ 1 => 38, 2 => 40, 3 => 22, 4 => 55, 5 => 68, 6 => 90, 7 => 95, 8 => 75, 9 => 78, 10 => 88, 11 => 78, 12 => 25 }, seasonal_adj: %{}, typical_range_km: 400, extended_range_km: 800, exceptional_range_km: 1500 }, 1_296 => %{ freq_mhz: 1_296, label: "1296 MHz", o2_db_km: 0.006, h2o_coeff: 0.0, humidity_effect: :beneficial, humidity_penalty: 0.0, rain_k: 0.000, rain_alpha: 1.0, # Per-band weights from 2026-04-18 full-corpus correlation (n=2,146). weights: %{ humidity: 0.2131, time_of_day: 0.0494, td_depression: 0.0898, refractivity: 0.1392, sky: 0.0797, season: 0.1108, wind: 0.0797, rain: 0.0136, pwat: 0.1678, pressure: 0.0568 }, seasonal_base: %{ 1 => 38, 2 => 40, 3 => 22, 4 => 55, 5 => 68, 6 => 90, 7 => 95, 8 => 75, 9 => 78, 10 => 88, 11 => 78, 12 => 25 }, seasonal_adj: %{}, typical_range_km: 350, extended_range_km: 700, exceptional_range_km: 1200 }, 2_304 => %{ freq_mhz: 2_304, label: "2304 MHz", o2_db_km: 0.006, h2o_coeff: 0.0, humidity_effect: :beneficial, humidity_penalty: 0.0, rain_k: 0.001, rain_alpha: 1.15, # Per-band weights from 2026-04-18 full-corpus correlation (n=564). weights: %{ humidity: 0.1941, time_of_day: 0.0387, td_depression: 0.1385, refractivity: 0.1638, sky: 0.0625, season: 0.0868, wind: 0.0625, rain: 0.0336, pwat: 0.1762, pressure: 0.0432 }, seasonal_base: %{ 1 => 38, 2 => 40, 3 => 22, 4 => 55, 5 => 68, 6 => 90, 7 => 95, 8 => 75, 9 => 78, 10 => 88, 11 => 78, 12 => 25 }, seasonal_adj: %{}, typical_range_km: 300, extended_range_km: 600, exceptional_range_km: 1000 }, 3_400 => %{ freq_mhz: 3_400, label: "3400 MHz", o2_db_km: 0.006, h2o_coeff: 0.0, humidity_effect: :beneficial, humidity_penalty: 0.0, rain_k: 0.002, rain_alpha: 1.20, # Per-band weights from 2026-04-18 full-corpus correlation (n=280). weights: %{ humidity: 0.1996, time_of_day: 0.0398, td_depression: 0.1251, refractivity: 0.1310, sky: 0.0642, season: 0.0893, wind: 0.0642, rain: 0.0489, pwat: 0.1811, pressure: 0.0568 }, seasonal_base: %{ 1 => 38, 2 => 40, 3 => 22, 4 => 55, 5 => 68, 6 => 90, 7 => 95, 8 => 75, 9 => 78, 10 => 88, 11 => 78, 12 => 25 }, seasonal_adj: %{}, typical_range_km: 250, extended_range_km: 550, exceptional_range_km: 900 }, 5_760 => %{ freq_mhz: 5_760, label: "5760 MHz", o2_db_km: 0.007, h2o_coeff: 0.0, humidity_effect: :beneficial, humidity_penalty: 0.0, rain_k: 0.005, rain_alpha: 1.25, # Per-band weights from 2026-04-18 full-corpus correlation (n=246). weights: %{ humidity: 0.1829, time_of_day: 0.0423, td_depression: 0.1205, refractivity: 0.0881, sky: 0.0683, season: 0.0949, wind: 0.0683, rain: 0.0822, pwat: 0.1925, pressure: 0.0602 }, seasonal_base: %{ 1 => 38, 2 => 40, 3 => 22, 4 => 55, 5 => 68, 6 => 90, 7 => 95, 8 => 75, 9 => 78, 10 => 88, 11 => 78, 12 => 25 }, seasonal_adj: %{}, typical_range_km: 220, extended_range_km: 500, exceptional_range_km: 1000 }, 10_000 => %{ freq_mhz: 10_000, label: "10 GHz", o2_db_km: 0.007, h2o_coeff: 0.0, humidity_effect: :beneficial, humidity_penalty: 0.0, rain_k: 0.010, rain_alpha: 1.28, seasonal_base: %{ 1 => 38, 2 => 40, 3 => 22, 4 => 55, 5 => 68, 6 => 90, 7 => 95, 8 => 75, 9 => 78, 10 => 88, 11 => 78, 12 => 25 }, seasonal_adj: %{}, typical_range_km: 200, extended_range_km: 500, exceptional_range_km: 1000 }, 24_000 => %{ freq_mhz: 24_000, label: "24 GHz", o2_db_km: 0.02, h2o_coeff: 0.002, humidity_effect: :harmful, humidity_penalty: 1.6, rain_k: 0.070, rain_alpha: 1.07, # Per-band weights from 2026-04-18 full-corpus correlation (n=613). # Rain climbs sharply (sqrt-dampened rain_k ratio = √7 ≈ 2.6× vs 10 G), # refractivity and pwat hold up, td_depression falls out because the # temperature signal alone is near-zero — moisture already carries it # via :harmful humidity. weights: %{ humidity: 0.1481, time_of_day: 0.0532, td_depression: 0.0360, refractivity: 0.1250, sky: 0.0477, season: 0.0729, wind: 0.0477, rain: 0.2147, pwat: 0.1344, pressure: 0.1203 }, seasonal_base: %{ 1 => 88, 2 => 84, 3 => 68, 4 => 62, 5 => 51, 6 => 34, 7 => 18, 8 => 18, 9 => 48, 10 => 68, 11 => 96, 12 => 88 }, seasonal_adj: %{5 => -4, 6 => -8, 7 => -10, 8 => -10, 9 => -4}, typical_range_km: 100, extended_range_km: 250, exceptional_range_km: 500 }, 47_000 => %{ freq_mhz: 47_000, label: "47 GHz", o2_db_km: 0.04, h2o_coeff: 0.003, humidity_effect: :harmful, humidity_penalty: 1.0, rain_k: 0.187, rain_alpha: 0.93, seasonal_base: %{ 1 => 90, 2 => 88, 3 => 78, 4 => 68, 5 => 55, 6 => 38, 7 => 22, 8 => 22, 9 => 48, 10 => 74, 11 => 96, 12 => 90 }, seasonal_adj: %{}, typical_range_km: 70, extended_range_km: 150, exceptional_range_km: 300 }, 68_000 => %{ freq_mhz: 68_000, label: "68 GHz", o2_db_km: 0.90, h2o_coeff: 0.007, humidity_effect: :harmful, humidity_penalty: 1.4, rain_k: 0.310, rain_alpha: 0.86, seasonal_base: %{ 1 => 90, 2 => 88, 3 => 78, 4 => 65, 5 => 50, 6 => 32, 7 => 18, 8 => 18, 9 => 44, 10 => 70, 11 => 92, 12 => 90 }, seasonal_adj: %{}, typical_range_km: 40, extended_range_km: 80, exceptional_range_km: 150 }, 75_000 => %{ freq_mhz: 75_000, label: "75 GHz", o2_db_km: 0.012, h2o_coeff: 0.006, humidity_effect: :harmful, humidity_penalty: 1.2, rain_k: 0.345, rain_alpha: 0.84, seasonal_base: %{ 1 => 90, 2 => 90, 3 => 80, 4 => 68, 5 => 55, 6 => 38, 7 => 22, 8 => 22, 9 => 48, 10 => 74, 11 => 96, 12 => 90 }, seasonal_adj: %{}, typical_range_km: 50, extended_range_km: 120, exceptional_range_km: 250 }, 122_000 => %{ freq_mhz: 122_000, label: "122 GHz", o2_db_km: 0.80, h2o_coeff: 0.010, humidity_effect: :harmful, humidity_penalty: 1.0, rain_k: 0.498, rain_alpha: 0.77, seasonal_base: %{ 1 => 92, 2 => 90, 3 => 78, 4 => 62, 5 => 45, 6 => 28, 7 => 15, 8 => 15, 9 => 38, 10 => 68, 11 => 92, 12 => 92 }, seasonal_adj: %{}, typical_range_km: 30, extended_range_km: 80, exceptional_range_km: 140 }, 134_000 => %{ freq_mhz: 134_000, label: "134 GHz", o2_db_km: 0.08, h2o_coeff: 0.015, humidity_effect: :harmful, humidity_penalty: 1.3, rain_k: 0.520, rain_alpha: 0.75, seasonal_base: %{ 1 => 92, 2 => 90, 3 => 78, 4 => 65, 5 => 48, 6 => 30, 7 => 18, 8 => 18, 9 => 42, 10 => 70, 11 => 92, 12 => 92 }, seasonal_adj: %{}, typical_range_km: 40, extended_range_km: 100, exceptional_range_km: 160 }, # 142–145 GHz — between the 118 GHz O2 line and the 183 GHz H2O line. # Coefficients linearly interpolate between 134 GHz and 241 GHz; rain # tracks ITU-R P.838 in the K-band-extrapolated regime. Ranges scaled # from the handful of contacts in the corpus (n=4 at 142 GHz, n=2 at # 145 GHz) — these are scaffolding values and will need calibration as # the band sees more activity. 142_000 => %{ freq_mhz: 142_000, label: "142 GHz", o2_db_km: 0.05, h2o_coeff: 0.025, humidity_effect: :harmful, humidity_penalty: 1.4, rain_k: 0.530, rain_alpha: 0.74, seasonal_base: %{ 1 => 92, 2 => 90, 3 => 78, 4 => 65, 5 => 47, 6 => 28, 7 => 16, 8 => 16, 9 => 40, 10 => 68, 11 => 92, 12 => 92 }, seasonal_adj: %{}, typical_range_km: 35, extended_range_km: 80, exceptional_range_km: 160 }, 145_000 => %{ freq_mhz: 145_000, label: "145 GHz", o2_db_km: 0.06, h2o_coeff: 0.040, humidity_effect: :harmful, humidity_penalty: 1.5, rain_k: 0.535, rain_alpha: 0.74, seasonal_base: %{ 1 => 92, 2 => 90, 3 => 78, 4 => 64, 5 => 46, 6 => 27, 7 => 15, 8 => 15, 9 => 38, 10 => 66, 11 => 92, 12 => 92 }, seasonal_adj: %{}, typical_range_km: 35, extended_range_km: 80, exceptional_range_km: 150 }, 241_000 => %{ freq_mhz: 241_000, label: "241 GHz", o2_db_km: 0.08, h2o_coeff: 0.30, humidity_effect: :harmful, humidity_penalty: 3.0, rain_k: 0.550, rain_alpha: 0.70, seasonal_base: %{ 1 => 95, 2 => 92, 3 => 75, 4 => 55, 5 => 35, 6 => 15, 7 => 8, 8 => 8, 9 => 30, 10 => 65, 11 => 95, 12 => 95 }, seasonal_adj: %{}, typical_range_km: 10, extended_range_km: 50, exceptional_range_km: 115 }, # Sub-mm bands (288–411 GHz) — extrapolated scaffolding so the scoring # pipeline doesn't silently drop these contacts. The prod corpus has 1 # contact per band, all under 1.5 km (effectively LOS), so the absorption # values dominate any duct-related score. Coefficients track ITU-R P.676/ # 838 trends past the 325 GHz H2O line; ranges are nominal. 288_000 => %{ freq_mhz: 288_000, label: "288 GHz", o2_db_km: 0.10, h2o_coeff: 0.45, humidity_effect: :harmful, humidity_penalty: 3.5, rain_k: 0.560, rain_alpha: 0.68, seasonal_base: %{ 1 => 95, 2 => 92, 3 => 75, 4 => 55, 5 => 35, 6 => 14, 7 => 7, 8 => 7, 9 => 28, 10 => 64, 11 => 95, 12 => 95 }, seasonal_adj: %{}, typical_range_km: 5, extended_range_km: 15, exceptional_range_km: 50 }, 322_000 => %{ freq_mhz: 322_000, label: "322 GHz", o2_db_km: 0.12, h2o_coeff: 0.55, humidity_effect: :harmful, humidity_penalty: 4.0, rain_k: 0.570, rain_alpha: 0.66, seasonal_base: %{ 1 => 96, 2 => 92, 3 => 74, 4 => 52, 5 => 32, 6 => 12, 7 => 6, 8 => 6, 9 => 26, 10 => 62, 11 => 96, 12 => 96 }, seasonal_adj: %{}, typical_range_km: 5, extended_range_km: 15, exceptional_range_km: 40 }, 403_000 => %{ freq_mhz: 403_000, label: "403 GHz", o2_db_km: 0.15, h2o_coeff: 0.40, humidity_effect: :harmful, humidity_penalty: 3.0, rain_k: 0.580, rain_alpha: 0.64, seasonal_base: %{ 1 => 96, 2 => 92, 3 => 74, 4 => 52, 5 => 32, 6 => 12, 7 => 6, 8 => 6, 9 => 26, 10 => 62, 11 => 96, 12 => 96 }, seasonal_adj: %{}, typical_range_km: 3, extended_range_km: 10, exceptional_range_km: 30 }, 411_000 => %{ freq_mhz: 411_000, label: "411 GHz", o2_db_km: 0.15, h2o_coeff: 0.42, humidity_effect: :harmful, humidity_penalty: 3.2, rain_k: 0.580, rain_alpha: 0.64, seasonal_base: %{ 1 => 96, 2 => 92, 3 => 74, 4 => 52, 5 => 32, 6 => 12, 7 => 6, 8 => 6, 9 => 26, 10 => 62, 11 => 96, 12 => 96 }, seasonal_adj: %{}, typical_range_km: 3, extended_range_km: 10, exceptional_range_km: 30 } } @doc "Returns the band config for the given frequency in MHz, or nil if not found." @spec get(integer()) :: map() | nil def get(freq_mhz), do: Map.get(@band_configs, freq_mhz) @doc "Returns all band configs sorted by frequency (ascending)." @spec all_bands() :: [map()] def all_bands do @band_configs |> Enum.sort_by(fn {freq, _config} -> freq end) |> Enum.map(fn {_freq, config} -> config end) end @doc "Returns all supported frequencies sorted ascending." @spec all_freqs() :: [integer()] def all_freqs do @band_configs |> Map.keys() |> Enum.sort() end @doc "Returns band options as {label, value_string} tuples for form dropdowns." @spec band_options() :: [{String.t(), String.t()}] def band_options do Enum.map(all_bands(), fn band -> {band.label, to_string(band.freq_mhz)} end) end @doc """ Human-readable label for a band's `:humidity_effect`. Accepts the band config map or any map with a `:humidity_effect` key; falls back to `"neutral"` for missing/unrecognized values so templates can render unconditionally. """ @spec humidity_effect_label(map() | nil) :: String.t() def humidity_effect_label(%{humidity_effect: :beneficial}), do: "beneficial" def humidity_effect_label(%{humidity_effect: :harmful}), do: "harmful" def humidity_effect_label(_), do: "neutral" @doc "Returns the default scoring weights map. All 10 values sum to 1.0." @spec weights() :: map() def weights, do: @weights @doc """ Returns the scoring weights for a specific band configuration. Resolution order: 1. `priv/algo/band_weights.json` via `BandWeights.lookup/1` — authoritative per-band fits produced by `scripts/recalibrate.py`. 2. In-source `:weights` override on the band config map — legacy snapshot of the 2026-04-18 fit, retained as a fallback. 3. Global `@weights` default. `nil` is accepted so callers that don't know the band can ask for defaults without an extra guard. """ @spec weights(map() | nil) :: map() def weights(nil), do: @weights def weights(%{freq_mhz: freq_mhz} = band_config) when is_integer(freq_mhz) do case BandWeights.lookup(freq_mhz) do nil -> in_source_or_default(band_config) json_override -> json_override end end def weights(%{weights: override}) when is_map(override), do: override def weights(%{}), do: @weights defp in_source_or_default(%{weights: override}) when is_map(override), do: override defp in_source_or_default(_), do: @weights @doc "Returns the 12-element sunrise hour table (Jan-Dec, local time)." @spec sunrise_table() :: [float()] def sunrise_table, do: @sunrise_table @doc "Returns score tier definitions ordered by threshold descending." @spec tiers() :: [map()] def tiers, do: @tiers @doc "Returns humidity beneficial thresholds as {hour, score} tuples." @spec humidity_beneficial_thresholds() :: [{integer(), integer()}] def humidity_beneficial_thresholds, do: @humidity_beneficial_thresholds @doc "Returns the default humidity beneficial score." @spec humidity_beneficial_default() :: integer() def humidity_beneficial_default, do: @humidity_beneficial_default @doc "Returns refractivity thresholds as {gradient, score_beneficial, score_harmful} tuples." @spec refractivity_thresholds() :: [{number(), number(), number()}] def refractivity_thresholds, do: @refractivity_thresholds @doc "Returns the default refractivity scores as {beneficial, harmful}." @spec refractivity_default() :: {number(), number()} def refractivity_default, do: @refractivity_default @doc """ Returns the propagation-mechanism stack for a band. Each band has a list of physical processes that can deliver a contact at that frequency. Cell-level scoring takes the max over these mechanisms — they're alternative paths, not additive — so e.g. a Kp-6 aurora opening can light up 6 m even when the tropo picture is poor. Defaults are derived from frequency: - ≤ 432 MHz → `[:tropo, :aurora]` (auroral E-region scatter is a 50 / 144 / 222 phenomenon, occasionally on 432 MHz, essentially never above) - > 432 MHz → `[:tropo]` Bands can override by setting `:mechanisms` directly in `@band_configs`. Path-level scoring (in `Propagation.PathCompute`) layers `:es` and `:f2` on top for the bands where those reach; those are inherently path-distance-dependent so they don't enter cell-level scoring. """ @spec mechanisms(map() | nil) :: [atom()] def mechanisms(nil), do: [:tropo] def mechanisms(%{mechanisms: list}) when is_list(list), do: list def mechanisms(%{freq_mhz: f}) when f <= 432, do: [:tropo, :aurora] def mechanisms(%{freq_mhz: _}), do: [:tropo] end