feat(prop-grid-rs): port Commercial.build_link_lookup to Rust
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.
This commit is contained in:
parent
b161ed4b3f
commit
9f36e78397
2 changed files with 267 additions and 0 deletions
266
rust/prop_grid_rs/src/commercial.rs
Normal file
266
rust/prop_grid_rs/src/commercial.rs
Normal file
|
|
@ -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<chrono::Utc>,
|
||||
) -> Result<Vec<LinkLookupEntry>, 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<chrono::Utc>,
|
||||
current_cutoff: chrono::DateTime<chrono::Utc>,
|
||||
valid_time: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<Option<(f64, f64)>, CommercialError> {
|
||||
// Baseline: average rx_power_0 over the 7-day window ending at
|
||||
// current_cutoff. sqlx decodes AVG(numeric) as Option<BigDecimal>
|
||||
// by default; reading it as f64 keeps the downstream math simple.
|
||||
let baseline: Option<f64> = 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<f64> = 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<Degradation> {
|
||||
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::<f64>() / n;
|
||||
let current_avg: f64 = in_range.iter().map(|e| e.current_dbm).sum::<f64>() / 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue