prop/lib/microwaveprop/propagation/band_config.ex
Graham McIntire 91c8cc9bc7
Add 144 MHz and 440 MHz bands (tropo only)
Both bands share the physics of low microwave: humidity and refractivity
gradient build ducts the same way, and rain + gas absorption are
effectively zero at VHF/UHF. humidity_effect is :beneficial, rain_k /
rain_alpha are (0, 1), and o2_db_km / h2o_coeff are 0.

Range estimates reflect tropo reality: 2m ducts can reach 2500+ km
under exceptional conditions (e.g. documented Hawaii ↔ California
openings), 70cm a bit less. The seasonal base matches the low-microwave
summer-peak curve.

These configs do NOT capture sporadic-E, meteor scatter, or aurora —
those need ionospheric data (GIRO ionosondes, NOAA SWPC) we don't yet
ingest. The scoring on these bands is therefore "tropo-only" and will
underestimate openings driven by ionospheric modes.
2026-04-15 14:22:33 -05:00

713 lines
17 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
"""
# Recalibrated 2026-04-11 via gradient descent on 5000 QSOs (loss 0.42 → 0.12).
# Key shifts: rain +70%, season +39%, wind +60%; time_of_day -50%, pressure -31%.
@weights %{
humidity: 0.1243,
time_of_day: 0.0496,
td_depression: 0.0978,
refractivity: 0.1049,
sky: 0.08,
season: 0.1112,
wind: 0.08,
rain: 0.1362,
pressure: 0.1032,
pwat: 0.1128
}
@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 or meteor scatter,
# which need ionospheric data we don't yet ingest.
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
},
440 => %{
freq_mhz: 440,
label: "440 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: 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,
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,
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,
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_456 => %{
freq_mhz: 3_456,
label: "3456 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,
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,
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,
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
},
# 142145 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 (288411 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 "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