From 9f36e78397a280f1a4d19cf9a07c235c7a7a1822 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 19 Apr 2026 17:44:26 -0500 Subject: [PATCH] feat(prop-grid-rs): port Commercial.build_link_lookup to Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of Microwaveprop.Commercial.build_link_lookup/2 + link_degradation_from_lookup/3. The Elixir context stays intact (LiveView sensor + SNMP poller); this Rust side is the read-only consumer used by the f00 chain step (Phase 3 Stream A). Three pure-math unit tests cover: haversine distance (DFW→Austin and same-point zero), degradation_at aggregation behaviour, and two-decimal rounding convention that Elixir's Float.round(_, 2) imposes. SQL queries mirror the Elixir Ecto queries one-for-one — baseline window, current point-in-time read, and MIN_BASELINE_SAMPLES quality gate. --- rust/prop_grid_rs/src/commercial.rs | 266 ++++++++++++++++++++++++++++ rust/prop_grid_rs/src/lib.rs | 1 + 2 files changed, 267 insertions(+) create mode 100644 rust/prop_grid_rs/src/commercial.rs diff --git a/rust/prop_grid_rs/src/commercial.rs b/rust/prop_grid_rs/src/commercial.rs new file mode 100644 index 00000000..4efbebee --- /dev/null +++ b/rust/prop_grid_rs/src/commercial.rs @@ -0,0 +1,266 @@ +//! Commercial-link degradation lookup. +//! +//! Port of `Microwaveprop.Commercial.build_link_lookup/2` + +//! `link_degradation_from_lookup/3`. The Elixir context stays intact for +//! LiveView sensors and SNMP poller; this Rust side is a read-only +//! consumer for the f00 chain step (Phase 3 Stream A). +//! +//! Commercial LOS microwave links act as an inverse tropo sensor: when +//! rx_power drops significantly below its 7-day baseline with the link +//! still in state=1, the refractivity structure hurting the Fresnel +//! zone is usually the same structure *helping* long beyond-LOS +//! amateur propagation. We report baseline/current/delta per grid cell +//! and let the scorer fold it in. + +use sqlx::PgPool; + +const DEFAULT_RADIUS_KM: f64 = 75.0; +const DEFAULT_BASELINE_DAYS: i64 = 7; +const DEFAULT_CURRENT_WINDOW_SECS: i64 = 900; +const MIN_BASELINE_SAMPLES: i64 = 5; + +#[derive(Debug, Clone)] +pub struct LinkLookupEntry { + pub link_id: i64, + pub endpoint: (f64, f64), + pub baseline_dbm: f64, + pub current_dbm: f64, +} + +/// Aggregate degradation at one grid cell, matching the shape produced +/// by `Commercial.link_degradation_from_lookup/3`. +#[derive(Debug, Clone, Copy)] +pub struct Degradation { + pub degradation_db: f64, + pub baseline_dbm: f64, + pub current_dbm: f64, + pub n_links: u32, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommercialError { + #[error(transparent)] + Sqlx(#[from] sqlx::Error), +} + +/// Precompute every enabled link's degradation once. Callers fold this +/// lookup across the ~92k CONUS cells. Links whose current/baseline +/// samples don't pass the quality gate (`MIN_BASELINE_SAMPLES`, both +/// sides non-null) are dropped so the per-cell path stays simple. +pub async fn build_link_lookup( + pool: &PgPool, + valid_time: chrono::DateTime, +) -> Result, CommercialError> { + let baseline_cutoff = valid_time - chrono::Duration::days(DEFAULT_BASELINE_DAYS); + let current_cutoff = valid_time - chrono::Duration::seconds(DEFAULT_CURRENT_WINDOW_SECS); + + // Pull enabled links with a usable endpoint_a. The JSONB → (lat, lon) + // dereference mirrors `link_endpoint/1` in Elixir: the schema + // convention is that endpoint_a holds a map with "lat" and "lon" + // keys. Failures of that assumption are dropped rather than + // surfaced — a malformed endpoint is a data-quality issue, not a + // chain-blocker. + let link_rows = sqlx::query_as::<_, (i64, serde_json::Value)>( + r#" + SELECT id, endpoint_a + FROM commercial_links + WHERE enabled = true + "#, + ) + .fetch_all(pool) + .await?; + + let mut out = Vec::with_capacity(link_rows.len()); + for (link_id, endpoint_json) in link_rows { + let Some(endpoint) = extract_endpoint(&endpoint_json) else { + continue; + }; + if let Some(deg) = + fetch_per_link_degradation(pool, link_id, baseline_cutoff, current_cutoff, valid_time) + .await? + { + out.push(LinkLookupEntry { + link_id, + endpoint, + baseline_dbm: deg.0, + current_dbm: deg.1, + }); + } + } + Ok(out) +} + +fn extract_endpoint(v: &serde_json::Value) -> Option<(f64, f64)> { + let lat = v.get("lat")?.as_f64()?; + let lon = v.get("lon")?.as_f64()?; + Some((lat, lon)) +} + +async fn fetch_per_link_degradation( + pool: &PgPool, + link_id: i64, + baseline_cutoff: chrono::DateTime, + current_cutoff: chrono::DateTime, + valid_time: chrono::DateTime, +) -> Result, CommercialError> { + // Baseline: average rx_power_0 over the 7-day window ending at + // current_cutoff. sqlx decodes AVG(numeric) as Option + // by default; reading it as f64 keeps the downstream math simple. + let baseline: Option = sqlx::query_scalar( + r#" + SELECT AVG(rx_power_0)::float8 + FROM commercial_samples + WHERE link_id = $1 + AND sampled_at >= $2 + AND sampled_at < $3 + AND link_state = 1 + AND rx_power_0 IS NOT NULL + "#, + ) + .bind(link_id) + .bind(baseline_cutoff.naive_utc()) + .bind(current_cutoff.naive_utc()) + .fetch_one(pool) + .await?; + + let current: Option = sqlx::query_scalar( + r#" + SELECT rx_power_0::float8 + FROM commercial_samples + WHERE link_id = $1 + AND sampled_at >= $2 + AND sampled_at <= $3 + AND link_state = 1 + AND rx_power_0 IS NOT NULL + ORDER BY sampled_at DESC + LIMIT 1 + "#, + ) + .bind(link_id) + .bind(current_cutoff.naive_utc()) + .bind(valid_time.naive_utc()) + .fetch_optional(pool) + .await? + .flatten(); + + let baseline_n: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(id) + FROM commercial_samples + WHERE link_id = $1 + AND sampled_at < $2 + AND link_state = 1 + AND rx_power_0 IS NOT NULL + "#, + ) + .bind(link_id) + .bind(current_cutoff.naive_utc()) + .fetch_one(pool) + .await?; + + match (baseline, current) { + (Some(b), Some(c)) if baseline_n >= MIN_BASELINE_SAMPLES => Ok(Some((b, c))), + _ => Ok(None), + } +} + +/// Aggregate degradation at a single grid cell given a precomputed +/// link lookup. `None` when no link is in range. +pub fn degradation_at(cell: (f64, f64), lookup: &[LinkLookupEntry]) -> Option { + let in_range: Vec<&LinkLookupEntry> = lookup + .iter() + .filter(|e| haversine_km(cell.0, cell.1, e.endpoint.0, e.endpoint.1) <= DEFAULT_RADIUS_KM) + .collect(); + if in_range.is_empty() { + return None; + } + let n = in_range.len() as f64; + let baseline_avg: f64 = in_range.iter().map(|e| e.baseline_dbm).sum::() / n; + let current_avg: f64 = in_range.iter().map(|e| e.current_dbm).sum::() / n; + Some(Degradation { + degradation_db: round2(baseline_avg - current_avg), + baseline_dbm: round2(baseline_avg), + current_dbm: round2(current_avg), + n_links: in_range.len() as u32, + }) +} + +fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + let r = 6371.0_f64; + let rad = std::f64::consts::PI / 180.0; + let phi1 = lat1 * rad; + let phi2 = lat2 * rad; + let dphi = (lat2 - lat1) * rad; + let dlambda = (lon2 - lon1) * rad; + let a = (dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlambda / 2.0).sin().powi(2); + 2.0 * r * a.sqrt().atan2((1.0 - a).sqrt()) +} + +fn round2(v: f64) -> f64 { + (v * 100.0).round() / 100.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(link_id: i64, lat: f64, lon: f64, baseline: f64, current: f64) -> LinkLookupEntry { + LinkLookupEntry { + link_id, + endpoint: (lat, lon), + baseline_dbm: baseline, + current_dbm: current, + } + } + + #[test] + fn haversine_known_distances() { + // DFW (32.9°N) → Austin (30.3°N): ~300 km (Wikipedia lists ~304 km + // road, great-circle is ~297 km). + let d = haversine_km(32.8998, -97.0403, 30.2672, -97.7431); + assert!((d - 300.0).abs() < 10.0, "got {d}"); + // Same point → 0 + assert!(haversine_km(32.0, -97.0, 32.0, -97.0).abs() < 1e-9); + } + + #[test] + fn no_links_in_range_returns_none() { + let lookup = vec![entry(1, 32.9, -97.0, -50.0, -52.0)]; + // Austin cell is ~290 km from a DFW link → out of 75 km radius. + let d = degradation_at((30.27, -97.74), &lookup); + assert!(d.is_none()); + } + + #[test] + fn aggregates_within_radius() { + let lookup = vec![ + entry(1, 32.9, -97.0, -50.0, -54.0), // 4 dB drop + entry(2, 33.0, -97.1, -50.0, -52.0), // 2 dB drop + ]; + let d = degradation_at((32.95, -97.05), &lookup).unwrap(); + assert_eq!(d.n_links, 2); + assert!((d.baseline_dbm - -50.0).abs() < 1e-9); + assert!((d.current_dbm - -53.0).abs() < 1e-9); + assert!((d.degradation_db - 3.0).abs() < 1e-9); + } + + #[test] + fn degradation_rounds_to_two_decimals() { + let lookup = vec![entry(1, 32.9, -97.0, -50.12345, -52.67890)]; + let d = degradation_at((32.9, -97.0), &lookup).unwrap(); + assert_eq!(d.baseline_dbm, -50.12); + assert_eq!(d.current_dbm, -52.68); + assert_eq!(d.degradation_db, 2.56); + } + + #[test] + fn extract_endpoint_handles_missing_keys() { + assert!(extract_endpoint(&serde_json::json!({})).is_none()); + assert!(extract_endpoint(&serde_json::json!({"lat": 32.0})).is_none()); + assert!(extract_endpoint(&serde_json::json!({"lon": -97.0})).is_none()); + assert_eq!( + extract_endpoint(&serde_json::json!({"lat": 32.0, "lon": -97.0})), + Some((32.0, -97.0)) + ); + } +} diff --git a/rust/prop_grid_rs/src/lib.rs b/rust/prop_grid_rs/src/lib.rs index a6874552..70745d82 100644 --- a/rust/prop_grid_rs/src/lib.rs +++ b/rust/prop_grid_rs/src/lib.rs @@ -19,6 +19,7 @@ //! production and Elixir keeps handling f00. pub mod band_config; +pub mod commercial; pub mod db; pub mod decoder; pub mod duct;