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
116 lines
4 KiB
Rust
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);
|
|
}
|
|
}
|