prop/lib/microwaveprop/propagation/band_config.ex
Graham McIntire 2eefe64c9b Update seasonal scores from sounding data, document commercial link patterns
Soundings (10,935): October has strongest gradients (-350 N/km) and
65.7% ducting, raised seasonal score 82→88. February ducting (20.2%)
exceeds January (14.8%), raised 32→40.

Commercial links (12,068 samples): 68 GHz shows 3.9 dB diurnal swing,
11 GHz shows 1.7 dB with inverted multipath pattern, 24 GHz remarkably
stable at 0.9 dB. Diurnal sensitivity is non-monotonic with frequency.

RAOB gradients avg -265 N/km vs HRRR -107 (2.5x stronger) — confirms
HRRR misses thin surface ducts that soundings resolve.
2026-04-07 11:45:44 -05:00

322 lines
7.8 KiB
Elixir

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