Both retired in light of the 2026-04-25 revision report
(`docs/algo-reports/2026-04-25-algo-revisions.md`):
* HPBL boundary-layer multiplier in `Scorer.score_refractivity/{3,4,5}`
is gone. On the n=47,418 10 GHz HRRR-matched corpus rho_hpbl is
+0.004; binned distance is flat to within ±5 % across 200–2,000 m.
The previously reported 2.3× ratio between shallow and deep HPBL
bins was a small-corpus artefact that disappeared once the matched
corpus passed ~5,000 contacts.
* Native-profile 1.15× duct boost is gone. At 10 GHz on n=52,341
matched contacts, cells where `best_duct_band_ghz` ≥ band ran
*shorter* than no-duct cells (198 km vs 211 km, n=173 vs 44,658).
The Bulk Richardson gate that existed only to suppress that boost
in turbulent conditions is also retired; arity preserved on every
`score_refractivity` overload so callers compile unchanged.
Same retirements applied in the Rust port (`rust/prop_grid_rs/src/scorer.rs`)
to keep the Elixir/Rust parity tests green.
algo.md updated end-to-end:
* Finding 4 (HPBL) rewritten to "no usable signal" with the
n=47,418 bin table.
* Finding 5 (gradient) tightened: load-bearing only at 24 GHz,
set band-specific gradient weight to 0 outside [10, 47] GHz on
the next derive_band_weights run.
* Finding 8 (time-of-day) augmented with the robust hour-bucketed
amplitude index (40 / 82 / 101 % at 10 / 24 / 47 GHz) and a
selection-bias caveat for VHF/UHF (where 129–148 % amplitude
reflects evening contest scheduling, not propagation).
* Finding 9 (sounding) refreshed against the n=27,058 corpus.
* Part 2c "Native-profile duct boost" rewritten as
"retired 2026-04-25" with the falsifying join.
* Part 2c "Commercial-link inverse sensor" promoted from deferred
with the 22-day, 38k-sample SNMP corpus, the +0.61 / −0.61 PWAT
and pressure correlations against the 37-hour HRRR overlap, and
the 04–05 / 10–12 local two-peak morning fade window.
* Part 2b "Pressure" annotated with the U-shape at 10 GHz and the
next-iteration target.
* Part 2d "What stayed, what left" + "Open items" updated to
reflect the four code/doc moves.
119 Elixir scorer tests green, 134 Rust scorer tests green, 2,888
total Elixir tests + 221 properties green via `mix precommit`.
944 lines
31 KiB
Rust
944 lines
31 KiB
Rust
//! Propagation scorer — 1:1 port of `lib/microwaveprop/propagation/scorer.ex`.
|
||
//!
|
||
//! Every factor returns a 0..=100 integer score. The composite is
|
||
//! `round(Σ factor · weight)` with weights from `band_config`. All
|
||
//! thresholds live in `band_config` / `region` — nothing magic here.
|
||
//!
|
||
//! Behavior parity tests mirror the Elixir `ScorerTest` suite line-by-line;
|
||
//! integer scores match exactly.
|
||
//!
|
||
//! Elixir `Kernel.round/1` and Rust `f64::round` both use round-half-away-
|
||
//! from-zero, so no custom rounding helper is needed.
|
||
|
||
use crate::band_config::{
|
||
BandConfig, HumidityEffect, Weights, DEFAULT_WEIGHTS, HUMIDITY_BENEFICIAL_DEFAULT,
|
||
HUMIDITY_BENEFICIAL_THRESHOLDS, REFRACTIVITY_DEFAULT, REFRACTIVITY_THRESHOLDS, SUNRISE_TABLE,
|
||
};
|
||
use crate::region;
|
||
|
||
/// Inputs to `composite_score`. All f64 everywhere to stay close to
|
||
/// Erlang's single-float world.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct Conditions {
|
||
pub abs_humidity: f64,
|
||
pub temp_f: f64,
|
||
pub dewpoint_f: f64,
|
||
pub wind_speed_kts: Option<f64>,
|
||
pub sky_cover_pct: Option<f64>,
|
||
pub utc_hour: u8,
|
||
pub utc_minute: u8,
|
||
pub month: u8,
|
||
pub longitude: f64,
|
||
pub latitude: Option<f64>,
|
||
pub pressure_mb: Option<f64>,
|
||
pub prev_pressure_mb: Option<f64>,
|
||
pub rain_rate_mmhr: Option<f64>,
|
||
pub min_refractivity_gradient: Option<f64>,
|
||
pub bl_depth_m: Option<f64>,
|
||
pub pwat_mm: Option<f64>,
|
||
pub best_duct_band_ghz: Option<f64>,
|
||
pub bulk_richardson: Option<f64>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct BandInvariants {
|
||
pub tod: i32,
|
||
pub sky: i32,
|
||
pub wind: i32,
|
||
pub pressure: i32,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct Factors {
|
||
pub humidity: i32,
|
||
pub time_of_day: i32,
|
||
pub td_depression: i32,
|
||
pub refractivity: i32,
|
||
pub sky: i32,
|
||
pub season: i32,
|
||
pub wind: i32,
|
||
pub rain: i32,
|
||
pub pwat: i32,
|
||
pub pressure: i32,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct Composite {
|
||
pub score: i32,
|
||
pub factors: Factors,
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────
|
||
|
||
pub fn f_to_c(f: f64) -> f64 {
|
||
(f - 32.0) * 5.0 / 9.0
|
||
}
|
||
|
||
pub fn c_to_f(c: f64) -> f64 {
|
||
c * 9.0 / 5.0 + 32.0
|
||
}
|
||
|
||
pub fn absolute_humidity(temp_c: f64, dewpoint_c: f64) -> f64 {
|
||
let e_sat = 6.112 * (17.67 * dewpoint_c / (dewpoint_c + 243.5)).exp();
|
||
217.0 * e_sat / (temp_c + 273.15)
|
||
}
|
||
|
||
pub fn wind_speed_kts(u_ms: f64, v_ms: f64) -> f64 {
|
||
(u_ms * u_ms + v_ms * v_ms).sqrt() * 1.94384
|
||
}
|
||
|
||
pub fn precip_to_rate_mmhr(mm: Option<f64>) -> f64 {
|
||
match mm {
|
||
Some(v) if v > 0.0 => v,
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
/// Marshall-Palmer Z-R: `Z = 200 · R^1.6` → `R = (Z/200)^(1/1.6)`, `Z = 10^(dBZ/10)`.
|
||
/// Clips below 5 dBZ to 0 (ground clutter) and above 150 mm/hr (hail).
|
||
pub fn dbz_to_rain_rate_mmhr(dbz: Option<f64>) -> f64 {
|
||
match dbz {
|
||
None => 0.0,
|
||
Some(v) if v < 5.0 => 0.0,
|
||
Some(v) => {
|
||
let z = 10f64.powf(v / 10.0);
|
||
(z / 200.0).powf(1.0 / 1.6).min(150.0)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn clamp_score_f(v: f64) -> i32 {
|
||
(v.round() as i64).clamp(0, 100) as i32
|
||
}
|
||
|
||
fn clamp_score(v: i32) -> i32 {
|
||
v.clamp(0, 100)
|
||
}
|
||
|
||
// ── Factor 1: humidity ───────────────────────────────────────────────
|
||
|
||
pub fn score_humidity(abs_humidity: f64, band: &BandConfig) -> i32 {
|
||
match band.humidity_effect {
|
||
HumidityEffect::Beneficial => HUMIDITY_BENEFICIAL_THRESHOLDS
|
||
.iter()
|
||
.find(|(max, _)| abs_humidity < *max as f64)
|
||
.map(|(_, s)| *s)
|
||
.unwrap_or(HUMIDITY_BENEFICIAL_DEFAULT),
|
||
HumidityEffect::Harmful => {
|
||
let r = abs_humidity * band.humidity_penalty;
|
||
if r <= 6.0 {
|
||
100
|
||
} else if r <= 9.0 {
|
||
(95.0 - (r - 6.0) / 3.0 * 20.0).round() as i32
|
||
} else if r <= 13.0 {
|
||
(75.0 - (r - 9.0) / 4.0 * 30.0).round() as i32
|
||
} else if r <= 18.0 {
|
||
(45.0 - (r - 13.0) / 5.0 * 35.0).round() as i32
|
||
} else {
|
||
((10.0 - (r - 18.0) * 2.0).round() as i32).max(0)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Factor 2: time of day ────────────────────────────────────────────
|
||
|
||
pub fn score_time_of_day(
|
||
utc_hour: u8,
|
||
utc_minute: u8,
|
||
month: u8,
|
||
longitude: f64,
|
||
) -> (i32, &'static str) {
|
||
let offset = longitude / 15.0;
|
||
let raw = utc_hour as f64 + utc_minute as f64 / 60.0 + offset + 24.0;
|
||
// Match Elixir :math.fmod/2 — same semantics as fmod.
|
||
let local = raw.rem_euclid(24.0);
|
||
let sunrise = SUNRISE_TABLE[(month - 1) as usize];
|
||
let d = local - sunrise;
|
||
|
||
if (-1.5..=1.5).contains(&d) {
|
||
(100, "Peak — inversion maximum")
|
||
} else if d > 1.5 && d <= 3.0 {
|
||
(78, "Good — inversion eroding")
|
||
} else if d > -3.0 && d < -1.5 {
|
||
(82, "Pre-dawn — inversion building")
|
||
} else if d > 3.0 && d <= 6.0 {
|
||
(38, "Marginal — boundary layer mixing")
|
||
} else if local >= 20.0 || local <= 1.0 {
|
||
(72, "Evening — cooling, inversion reforming")
|
||
} else if d > 6.0 {
|
||
(18, "Afternoon — full convective mixing")
|
||
} else {
|
||
(55, "Night — gradual cooling")
|
||
}
|
||
}
|
||
|
||
// ── Factor 3: T-Td depression ────────────────────────────────────────
|
||
|
||
pub fn score_td_depression(temp_f: f64, dewpoint_f: f64, band: &BandConfig) -> i32 {
|
||
let dep = temp_f - dewpoint_f;
|
||
match band.humidity_effect {
|
||
HumidityEffect::Beneficial => {
|
||
if dep < 3.0 {
|
||
40
|
||
} else if dep < 8.0 {
|
||
75
|
||
} else if dep < 14.0 {
|
||
85
|
||
} else if dep < 22.0 {
|
||
70
|
||
} else {
|
||
55
|
||
}
|
||
}
|
||
HumidityEffect::Harmful => {
|
||
if dep > 22.0 {
|
||
96
|
||
} else if dep > 14.0 {
|
||
80
|
||
} else if dep > 8.0 {
|
||
60
|
||
} else if dep > 4.0 {
|
||
38
|
||
} else {
|
||
18
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Factor 4: refractivity gradient (gradient-only as of 2026-04-25) ─
|
||
//
|
||
// HPBL boundary-layer multiplier and native-profile 1.15× duct boost both
|
||
// retired — see `docs/algo-reports/2026-04-25-algo-revisions.md`. The
|
||
// `bl_depth_m`, `best_duct_band_ghz`, and `bulk_richardson` parameters are
|
||
// retained on the call signature so callers (and the Elixir parity test
|
||
// suite under `mix rust.golden`) keep the same shape, but they no longer
|
||
// influence the score.
|
||
|
||
pub fn score_refractivity(
|
||
min_gradient: Option<f64>,
|
||
_bl_depth_m: Option<f64>,
|
||
_best_duct_band_ghz: Option<f64>,
|
||
_bulk_richardson: Option<f64>,
|
||
band: &BandConfig,
|
||
) -> i32 {
|
||
let Some(gradient) = min_gradient else {
|
||
return 50;
|
||
};
|
||
|
||
let base_match = REFRACTIVITY_THRESHOLDS
|
||
.iter()
|
||
.find(|(max, _, _)| gradient < *max as f64);
|
||
|
||
match base_match {
|
||
Some((_, b, h)) => match band.humidity_effect {
|
||
HumidityEffect::Beneficial => *b,
|
||
HumidityEffect::Harmful => *h,
|
||
},
|
||
None => {
|
||
let (b, h) = REFRACTIVITY_DEFAULT;
|
||
if band.humidity_effect == HumidityEffect::Beneficial {
|
||
b
|
||
} else {
|
||
h
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Factor 5: sky cover ──────────────────────────────────────────────
|
||
|
||
pub fn score_sky(pct: Option<f64>) -> i32 {
|
||
match pct {
|
||
None => 50,
|
||
Some(p) if p <= 6.0 => 100,
|
||
Some(p) if p <= 25.0 => 88,
|
||
Some(p) if p <= 50.0 => 60,
|
||
Some(p) if p <= 87.0 => 25,
|
||
Some(_) => 5,
|
||
}
|
||
}
|
||
|
||
// ── Factor 6: season ─────────────────────────────────────────────────
|
||
|
||
pub fn score_season(month: u8, lat: Option<f64>, lon: Option<f64>, band: &BandConfig) -> i32 {
|
||
let idx = (month - 1) as usize;
|
||
let base = band.seasonal_base[idx];
|
||
let adj = band.seasonal_adj[idx];
|
||
|
||
let region_mult = match (lat, lon) {
|
||
(Some(la), Some(lo)) => region::seasonal_adjustment(region::for_point(la, lo), month),
|
||
_ => 1.0,
|
||
};
|
||
|
||
let raw = (base + adj) as f64 * region_mult;
|
||
clamp_score_f(raw)
|
||
}
|
||
|
||
// ── Factor 7: wind ───────────────────────────────────────────────────
|
||
|
||
pub fn score_wind(speed_kts: Option<f64>) -> i32 {
|
||
match speed_kts {
|
||
None => 50,
|
||
Some(s) if s < 5.0 => 100,
|
||
Some(s) if s < 10.0 => 90,
|
||
Some(s) if s < 15.0 => 75,
|
||
Some(s) if s < 20.0 => 55,
|
||
Some(s) if s < 25.0 => 35,
|
||
Some(_) => 15,
|
||
}
|
||
}
|
||
|
||
// ── Factor 8: rain attenuation (ITU-R P.838) ────────────────────────
|
||
|
||
pub fn score_rain(rate_mmhr: Option<f64>, band: &BandConfig) -> i32 {
|
||
match rate_mmhr {
|
||
None => 100,
|
||
Some(0.0) => 100,
|
||
Some(v) => {
|
||
let gamma = band.rain_k * v.powf(band.rain_alpha);
|
||
if gamma < 0.1 {
|
||
95
|
||
} else if gamma < 0.5 {
|
||
75
|
||
} else if gamma < 1.0 {
|
||
50
|
||
} else if gamma < 2.0 {
|
||
25
|
||
} else if gamma < 5.0 {
|
||
10
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Factor 9: precipitable water ────────────────────────────────────
|
||
|
||
pub fn score_pwat(pwat_mm: Option<f64>, band: &BandConfig) -> i32 {
|
||
let Some(v) = pwat_mm else { return 60 };
|
||
match band.humidity_effect {
|
||
HumidityEffect::Beneficial => {
|
||
if v < 10.0 {
|
||
55
|
||
} else if v < 20.0 {
|
||
75
|
||
} else if v < 30.0 {
|
||
90
|
||
} else if v < 40.0 {
|
||
70
|
||
} else {
|
||
50
|
||
}
|
||
}
|
||
HumidityEffect::Harmful => {
|
||
if v < 10.0 {
|
||
95
|
||
} else if v < 20.0 {
|
||
80
|
||
} else if v < 30.0 {
|
||
60
|
||
} else if v < 40.0 {
|
||
35
|
||
} else {
|
||
15
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Factor 10: pressure ─────────────────────────────────────────────
|
||
|
||
pub fn score_pressure(current_mb: Option<f64>, previous_mb: Option<f64>) -> i32 {
|
||
let Some(current) = current_mb else { return 50 };
|
||
match previous_mb {
|
||
None => {
|
||
if current < 980.0 {
|
||
88
|
||
} else if current < 990.0 {
|
||
82
|
||
} else if current < 1000.0 {
|
||
70
|
||
} else if current < 1010.0 {
|
||
55
|
||
} else if current < 1020.0 {
|
||
40
|
||
} else {
|
||
30
|
||
}
|
||
}
|
||
Some(prev) => {
|
||
let delta = current - prev;
|
||
if delta > 2.5 {
|
||
80
|
||
} else if delta > 0.8 {
|
||
70
|
||
} else if delta > -0.5 {
|
||
60
|
||
} else if delta > -2.0 {
|
||
65
|
||
} else {
|
||
45
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Composite ───────────────────────────────────────────────────────
|
||
|
||
pub fn precompute_band_invariants(c: &Conditions) -> BandInvariants {
|
||
let (tod, _) = score_time_of_day(c.utc_hour, c.utc_minute, c.month, c.longitude);
|
||
BandInvariants {
|
||
tod,
|
||
sky: score_sky(c.sky_cover_pct),
|
||
wind: score_wind(c.wind_speed_kts),
|
||
pressure: score_pressure(c.pressure_mb, c.prev_pressure_mb),
|
||
}
|
||
}
|
||
|
||
pub fn composite_score(c: &Conditions, band: &BandConfig) -> Composite {
|
||
composite_score_with(c, band, None)
|
||
}
|
||
|
||
pub fn composite_score_with(
|
||
c: &Conditions,
|
||
band: &BandConfig,
|
||
precomputed: Option<BandInvariants>,
|
||
) -> Composite {
|
||
let inv = precomputed.unwrap_or_else(|| precompute_band_invariants(c));
|
||
|
||
let factors = Factors {
|
||
humidity: score_humidity(c.abs_humidity, band),
|
||
time_of_day: inv.tod,
|
||
td_depression: score_td_depression(c.temp_f, c.dewpoint_f, band),
|
||
refractivity: score_refractivity(
|
||
c.min_refractivity_gradient,
|
||
c.bl_depth_m,
|
||
c.best_duct_band_ghz,
|
||
c.bulk_richardson,
|
||
band,
|
||
),
|
||
sky: inv.sky,
|
||
season: score_season(
|
||
c.month,
|
||
c.latitude,
|
||
Some(c.longitude).filter(|_| c.latitude.is_some()),
|
||
band,
|
||
),
|
||
wind: inv.wind,
|
||
rain: score_rain(c.rain_rate_mmhr, band),
|
||
pwat: score_pwat(c.pwat_mm, band),
|
||
pressure: inv.pressure,
|
||
};
|
||
|
||
let w = band.weights.unwrap_or(DEFAULT_WEIGHTS);
|
||
let weighted = weighted_sum(&factors, &w);
|
||
Composite {
|
||
score: clamp_score_f(weighted),
|
||
factors,
|
||
}
|
||
}
|
||
|
||
fn weighted_sum(f: &Factors, w: &Weights) -> f64 {
|
||
f.humidity as f64 * w.humidity
|
||
+ f.time_of_day as f64 * w.time_of_day
|
||
+ f.td_depression as f64 * w.td_depression
|
||
+ f.refractivity as f64 * w.refractivity
|
||
+ f.sky as f64 * w.sky
|
||
+ f.season as f64 * w.season
|
||
+ f.wind as f64 * w.wind
|
||
+ f.rain as f64 * w.rain
|
||
+ f.pressure as f64 * w.pressure
|
||
+ f.pwat as f64 * w.pwat
|
||
}
|
||
|
||
/// Commercial-link inverse-sensor boost. Not used in the grid hot path
|
||
/// (f00-only — Elixir keeps that) but ported so unit-level correctness
|
||
/// stays testable from the Rust side.
|
||
pub fn commercial_link_boost(score: i32, n_links: u32, degradation_db: f64) -> i32 {
|
||
if n_links == 0 || degradation_db < 3.0 {
|
||
return clamp_score(score);
|
||
}
|
||
if degradation_db < 8.0 {
|
||
let bonus = 2.0 + (degradation_db - 3.0) * 8.0 / 5.0;
|
||
clamp_score_f(score as f64 + bonus)
|
||
} else {
|
||
let bonus = (10.0 + (degradation_db - 8.0) * 15.0 / 8.0).min(25.0);
|
||
clamp_score_f(score as f64 + bonus)
|
||
}
|
||
}
|
||
|
||
// ── Tests (mirror Elixir ScorerTest) ────────────────────────────────
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::band_config;
|
||
|
||
fn b10g() -> &'static BandConfig {
|
||
band_config::get(10_000).unwrap()
|
||
}
|
||
fn b24g() -> &'static BandConfig {
|
||
band_config::get(24_000).unwrap()
|
||
}
|
||
fn b75g() -> &'static BandConfig {
|
||
band_config::get(75_000).unwrap()
|
||
}
|
||
|
||
const EAST_LON: f64 = -75.0;
|
||
|
||
#[test]
|
||
fn f_to_c_boundaries() {
|
||
assert!((f_to_c(32.0) - 0.0).abs() < 0.01);
|
||
assert!((f_to_c(212.0) - 100.0).abs() < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn c_to_f_boundaries() {
|
||
assert!((c_to_f(0.0) - 32.0).abs() < 0.01);
|
||
assert!((c_to_f(100.0) - 212.0).abs() < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn absolute_humidity_typical_summer() {
|
||
let ah = absolute_humidity(25.0, 20.0);
|
||
assert!(ah > 15.0 && ah < 20.0, "{ah}");
|
||
}
|
||
|
||
#[test]
|
||
fn absolute_humidity_cold_dry() {
|
||
let ah = absolute_humidity(0.0, -5.0);
|
||
assert!(ah > 2.0 && ah < 5.0, "{ah}");
|
||
}
|
||
|
||
#[test]
|
||
fn wind_speed_3_4_kts() {
|
||
assert!((wind_speed_kts(3.0, 4.0) - 9.72).abs() < 0.1);
|
||
}
|
||
|
||
#[test]
|
||
fn precip_to_rate_handles_nil_zero_negative() {
|
||
assert_eq!(precip_to_rate_mmhr(Some(2.5)), 2.5);
|
||
assert_eq!(precip_to_rate_mmhr(Some(0.0)), 0.0);
|
||
assert_eq!(precip_to_rate_mmhr(Some(-1.0)), 0.0);
|
||
assert_eq!(precip_to_rate_mmhr(None), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn dbz_to_rain_rate_clips_and_interpolates() {
|
||
assert_eq!(dbz_to_rain_rate_mmhr(None), 0.0);
|
||
assert_eq!(dbz_to_rain_rate_mmhr(Some(3.0)), 0.0);
|
||
assert!((dbz_to_rain_rate_mmhr(Some(20.0)) - 0.66).abs() < 0.05);
|
||
assert!((dbz_to_rain_rate_mmhr(Some(30.0)) - 2.7).abs() < 0.2);
|
||
assert!((dbz_to_rain_rate_mmhr(Some(45.0)) - 23.7).abs() < 1.0);
|
||
assert!(dbz_to_rain_rate_mmhr(Some(55.0)) <= 150.0);
|
||
assert!(dbz_to_rain_rate_mmhr(Some(65.0)) <= 150.0);
|
||
}
|
||
|
||
// ── humidity ─────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn humidity_beneficial_table() {
|
||
assert_eq!(score_humidity(3.0, b10g()), 55);
|
||
assert_eq!(score_humidity(9.0, b10g()), 82);
|
||
assert_eq!(score_humidity(16.0, b10g()), 95);
|
||
assert_eq!(score_humidity(20.0, b10g()), 88);
|
||
assert_eq!(score_humidity(25.0, b10g()), 75);
|
||
}
|
||
|
||
#[test]
|
||
fn humidity_harmful_piecewise() {
|
||
assert_eq!(score_humidity(2.0, b24g()), 100);
|
||
assert_eq!(score_humidity(5.0, b24g()), 82);
|
||
assert_eq!(score_humidity(10.0, b24g()), 24);
|
||
assert_eq!(score_humidity(20.0, b24g()), 0);
|
||
}
|
||
|
||
// ── time of day ──────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn time_of_day_dawn_peak() {
|
||
let (s, l) = score_time_of_day(11, 15, 6, EAST_LON);
|
||
assert_eq!(s, 100);
|
||
assert!(l.contains("Peak"));
|
||
}
|
||
|
||
#[test]
|
||
fn time_of_day_afternoon() {
|
||
let (s, _) = score_time_of_day(22, 0, 6, EAST_LON);
|
||
assert_eq!(s, 18);
|
||
}
|
||
|
||
#[test]
|
||
fn time_of_day_pre_dawn() {
|
||
let (s, l) = score_time_of_day(9, 0, 6, EAST_LON);
|
||
assert_eq!(s, 82);
|
||
assert!(l.contains("Pre-dawn"));
|
||
}
|
||
|
||
#[test]
|
||
fn time_of_day_evening() {
|
||
let (s, l) = score_time_of_day(1, 0, 6, EAST_LON);
|
||
assert_eq!(s, 72);
|
||
assert!(l.contains("Evening"));
|
||
}
|
||
|
||
#[test]
|
||
fn time_of_day_western_longitude_shifts_earlier() {
|
||
let (s, _) = score_time_of_day(13, 24, 1, -90.0);
|
||
assert_eq!(s, 100);
|
||
}
|
||
|
||
#[test]
|
||
fn time_of_day_same_utc_different_longitudes() {
|
||
let (east, _) = score_time_of_day(18, 0, 6, -75.0);
|
||
let (west, _) = score_time_of_day(18, 0, 6, -120.0);
|
||
assert!(west > east);
|
||
}
|
||
|
||
// ── td depression ────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn td_beneficial() {
|
||
assert_eq!(score_td_depression(72.0, 70.0, b10g()), 40);
|
||
assert_eq!(score_td_depression(80.0, 70.0, b10g()), 85);
|
||
assert_eq!(score_td_depression(100.0, 50.0, b10g()), 55);
|
||
}
|
||
|
||
#[test]
|
||
fn td_harmful() {
|
||
assert_eq!(score_td_depression(72.0, 70.0, b24g()), 18);
|
||
assert_eq!(score_td_depression(80.0, 70.0, b24g()), 60);
|
||
assert_eq!(score_td_depression(100.0, 50.0, b24g()), 96);
|
||
}
|
||
|
||
// ── refractivity ─────────────────────────────────────────────
|
||
|
||
const BASELINE_BL: Option<f64> = Some(750.0);
|
||
|
||
#[test]
|
||
fn refractivity_strong_gradient_beneficial() {
|
||
assert_eq!(
|
||
score_refractivity(Some(-600.0), BASELINE_BL, None, None, b10g()),
|
||
98
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn refractivity_strong_gradient_harmful() {
|
||
assert_eq!(
|
||
score_refractivity(Some(-250.0), BASELINE_BL, None, None, b24g()),
|
||
85
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn refractivity_moderate_gradient_beneficial() {
|
||
assert_eq!(
|
||
score_refractivity(Some(-160.0), BASELINE_BL, None, None, b10g()),
|
||
92
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn refractivity_nil_gradient_returns_50() {
|
||
assert_eq!(
|
||
score_refractivity(None, BASELINE_BL, None, None, b10g()),
|
||
50
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn refractivity_nil_bl_no_multiplier() {
|
||
assert_eq!(
|
||
score_refractivity(Some(-160.0), None, None, None, b10g()),
|
||
92
|
||
);
|
||
}
|
||
|
||
// HPBL multiplier and native-profile duct boost retired in the
|
||
// 2026-04-25 algo revisions (see `docs/algo-reports/`). Both helpers
|
||
// are gone; the parameter slots remain so existing callers don't need
|
||
// a coordinated rewrite. Tests below pin the post-retirement
|
||
// behaviour: every variant collapses onto the gradient-only score.
|
||
|
||
#[test]
|
||
fn hpbl_no_longer_affects_score() {
|
||
let shallow = score_refractivity(Some(-100.0), Some(150.0), None, None, b10g());
|
||
let baseline = score_refractivity(Some(-100.0), BASELINE_BL, None, None, b10g());
|
||
let deep = score_refractivity(Some(-100.0), Some(2200.0), None, None, b10g());
|
||
assert_eq!(shallow, baseline);
|
||
assert_eq!(deep, baseline);
|
||
}
|
||
|
||
#[test]
|
||
fn hpbl_no_longer_affects_default_fallback() {
|
||
let shallow = score_refractivity(Some(-30.0), Some(150.0), None, None, b10g());
|
||
let baseline = score_refractivity(Some(-30.0), BASELINE_BL, None, None, b10g());
|
||
let deep = score_refractivity(Some(-30.0), Some(2200.0), None, None, b10g());
|
||
assert_eq!(shallow, baseline);
|
||
assert_eq!(deep, baseline);
|
||
}
|
||
|
||
#[test]
|
||
fn native_duct_no_longer_boosts_matching_band() {
|
||
let with_duct = score_refractivity(Some(-80.0), Some(800.0), Some(15.0), None, b10g());
|
||
let plain = score_refractivity(Some(-80.0), Some(800.0), None, None, b10g());
|
||
assert_eq!(with_duct, plain);
|
||
}
|
||
|
||
#[test]
|
||
fn native_duct_below_target_still_matches_plain() {
|
||
let unchanged = score_refractivity(Some(-80.0), Some(800.0), Some(3.0), None, b10g());
|
||
let plain = score_refractivity(Some(-80.0), Some(800.0), None, None, b10g());
|
||
assert_eq!(unchanged, plain);
|
||
}
|
||
|
||
#[test]
|
||
fn richardson_no_longer_gates_anything() {
|
||
let stable = score_refractivity(Some(-80.0), Some(800.0), Some(15.0), Some(12.0), b10g());
|
||
let plain = score_refractivity(Some(-80.0), Some(800.0), None, None, b10g());
|
||
assert_eq!(stable, plain);
|
||
|
||
let turbulent =
|
||
score_refractivity(Some(-80.0), Some(800.0), Some(15.0), Some(40.0), b10g());
|
||
assert_eq!(turbulent, plain);
|
||
|
||
let nil_r = score_refractivity(Some(-80.0), Some(800.0), Some(15.0), None, b10g());
|
||
let four_arity_equiv =
|
||
score_refractivity(Some(-80.0), Some(800.0), Some(15.0), None, b10g());
|
||
assert_eq!(nil_r, four_arity_equiv);
|
||
|
||
let boundary = score_refractivity(Some(-80.0), Some(800.0), Some(15.0), Some(25.0), b10g());
|
||
assert_eq!(boundary, plain);
|
||
}
|
||
|
||
// ── commercial link boost ────────────────────────────────────
|
||
|
||
#[test]
|
||
fn commercial_link_noop_cases() {
|
||
assert_eq!(commercial_link_boost(75, 0, 10.0), 75);
|
||
assert_eq!(commercial_link_boost(75, 3, 2.0), 75);
|
||
}
|
||
|
||
#[test]
|
||
fn commercial_link_mild_boost() {
|
||
let b = commercial_link_boost(75, 3, 5.0);
|
||
assert!(b > 75 && b < 90, "{b}");
|
||
}
|
||
|
||
#[test]
|
||
fn commercial_link_strong_boost_capped() {
|
||
let b = commercial_link_boost(75, 3, 12.0);
|
||
assert!(b > 85 && b <= 100, "{b}");
|
||
}
|
||
|
||
// ── sky ──────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn sky_table() {
|
||
assert_eq!(score_sky(Some(5.0)), 100);
|
||
assert_eq!(score_sky(Some(20.0)), 88);
|
||
assert_eq!(score_sky(Some(40.0)), 60);
|
||
assert_eq!(score_sky(Some(75.0)), 25);
|
||
assert_eq!(score_sky(Some(95.0)), 5);
|
||
assert_eq!(score_sky(None), 50);
|
||
}
|
||
|
||
// ── season ───────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn season_table_basic() {
|
||
assert_eq!(score_season(7, None, None, b10g()), 95);
|
||
assert_eq!(score_season(3, None, None, b10g()), 22);
|
||
assert_eq!(score_season(11, None, None, b24g()), 96);
|
||
assert_eq!(score_season(7, None, None, b24g()), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn season_gulf_coast_august_boost() {
|
||
let no_region = score_season(8, None, None, b10g());
|
||
let gulf = score_season(8, Some(29.0), Some(-95.0), b10g());
|
||
assert!(gulf > no_region);
|
||
}
|
||
|
||
#[test]
|
||
fn season_corn_belt_august_penalty() {
|
||
let no_region = score_season(8, None, None, b10g());
|
||
let iowa = score_season(8, Some(42.0), Some(-93.0), b10g());
|
||
assert!(iowa < no_region);
|
||
}
|
||
|
||
// ── wind ─────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn wind_table() {
|
||
assert_eq!(score_wind(Some(3.0)), 100);
|
||
assert_eq!(score_wind(Some(8.0)), 90);
|
||
assert_eq!(score_wind(Some(12.0)), 75);
|
||
assert_eq!(score_wind(Some(22.0)), 35);
|
||
assert_eq!(score_wind(Some(30.0)), 15);
|
||
assert_eq!(score_wind(None), 50);
|
||
}
|
||
|
||
// ── rain ─────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn rain_zero_and_nil_full_score() {
|
||
assert_eq!(score_rain(Some(0.0), b24g()), 100);
|
||
assert_eq!(score_rain(None, b24g()), 100);
|
||
}
|
||
|
||
#[test]
|
||
fn rain_24g_light() {
|
||
// gamma = 0.070 * 2.0^1.07 ≈ 0.147 → 75
|
||
assert_eq!(score_rain(Some(2.0), b24g()), 75);
|
||
}
|
||
|
||
#[test]
|
||
fn rain_10g_heavy_still_high() {
|
||
// gamma = 0.010 * 5.0^1.28 ≈ 0.076 → 95
|
||
assert_eq!(score_rain(Some(5.0), b10g()), 95);
|
||
}
|
||
|
||
#[test]
|
||
fn rain_75g_heavy_drops() {
|
||
// gamma = 0.345 * 10^0.84 ≈ 2.39 → 10
|
||
assert_eq!(score_rain(Some(10.0), b75g()), 10);
|
||
}
|
||
|
||
// ── pwat ─────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn pwat_beneficial_table() {
|
||
assert_eq!(score_pwat(Some(5.0), b10g()), 55);
|
||
assert_eq!(score_pwat(Some(15.0), b10g()), 75);
|
||
assert_eq!(score_pwat(Some(25.0), b10g()), 90);
|
||
assert_eq!(score_pwat(Some(35.0), b10g()), 70);
|
||
assert_eq!(score_pwat(Some(45.0), b10g()), 50);
|
||
}
|
||
|
||
#[test]
|
||
fn pwat_harmful_table() {
|
||
assert_eq!(score_pwat(Some(5.0), b24g()), 95);
|
||
assert_eq!(score_pwat(Some(15.0), b24g()), 80);
|
||
assert_eq!(score_pwat(Some(25.0), b24g()), 60);
|
||
assert_eq!(score_pwat(Some(35.0), b24g()), 35);
|
||
assert_eq!(score_pwat(Some(45.0), b24g()), 15);
|
||
}
|
||
|
||
#[test]
|
||
fn pwat_nil_returns_60() {
|
||
assert_eq!(score_pwat(None, b10g()), 60);
|
||
assert_eq!(score_pwat(None, b24g()), 60);
|
||
}
|
||
|
||
// ── pressure ─────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn pressure_standalone_table() {
|
||
assert_eq!(score_pressure(Some(1028.0), None), 30);
|
||
assert_eq!(score_pressure(Some(1020.0), None), 30);
|
||
assert_eq!(score_pressure(Some(1000.0), None), 55);
|
||
}
|
||
|
||
#[test]
|
||
fn pressure_deltas() {
|
||
assert_eq!(score_pressure(Some(1020.0), Some(1015.0)), 80);
|
||
assert_eq!(score_pressure(Some(1016.0), Some(1015.0)), 70);
|
||
assert_eq!(score_pressure(Some(1015.0), Some(1015.3)), 60);
|
||
assert_eq!(score_pressure(Some(1014.0), Some(1015.0)), 65);
|
||
assert_eq!(score_pressure(Some(1010.0), Some(1015.0)), 45);
|
||
}
|
||
|
||
// ── composite ────────────────────────────────────────────────
|
||
|
||
fn canonical_conditions() -> Conditions {
|
||
Conditions {
|
||
abs_humidity: 10.0,
|
||
temp_f: 80.0,
|
||
dewpoint_f: 70.0,
|
||
wind_speed_kts: Some(5.0),
|
||
sky_cover_pct: Some(20.0),
|
||
utc_hour: 11,
|
||
utc_minute: 15,
|
||
month: 6,
|
||
longitude: -75.0,
|
||
latitude: None,
|
||
pressure_mb: Some(1020.0),
|
||
prev_pressure_mb: Some(1018.0),
|
||
rain_rate_mmhr: Some(0.0),
|
||
min_refractivity_gradient: Some(-350.0),
|
||
bl_depth_m: Some(400.0),
|
||
pwat_mm: Some(25.0),
|
||
best_duct_band_ghz: None,
|
||
bulk_richardson: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn composite_returns_0_100_score() {
|
||
let r = composite_score(&canonical_conditions(), b10g());
|
||
assert!(r.score >= 0 && r.score <= 100);
|
||
}
|
||
|
||
#[test]
|
||
fn composite_all_factors_populated() {
|
||
let r = composite_score(&canonical_conditions(), b10g());
|
||
let f = &r.factors;
|
||
for s in [
|
||
f.humidity,
|
||
f.time_of_day,
|
||
f.td_depression,
|
||
f.refractivity,
|
||
f.sky,
|
||
f.season,
|
||
f.wind,
|
||
f.rain,
|
||
f.pwat,
|
||
f.pressure,
|
||
] {
|
||
assert!((0..=100).contains(&s), "factor out of range: {s}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn precomputed_invariants_match_fresh() {
|
||
let c = canonical_conditions();
|
||
let inv = precompute_band_invariants(&c);
|
||
let fresh = composite_score(&c, b10g());
|
||
let reused = composite_score_with(&c, b10g(), Some(inv));
|
||
assert_eq!(fresh.factors.time_of_day, reused.factors.time_of_day);
|
||
assert_eq!(fresh.factors.sky, reused.factors.sky);
|
||
assert_eq!(fresh.factors.wind, reused.factors.wind);
|
||
assert_eq!(fresh.factors.pressure, reused.factors.pressure);
|
||
assert_eq!(fresh.score, reused.score);
|
||
}
|
||
|
||
#[test]
|
||
fn composite_uses_band_weights() {
|
||
let r = composite_score(&canonical_conditions(), b10g());
|
||
let w = DEFAULT_WEIGHTS;
|
||
let expected = r.factors.humidity as f64 * w.humidity
|
||
+ r.factors.time_of_day as f64 * w.time_of_day
|
||
+ r.factors.td_depression as f64 * w.td_depression
|
||
+ r.factors.refractivity as f64 * w.refractivity
|
||
+ r.factors.sky as f64 * w.sky
|
||
+ r.factors.season as f64 * w.season
|
||
+ r.factors.wind as f64 * w.wind
|
||
+ r.factors.rain as f64 * w.rain
|
||
+ r.factors.pwat as f64 * w.pwat
|
||
+ r.factors.pressure as f64 * w.pressure;
|
||
assert!((r.score as f64 - expected.round()).abs() <= 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn composite_10g_vs_24g_differ() {
|
||
let c = canonical_conditions();
|
||
let r10 = composite_score(&c, b10g());
|
||
let r24 = composite_score(&c, b24g());
|
||
assert_ne!(r10.score, r24.score);
|
||
}
|
||
}
|