//! 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, pub sky_cover_pct: Option, pub utc_hour: u8, pub utc_minute: u8, pub month: u8, pub longitude: f64, pub latitude: Option, pub pressure_mb: Option, pub prev_pressure_mb: Option, pub rain_rate_mmhr: Option, pub min_refractivity_gradient: Option, pub bl_depth_m: Option, pub pwat_mm: Option, pub best_duct_band_ghz: Option, pub bulk_richardson: Option, } #[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 { 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 { 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, bl_depth_m: Option, best_duct_band_ghz: Option, bulk_richardson: Option, 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) -> 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, bulk_richardson: Option, 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) -> 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, lon: Option, 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) -> 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, 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, 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, previous_mb: Option) -> 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, ) -> 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 = 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); } }