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
148 lines
4.9 KiB
Rust
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,
|
|
);
|
|
}
|
|
}
|