prop/rust/prop_grid_rs/src/scorer.rs
Graham McIntire 3a1e79fb67
feat(scorer): retire HPBL multiplier and native-duct 1.15× boost
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`.
2026-04-25 12:42:56 -05:00

944 lines
31 KiB
Rust
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.

//! 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);
}
}