Extracts the memory-hostile HRRR fetch → decode → score pipeline for
forecast hours 1–18 into a separate Rust service (`prop-grid-rs`).
Elixir retains f00 with its native-duct + NEXRAD + commercial-link
enrichment; Rust handles the other 18 steps per hourly chain.
Hand-off via a new `grid_tasks` table (`FOR UPDATE SKIP LOCKED` claim
from Rust, Postgrex `NOTIFY propagation_ready` back to Elixir). Rust
writes the same on-disk score-grid format Elixir already uses, to the
same `/data/scores` NFS tree. Phase 1 ships in shadow mode with
PROP_SCORES_DIR=/data/scores_shadow.
Rust crate layout at `rust/prop_grid_rs/` (1:1 module parity with the
Elixir source it ports):
- grid, region, band_config, scores_file, sounding_params
- scorer: all 10 factors + composite. Matches the Elixir scorer
byte-for-byte across 115 golden-fixture samples (5 scenarios
× 23 bands).
- decoder: wgrib2 subprocess + Fortran-record lola-binary parser
- fetcher: HRRR URL derivation + idx cache (1h TTL) + 8-way
parallel byte-range downloads with 429/5xx retry
- pipeline: end-to-end chain step, f00 rejected at the boundary
- db: sqlx grid_tasks claim/complete + propagation_ready NOTIFY
- bin/worker: tokio main loop, JSON logs, SIGTERM-safe
91 Rust tests + clippy -D warnings clean. 2,158 Elixir tests green.
Elixir additions:
- `GridTaskEnqueuer` seeds fh=1..18 rows from
`PropagationGridWorker.seed_chain/0`
- `NotifyListener` LISTEN → warm `ScoreCache` → PubSub fan-out
- `ShadowComparator` diffs prod vs shadow `.ntms` bodies
- `mix rust.golden` task writes the Rust-side golden fixture
k8s: new `deployment-grid-rs.yaml` pinned to talos5 (32 GB NUC) via
`prop-grid-rs=primary` nodeSelector + `workload=grid-rs:NoSchedule`
toleration, 512 Mi limit, sharing the existing NFS `/data` mount.
The plan document is at `plans/vivid-hatching-quail.md` (local to my
workstation); phases 2 (cutover) and 3 (talos5 concurrency tuning)
follow after 72h of shadow-mode parity.
483 lines
18 KiB
Rust
483 lines
18 KiB
Rust
//! Band configuration tables. 1:1 port of
|
|
//! `lib/microwaveprop/propagation/band_config.ex`.
|
|
//!
|
|
//! Keep the data layout identical to the Elixir module; any numerical
|
|
//! drift here will show up immediately in golden-fixture scorer tests.
|
|
|
|
use std::sync::OnceLock;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum HumidityEffect {
|
|
Beneficial,
|
|
Harmful,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct Weights {
|
|
pub humidity: f64,
|
|
pub time_of_day: f64,
|
|
pub td_depression: f64,
|
|
pub refractivity: f64,
|
|
pub sky: f64,
|
|
pub season: f64,
|
|
pub wind: f64,
|
|
pub rain: f64,
|
|
pub pressure: f64,
|
|
pub pwat: f64,
|
|
}
|
|
|
|
impl Weights {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub const fn new(
|
|
humidity: f64,
|
|
time_of_day: f64,
|
|
td_depression: f64,
|
|
refractivity: f64,
|
|
sky: f64,
|
|
season: f64,
|
|
wind: f64,
|
|
rain: f64,
|
|
pressure: f64,
|
|
pwat: f64,
|
|
) -> Self {
|
|
Self {
|
|
humidity,
|
|
time_of_day,
|
|
td_depression,
|
|
refractivity,
|
|
sky,
|
|
season,
|
|
wind,
|
|
rain,
|
|
pressure,
|
|
pwat,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Default weight vector fit by gradient descent on 5,000 QSOs
|
|
/// (2026-04-11). Used as fallback for bands that don't carry a `weights`
|
|
/// override.
|
|
pub const DEFAULT_WEIGHTS: Weights = Weights::new(
|
|
0.1243, // humidity
|
|
0.0496, // time_of_day
|
|
0.0978, // td_depression
|
|
0.1049, // refractivity
|
|
0.08, // sky
|
|
0.1112, // season
|
|
0.08, // wind
|
|
0.1362, // rain
|
|
0.1032, // pressure
|
|
0.1128, // pwat
|
|
);
|
|
|
|
/// Sunrise hour by month (1..=12) in local solar time. Index = month - 1.
|
|
pub const SUNRISE_TABLE: [f64; 12] = [
|
|
7.4, 7.3, 7.0, 6.7, 6.35, 6.25, 6.35, 6.65, 6.9, 7.1, 7.35, 7.45,
|
|
];
|
|
|
|
pub const HUMIDITY_BENEFICIAL_THRESHOLDS: &[(u8, i32)] = &[
|
|
(4, 55),
|
|
(7, 70),
|
|
(10, 82),
|
|
(14, 90),
|
|
(18, 95),
|
|
(22, 88),
|
|
];
|
|
pub const HUMIDITY_BENEFICIAL_DEFAULT: i32 = 75;
|
|
|
|
/// `(gradient_max, score_beneficial, score_harmful)`. First entry whose
|
|
/// `gradient_max` exceeds the observed gradient wins. Calibrated for
|
|
/// HRRR-derived gradients.
|
|
pub const REFRACTIVITY_THRESHOLDS: &[(i32, i32, i32)] = &[
|
|
(-200, 98, 85),
|
|
(-150, 92, 80),
|
|
(-100, 82, 72),
|
|
(-75, 68, 62),
|
|
(-55, 55, 55),
|
|
(-40, 48, 48),
|
|
];
|
|
pub const REFRACTIVITY_DEFAULT: (i32, i32) = (42, 42);
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct BandConfig {
|
|
pub freq_mhz: u32,
|
|
pub label: &'static str,
|
|
pub o2_db_km: f64,
|
|
pub h2o_coeff: f64,
|
|
pub humidity_effect: HumidityEffect,
|
|
pub humidity_penalty: f64,
|
|
pub rain_k: f64,
|
|
pub rain_alpha: f64,
|
|
/// Per-month base score for seasonal factor, index = month - 1.
|
|
pub seasonal_base: [i32; 12],
|
|
/// Per-month additive adjustment, index = month - 1.
|
|
pub seasonal_adj: [i32; 12],
|
|
pub typical_range_km: u32,
|
|
pub extended_range_km: u32,
|
|
pub exceptional_range_km: u32,
|
|
/// `None` falls back to `DEFAULT_WEIGHTS`.
|
|
pub weights: Option<Weights>,
|
|
}
|
|
|
|
impl BandConfig {
|
|
pub fn weights(&self) -> Weights {
|
|
self.weights.unwrap_or(DEFAULT_WEIGHTS)
|
|
}
|
|
}
|
|
|
|
/// Fixed-size literal helper; month values given as (month, score) pairs
|
|
/// in Elixir map form. Panics at construction if a month is out of range.
|
|
const fn months(v: [(u8, i32); 12]) -> [i32; 12] {
|
|
// Build by iterating; can't use const fn loop with indirect writes
|
|
// cleanly, so use a match per position.
|
|
let mut out = [0; 12];
|
|
let mut i = 0;
|
|
while i < 12 {
|
|
let (m, s) = v[i];
|
|
// Month values always 1..=12, stored at index month - 1 is the
|
|
// convention in Elixir — we rely on the literal being in month
|
|
// order here (it is in every band config).
|
|
assert!(m >= 1 && m <= 12);
|
|
out[(m - 1) as usize] = s;
|
|
i += 1;
|
|
}
|
|
out
|
|
}
|
|
|
|
const fn adj_from(values: &[(u8, i32)]) -> [i32; 12] {
|
|
let mut out = [0; 12];
|
|
let mut i = 0;
|
|
while i < values.len() {
|
|
let (m, s) = values[i];
|
|
assert!(m >= 1 && m <= 12);
|
|
out[(m - 1) as usize] = s;
|
|
i += 1;
|
|
}
|
|
out
|
|
}
|
|
|
|
// The seasonal_base tables are identical across many bands; pull out the
|
|
// common shape once so the 22 band definitions stay scannable.
|
|
const BASE_LOW_BAND: [i32; 12] = months([
|
|
(1, 38), (2, 40), (3, 22), (4, 55), (5, 68), (6, 90),
|
|
(7, 95), (8, 75), (9, 78), (10, 88), (11, 78), (12, 25),
|
|
]);
|
|
|
|
const BASE_50: [i32; 12] = months([
|
|
(1, 30), (2, 32), (3, 25), (4, 50), (5, 70), (6, 95),
|
|
(7, 95), (8, 70), (9, 65), (10, 70), (11, 60), (12, 25),
|
|
]);
|
|
|
|
const BASE_24G: [i32; 12] = months([
|
|
(1, 88), (2, 84), (3, 68), (4, 62), (5, 51), (6, 34),
|
|
(7, 18), (8, 18), (9, 48), (10, 68), (11, 96), (12, 88),
|
|
]);
|
|
|
|
const BASE_47G: [i32; 12] = months([
|
|
(1, 90), (2, 88), (3, 78), (4, 68), (5, 55), (6, 38),
|
|
(7, 22), (8, 22), (9, 48), (10, 74), (11, 96), (12, 90),
|
|
]);
|
|
|
|
const BASE_68G: [i32; 12] = months([
|
|
(1, 90), (2, 88), (3, 78), (4, 65), (5, 50), (6, 32),
|
|
(7, 18), (8, 18), (9, 44), (10, 70), (11, 92), (12, 90),
|
|
]);
|
|
|
|
const BASE_75G: [i32; 12] = months([
|
|
(1, 90), (2, 90), (3, 80), (4, 68), (5, 55), (6, 38),
|
|
(7, 22), (8, 22), (9, 48), (10, 74), (11, 96), (12, 90),
|
|
]);
|
|
|
|
const BASE_122G: [i32; 12] = months([
|
|
(1, 92), (2, 90), (3, 78), (4, 62), (5, 45), (6, 28),
|
|
(7, 15), (8, 15), (9, 38), (10, 68), (11, 92), (12, 92),
|
|
]);
|
|
|
|
const BASE_134G: [i32; 12] = months([
|
|
(1, 92), (2, 90), (3, 78), (4, 65), (5, 48), (6, 30),
|
|
(7, 18), (8, 18), (9, 42), (10, 70), (11, 92), (12, 92),
|
|
]);
|
|
|
|
const BASE_142G: [i32; 12] = months([
|
|
(1, 92), (2, 90), (3, 78), (4, 65), (5, 47), (6, 28),
|
|
(7, 16), (8, 16), (9, 40), (10, 68), (11, 92), (12, 92),
|
|
]);
|
|
|
|
const BASE_145G: [i32; 12] = months([
|
|
(1, 92), (2, 90), (3, 78), (4, 64), (5, 46), (6, 27),
|
|
(7, 15), (8, 15), (9, 38), (10, 66), (11, 92), (12, 92),
|
|
]);
|
|
|
|
const BASE_241G: [i32; 12] = months([
|
|
(1, 95), (2, 92), (3, 75), (4, 55), (5, 35), (6, 15),
|
|
(7, 8), (8, 8), (9, 30), (10, 65), (11, 95), (12, 95),
|
|
]);
|
|
|
|
const BASE_288G: [i32; 12] = months([
|
|
(1, 95), (2, 92), (3, 75), (4, 55), (5, 35), (6, 14),
|
|
(7, 7), (8, 7), (9, 28), (10, 64), (11, 95), (12, 95),
|
|
]);
|
|
|
|
const BASE_322G: [i32; 12] = months([
|
|
(1, 96), (2, 92), (3, 74), (4, 52), (5, 32), (6, 12),
|
|
(7, 6), (8, 6), (9, 26), (10, 62), (11, 96), (12, 96),
|
|
]);
|
|
|
|
const BASE_403G: [i32; 12] = months([
|
|
(1, 96), (2, 92), (3, 74), (4, 52), (5, 32), (6, 12),
|
|
(7, 6), (8, 6), (9, 26), (10, 62), (11, 96), (12, 96),
|
|
]);
|
|
|
|
const ADJ_NONE: [i32; 12] = [0; 12];
|
|
const ADJ_24G: [i32; 12] = adj_from(&[(5, -4), (6, -8), (7, -10), (8, -10), (9, -4)]);
|
|
|
|
fn bands() -> &'static [BandConfig] {
|
|
static CELL: OnceLock<Vec<BandConfig>> = OnceLock::new();
|
|
CELL.get_or_init(build_bands)
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
fn build_bands() -> Vec<BandConfig> {
|
|
use HumidityEffect::*;
|
|
vec![
|
|
BandConfig {
|
|
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: BASE_50, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 400, extended_range_km: 1500, exceptional_range_km: 4000,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_LOW_BAND, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 350, extended_range_km: 900, exceptional_range_km: 2500,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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,
|
|
seasonal_base: BASE_LOW_BAND, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 320, extended_range_km: 800, exceptional_range_km: 2000,
|
|
weights: Some(Weights::new(0.1593, 0.0350, 0.1250, 0.1401, 0.0706, 0.1276, 0.0706, 0.0120, 0.0681, 0.1916)),
|
|
},
|
|
BandConfig {
|
|
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,
|
|
seasonal_base: BASE_LOW_BAND, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 300, extended_range_km: 700, exceptional_range_km: 1800,
|
|
weights: Some(Weights::new(0.2061, 0.0329, 0.1200, 0.1186, 0.0663, 0.1106, 0.0663, 0.0113, 0.0809, 0.1870)),
|
|
},
|
|
BandConfig {
|
|
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: BASE_LOW_BAND, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 400, extended_range_km: 800, exceptional_range_km: 1500,
|
|
weights: Some(Weights::new(0.2201, 0.0437, 0.1102, 0.0888, 0.0783, 0.1197, 0.0783, 0.0133, 0.0839, 0.1635)),
|
|
},
|
|
BandConfig {
|
|
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: BASE_LOW_BAND, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 350, extended_range_km: 700, exceptional_range_km: 1200,
|
|
weights: Some(Weights::new(0.2131, 0.0494, 0.0898, 0.1392, 0.0797, 0.1108, 0.0797, 0.0136, 0.0568, 0.1678)),
|
|
},
|
|
BandConfig {
|
|
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: BASE_LOW_BAND, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 300, extended_range_km: 600, exceptional_range_km: 1000,
|
|
weights: Some(Weights::new(0.1941, 0.0387, 0.1385, 0.1638, 0.0625, 0.0868, 0.0625, 0.0336, 0.0432, 0.1762)),
|
|
},
|
|
BandConfig {
|
|
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,
|
|
seasonal_base: BASE_LOW_BAND, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 250, extended_range_km: 550, exceptional_range_km: 900,
|
|
weights: Some(Weights::new(0.1996, 0.0398, 0.1251, 0.1310, 0.0642, 0.0893, 0.0642, 0.0489, 0.0568, 0.1811)),
|
|
},
|
|
BandConfig {
|
|
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: BASE_LOW_BAND, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 220, extended_range_km: 500, exceptional_range_km: 1000,
|
|
weights: Some(Weights::new(0.1829, 0.0423, 0.1205, 0.0881, 0.0683, 0.0949, 0.0683, 0.0822, 0.0602, 0.1925)),
|
|
},
|
|
BandConfig {
|
|
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: BASE_LOW_BAND, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 200, extended_range_km: 500, exceptional_range_km: 1000,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_24G, seasonal_adj: ADJ_24G,
|
|
typical_range_km: 100, extended_range_km: 250, exceptional_range_km: 500,
|
|
weights: Some(Weights::new(0.1481, 0.0532, 0.0360, 0.1250, 0.0477, 0.0729, 0.0477, 0.2147, 0.1203, 0.1344)),
|
|
},
|
|
BandConfig {
|
|
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: BASE_47G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 70, extended_range_km: 150, exceptional_range_km: 300,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_68G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 40, extended_range_km: 80, exceptional_range_km: 150,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_75G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 50, extended_range_km: 120, exceptional_range_km: 250,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_122G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 30, extended_range_km: 80, exceptional_range_km: 140,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_134G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 40, extended_range_km: 100, exceptional_range_km: 160,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_142G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 35, extended_range_km: 80, exceptional_range_km: 160,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_145G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 35, extended_range_km: 80, exceptional_range_km: 150,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_241G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 10, extended_range_km: 50, exceptional_range_km: 115,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_288G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 5, extended_range_km: 15, exceptional_range_km: 50,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_322G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 5, extended_range_km: 15, exceptional_range_km: 40,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_403G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 3, extended_range_km: 10, exceptional_range_km: 30,
|
|
weights: None,
|
|
},
|
|
BandConfig {
|
|
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: BASE_403G, seasonal_adj: ADJ_NONE,
|
|
typical_range_km: 3, extended_range_km: 10, exceptional_range_km: 30,
|
|
weights: None,
|
|
},
|
|
]
|
|
}
|
|
|
|
pub fn get(freq_mhz: u32) -> Option<&'static BandConfig> {
|
|
bands().iter().find(|b| b.freq_mhz == freq_mhz)
|
|
}
|
|
|
|
pub fn all_bands() -> &'static [BandConfig] {
|
|
bands()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn expected_band_count() {
|
|
assert_eq!(all_bands().len(), 23);
|
|
}
|
|
|
|
#[test]
|
|
fn default_weights_sum_to_one() {
|
|
let w = DEFAULT_WEIGHTS;
|
|
let sum = w.humidity + w.time_of_day + w.td_depression + w.refractivity
|
|
+ w.sky + w.season + w.wind + w.rain + w.pressure + w.pwat;
|
|
// Elixir comment says "all 10 values sum to 1.0" — allow tiny FP slop.
|
|
assert!((sum - 1.0).abs() < 1e-9, "weights sum = {sum}");
|
|
}
|
|
|
|
#[test]
|
|
fn lookup_by_freq() {
|
|
let tenghz = get(10_000).unwrap();
|
|
assert_eq!(tenghz.label, "10 GHz");
|
|
assert_eq!(tenghz.humidity_effect, HumidityEffect::Beneficial);
|
|
assert!(get(99_999).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn band_with_weights_override_returns_override() {
|
|
let b = get(10_000).unwrap();
|
|
// 10 GHz has weights: None → defaults.
|
|
assert_eq!(b.weights().humidity, DEFAULT_WEIGHTS.humidity);
|
|
|
|
let b432 = get(432).unwrap();
|
|
assert!((b432.weights().humidity - 0.2061).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn seasonal_adj_24g_matches_elixir() {
|
|
let b = get(24_000).unwrap();
|
|
// Elixir: seasonal_adj: %{5 => -4, 6 => -8, 7 => -10, 8 => -10, 9 => -4}
|
|
assert_eq!(b.seasonal_adj[4], -4); // May
|
|
assert_eq!(b.seasonal_adj[6], -10); // July
|
|
assert_eq!(b.seasonal_adj[11], 0); // Dec untouched
|
|
}
|
|
}
|