prop/lib/microwaveprop/propagation/band_config.ex
Graham McIntire a68027b00b
feat(scoring): per-band mechanism stack via cell_score/2
Replaces the "tropo composite + post-hoc aurora boost" model with a
max-over-mechanisms dispatcher so each band's score reflects the
dominant propagation channel rather than summing alternative paths.

- BandConfig.mechanisms/1 derives the stack from frequency:
  ≤432 MHz gets [:tropo, :aurora]; >432 MHz stays [:tropo]. Bands
  may override with an explicit :mechanisms field.
- Scorer.mechanism_score/3 returns a standalone integer score for
  one mechanism (:tropo delegates to composite_score; :aurora is a
  clean Kp/freq function, not a boost on top of tropo).
- Scorer.cell_score/2 takes max across the band's mechanisms so a
  Kp-6 storm scores 6m at 85 even when the tropo picture is poor,
  instead of conflating the two channels.

Es and F2 stay in PathCompute since they're path-distance-dependent.
The legacy aurora_boost path is unchanged so no callers shift today.
2026-05-04 16:46:48 -05:00

936 lines
24 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.
"""
# 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
},
# 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 """
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.
Bands with enough contacts to support a stable gradient-descent fit
carry a `:weights` override map. Bands without it fall back to the
global defaults. `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(%{weights: override}) when is_map(override), do: override
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
@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