prop/rust/prop_grid_rs/src/scorer.rs

980 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;
const BULK_RICHARDSON_STABLE_MAX: f64 = 25.0;
/// 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 (+ HPBL multiplier + native duct boost) ──
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);
let base = 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
}
}
};
let with_hpbl = apply_hpbl_multiplier(base, bl_depth_m);
apply_native_duct_boost(
with_hpbl,
best_duct_band_ghz,
bulk_richardson,
band.freq_mhz,
)
}
fn apply_hpbl_multiplier(base: i32, hpbl: Option<f64>) -> i32 {
let mul = match hpbl {
None => return base,
Some(v) if v < 200.0 => 1.10,
Some(v) if v < 500.0 => 1.05,
Some(v) if v < 1500.0 => 1.0,
Some(v) if v < 2000.0 => 0.92,
Some(_) => 0.78,
};
clamp_score_f(base as f64 * mul)
}
fn apply_native_duct_boost(
base: i32,
best_duct_band_ghz: Option<f64>,
bulk_richardson: Option<f64>,
freq_mhz: u32,
) -> i32 {
let Some(duct_band_ghz) = best_duct_band_ghz else {
return base;
};
let target_ghz = freq_mhz as f64 / 1000.0;
let stable = match bulk_richardson {
None => true,
Some(r) => r < BULK_RICHARDSON_STABLE_MAX,
};
if duct_band_ghz >= target_ghz && stable {
clamp_score_f(base as f64 * 1.15)
} else {
base
}
}
// ── 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
);
}
#[test]
fn shallow_bl_boosts_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());
assert!(shallow > baseline);
assert!(shallow <= 100);
}
#[test]
fn deep_bl_penalises_score() {
let deep = score_refractivity(Some(-100.0), Some(2200.0), None, None, b10g());
let baseline = score_refractivity(Some(-100.0), BASELINE_BL, None, None, b10g());
assert!(deep < baseline);
assert!(deep >= 0);
}
#[test]
fn shallow_bl_also_lifts_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());
assert!(shallow > baseline);
}
#[test]
fn native_duct_boosts_matching_band() {
let boosted = 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!(boosted > plain);
assert!(boosted <= 100);
}
#[test]
fn native_duct_below_target_no_boost() {
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_gates_the_boost() {
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!(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);
}
}