Replace the two-script Python pipeline (analysis report + Elixir-source emitter) with a single `scripts/recalibrate.py` that fits per-band weights from PSKR spot density (VHF/UHF) and contacts↔HRRR correlations (microwave), writing `priv/algo/band_weights.json` as a machine-readable artifact. A new Elixir BandWeights module loads this JSON once via `:persistent_term` cache; BandConfig.weights/1 consults it before falling back to in-source overrides or global defaults. The script never touches Elixir source — recalibration is now `python3 scripts/recalibrate.py` followed by an app restart.
954 lines
24 KiB
Elixir
954 lines
24 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.
|
||
"""
|
||
|
||
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
|