prop/rust/prop_grid_rs/tests/json_weights_golden.rs
Graham McIntire 2fd88a94ea
Some checks failed
Build and Push / Build and Push Docker Image (push) Waiting to run
Build prop-grid-rs / Test, build, push (push) Failing after 3m41s
fix(propagation): 7 pipeline bugs + validation harnesses + model improvements
Phase A — 7 pipeline bugs:
- retain_scores_window: fix timeline self-deletion (NOTIFY payload run_time|valid_time)
- Rust/Elixir weight divergence: Rust loads band_weights.json at startup
- Aurora boost ported to Rust (Kp query + per-cell boost for bands <= 432 MHz)
- Commercial-link boost applied in Rust per-cell scoring
- f00 native gradient preferred over pressure-level gradient
- HRDPS files: greedy regex fixed, now visible to timeline/prune/retain
- GEFS/HRRR collision: GEFS namespace as .gefs.prop, merge at read

Phase B — Validation harness:
- scripts/validate_algo.py: out-of-sample Spearman rho(score,distance) + baselines
- docs/algo-reports/validation-2026-08-01.{json,md}

Phase C — Forecast-skill evaluation:
- scripts/validate_forecast.py: skill degradation by lead time (0h-24h)
- docs/algo-reports/forecast-2026-08-01.{json,md}

Phase D — Calibration + model improvements:
- recalibrate.py: validation gate before weight deploy
- Recalibrator: Nx.max(0.0) replaces Nx.abs(), L2 regularization, val-set integrity
- ML train/serve defaults unified; prop_compare null-handling skew fixed
- Latitude-aware sunrise: solar-declination sunrise_hour(lat,month) (Elixir + Rust)
- Path scoring: wind/sky/rain from HRRR (was ~30% of composite weight silent)
- Region multiplier documented as unvalidated; PWAT/refractivity doc-vs-code noted
- algo.md: synced scoring sections, marked retired features, D5/D6 changelog
- Credo: path_compute cyclomatic complexity bumped (6->10 fields) — informational only
2026-08-01 19:28:41 -05:00

148 lines
4.9 KiB
Rust

//! Golden test verifying that per-band weight resolution matches the
//! JSON produced by `scripts/recalibrate.py`.
//!
//! This test sets `PROP_BAND_WEIGHTS_JSON` before any weights lookup so
//! the `OnceLock` cache in `band_config` seeds from the real JSON file.
//! It runs in a separate test binary from `scorer_golden.rs`, so the
//! env var does not affect the existing golden-fixture test.
use std::collections::HashMap;
use serde_json::Value;
use prop_grid_rs::band_config::{self, Weights};
// ── Helpers ──────────────────────────────────────────────────────────
fn weights_sum(w: &Weights) -> f64 {
w.humidity
+ w.time_of_day
+ w.td_depression
+ w.refractivity
+ w.sky
+ w.season
+ w.wind
+ w.rain
+ w.pressure
+ w.pwat
}
fn parse_json_override_weights(json_path: &str) -> HashMap<u32, Weights> {
let data = std::fs::read_to_string(json_path).expect("JSON file must exist");
let root: Value = serde_json::from_str(&data).expect("valid JSON");
let overrides = root["band_overrides"]
.as_object()
.expect("band_overrides must be an object");
let mut map = HashMap::new();
for (freq_str, entry) in overrides {
let freq: u32 = freq_str.parse().expect("band override key must be a u32");
let w = &entry["weights"];
let humidity = w["humidity"].as_f64().expect("humidity");
let time_of_day = w["time_of_day"].as_f64().expect("time_of_day");
let td_depression = w["td_depression"].as_f64().expect("td_depression");
let refractivity = w["refractivity"].as_f64().expect("refractivity");
let sky = w["sky"].as_f64().expect("sky");
let season = w["season"].as_f64().expect("season");
let wind = w["wind"].as_f64().expect("wind");
let rain = w["rain"].as_f64().expect("rain");
let pwat = w["pwat"].as_f64().expect("pwat");
let pressure = w["pressure"].as_f64().expect("pressure");
map.insert(
freq,
Weights::new(
humidity,
time_of_day,
td_depression,
refractivity,
sky,
season,
wind,
rain,
pressure,
pwat,
),
);
}
map
}
fn assert_weights_equal(label: &str, got: &Weights, expected: &Weights) {
let tolerance = 1e-9;
let factors: [(&str, f64, f64); 10] = [
("humidity", got.humidity, expected.humidity),
("time_of_day", got.time_of_day, expected.time_of_day),
("td_depression", got.td_depression, expected.td_depression),
("refractivity", got.refractivity, expected.refractivity),
("sky", got.sky, expected.sky),
("season", got.season, expected.season),
("wind", got.wind, expected.wind),
("rain", got.rain, expected.rain),
("pressure", got.pressure, expected.pressure),
("pwat", got.pwat, expected.pwat),
];
for (name, g, e) in &factors {
assert!(
(g - e).abs() < tolerance,
"{label} factor {name}: got {g}, expected {e}"
);
}
}
// ── Tests ────────────────────────────────────────────────────────────
/// Set env var before any test accesses the OnceLock cache. `std::sync::Once`
/// ensures this runs exactly once even if the test runner spawns multiple
/// threads for `#[test]` functions in this binary.
static ENSURE_ENV: std::sync::Once = std::sync::Once::new();
fn ensure_json_env() {
ENSURE_ENV.call_once(|| {
std::env::set_var(
"PROP_BAND_WEIGHTS_JSON",
"../../priv/algo/band_weights.json",
);
});
}
#[test]
fn json_override_weights_match_file() {
ensure_json_env();
let json_path = "../../priv/algo/band_weights.json";
let expected = parse_json_override_weights(json_path);
for band in band_config::all_bands() {
let w = band.weights();
// Every weight vector must sum to ~1.0
let sum = weights_sum(&w);
assert!(
(sum - 1.0).abs() < 0.001,
"band {} MHz weights sum = {sum} (expected 1.0 ± 0.001)",
band.freq_mhz,
);
// If JSON has an override for this band, it must match exactly
if let Some(exp) = expected.get(&band.freq_mhz) {
assert_weights_equal(&format!("band {} MHz", band.freq_mhz), &w, exp);
}
}
}
#[test]
fn all_bands_weights_sum_to_one() {
ensure_json_env();
for band in band_config::all_bands() {
let w = band.weights();
let sum = weights_sum(&w);
assert!(
(sum - 1.0).abs() < 0.001,
"band {} MHz weights sum = {sum}",
band.freq_mhz,
);
}
}