prop/rust/prop_grid_rs/src/region.rs
Graham McIntire 6aa91e7656
fix: April 2026 codebase review — address 13 bugs across propagation chain
Each fix is covered by a regression test that fails on `main` and
passes on this commit.

Round 1 (initial review):

* propagation: thread `latitude` into the conditions map so
  `score_season/4` actually picks up regional multipliers
* hrrr_client / fetcher.rs: `nearest_hrrr_hour` rounds DOWN, never at
  a future cycle that NOAA hasn't published yet
* radio: spherical-vector great-circle midpoint replaces the
  arithmetic mean — anti-meridian paths no longer fold to Greenwich
* weather: `reconcile_weather_statuses` scales the longitude band by
  `1 / cos(lat)` so the bbox stays ~150 km wide at every latitude
* radio/maidenhead: clamp 90°/180° below the field-bucket overflow so
  `from_latlon` never emits invalid characters like 'S'
* prop_grid_rs/pipeline: merge HRRR + NEXRAD-derived rain rates and
  read `best_duct_freq_ghz` into `best_duct_band_ghz` so the Native
  Duct Boost actually fires
* propagation/region (Elixir + Rust): inclusive upper bounds so points
  exactly at lat_max get the regional multiplier
* weather/sounding_params (Elixir + Rust): drop the 10 m gradient
  floor so HRRR's thin near-surface layers stop hiding sharp ducts
* weather/sounding_params: when the profile ends inside a duct,
  finalize it with the highest sample as the top instead of throwing
  it away (Rust port already correct)

Round 2 (post-fix sweep):

* radio + commercial: single canonical haversine in Radio (atan2
  form); Commercial delegates instead of carrying a second copy that
  could disagree at threshold distances
* prop_grid_rs/profiles_file: `snap_coords` matches Elixir's
  step-aware snap (`round(coord/0.125) * 0.125`, then 3-dp round) so
  Rust-keyed and Elixir-keyed profile maps land on the same cell
* weather/grib2/wgrib2: `parse_lon_val_segment` uses `Float.parse`
  uniformly — wgrib2 dropping the trailing `.0` from a longitude no
  longer crashes the whole chain step
2026-04-25 10:52:51 -05:00

116 lines
4 KiB
Rust

//! Climatological region lookup. 1:1 port of
//! `lib/microwaveprop/propagation/region.ex`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region {
GulfCoast,
Southeast,
SouthernPlains,
CornBelt,
Northeast,
DesertSouthwest,
PacificNorthwest,
MountainWest,
Other,
}
/// Lat min, lat max, lon min, lon max — all inclusive on both ends so
/// that points sitting exactly on a region's upper boundary still pick
/// up the regional multiplier instead of falling through to `Other`.
struct BBox(Region, f64, f64, f64, f64);
// Ordered to match Elixir's `Enum.find_value` short-circuit.
const REGIONS: &[BBox] = &[
BBox(Region::GulfCoast, 25.0, 32.0, -100.0, -80.0),
BBox(Region::Southeast, 30.0, 37.0, -90.0, -75.0),
BBox(Region::SouthernPlains, 32.0, 38.0, -105.0, -93.0),
BBox(Region::CornBelt, 38.0, 48.0, -100.0, -82.0),
BBox(Region::Northeast, 38.0, 48.0, -82.0, -67.0),
BBox(Region::DesertSouthwest, 30.0, 38.0, -120.0, -105.0),
BBox(Region::PacificNorthwest, 42.0, 50.0, -125.0, -115.0),
BBox(Region::MountainWest, 38.0, 48.0, -115.0, -100.0),
];
pub fn for_point(lat: f64, lon: f64) -> Region {
for bbox in REGIONS {
let BBox(name, lat_min, lat_max, lon_min, lon_max) = *bbox;
if lat >= lat_min && lat <= lat_max && lon >= lon_min && lon <= lon_max {
return name;
}
}
Region::Other
}
/// Multiplier applied to the band's `seasonal_base` score. Unknown
/// region/month combinations return 1.0. Range [0.7, 1.3].
pub fn seasonal_adjustment(region: Region, month: u8) -> f64 {
match (region, month) {
(Region::GulfCoast, 6) => 0.95,
(Region::GulfCoast, 7) => 0.95,
(Region::GulfCoast, 8) => 1.15,
(Region::GulfCoast, 9) => 1.10,
(Region::CornBelt, 6) => 1.05,
(Region::CornBelt, 7) => 0.85,
(Region::CornBelt, 8) => 0.80,
(Region::CornBelt, 9) => 0.95,
(Region::Southeast, 7) => 0.97,
(Region::Southeast, 8) => 1.08,
(Region::SouthernPlains, 8) => 1.05,
(Region::DesertSouthwest, 7) => 1.10,
(Region::DesertSouthwest, 8) => 1.10,
(Region::DesertSouthwest, 9) => 1.05,
(Region::PacificNorthwest, 6) => 1.15,
(Region::PacificNorthwest, 7) => 1.20,
(Region::PacificNorthwest, 8) => 1.20,
(Region::PacificNorthwest, 9) => 1.10,
_ => 1.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gulf_coast_points() {
// Houston ≈ (29.8, -95.4) — Gulf Coast bbox.
assert_eq!(for_point(29.8, -95.4), Region::GulfCoast);
}
#[test]
fn corn_belt_points() {
// Des Moines ≈ (41.6, -93.6) — Corn Belt bbox.
assert_eq!(for_point(41.6, -93.6), Region::CornBelt);
}
#[test]
fn non_conus_returns_other() {
// Alaska (61.2, -149.9) — outside every bbox.
assert_eq!(for_point(61.2, -149.9), Region::Other);
}
#[test]
fn adjustments_match_elixir_table() {
assert_eq!(seasonal_adjustment(Region::GulfCoast, 8), 1.15);
assert_eq!(seasonal_adjustment(Region::CornBelt, 7), 0.85);
assert_eq!(seasonal_adjustment(Region::PacificNorthwest, 7), 1.20);
assert_eq!(seasonal_adjustment(Region::Other, 8), 1.0);
assert_eq!(seasonal_adjustment(Region::CornBelt, 1), 1.0);
}
#[test]
fn precedence_matches_elixir_ordering() {
// (30.5, -90.0) is inside BOTH gulf_coast (25..32, -100..-80) and
// southeast (30..37, -90..-75). Elixir hits gulf_coast first.
assert_eq!(for_point(30.5, -90.0), Region::GulfCoast);
}
#[test]
fn includes_upper_boundary_points() {
// CornBelt is 38.0..48.0 lat; PacificNorthwest is 42.0..50.0.
// Half-open bounds dropped points sitting exactly at the upper
// edge into Region::Other and stripped their seasonal multiplier.
assert_ne!(for_point(48.0, -93.0), Region::Other);
assert_ne!(for_point(50.0, -122.0), Region::Other);
}
}