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. """ @weights %{ humidity: 0.18, time_of_day: 0.10, td_depression: 0.10, refractivity: 0.08, sky: 0.08, season: 0.08, wind: 0.05, rain: 0.08, pressure: 0.15, pwat: 0.10 } @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: "#00ffa3"}, %{min_score: 65, label: "GOOD", color: "#7dffd4"}, %{min_score: 50, label: "MARGINAL", color: "#ffe566"}, %{min_score: 33, label: "POOR", color: "#ff9044"}, %{min_score: 0, label: "NEGLIGIBLE", color: "#ff4f4f"} ] @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 %{ 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, 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 }, 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 } } @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 the scoring weights map. All 10 values sum to 1.0." @spec weights() :: map() def weights, 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 end