980 lines
31 KiB
Rust
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);
|
|
}
|
|
}
|