mirror of
https://github.com/ruvnet/RuView
synced 2026-08-04 19:31:42 +00:00
e1f4897269
* fix(geo numerical robustness): parse_hgt underflow panic + haversine asin-domain NaN Targeted numerical-robustness audit of wifi-densepose-geo (ADR-154-class sweep). Two real bugs, each pinned by a fails-on-old test: 1. terrain.rs parse_hgt — usize underflow panic on degenerate input. `side = sqrt(n_samples)`; for empty / sub-2x2 buffers side <= 1, so `1.0 / (side - 1)` underflows `usize` (panic "attempt to subtract with overflow" in debug; wraps to a huge value in release → garbage/inf cell_size_deg that poisons every ElevationGrid::get). A truncated HTTP body or a 404 HTML page reaches parse_hgt. Now bails with a clear error when side < 2. 2. coord.rs haversine — asin domain overflow → NaN for (near-)antipodal points. Floating rounding can push `h.sqrt()` to 1.0 + ~4e-16, and `asin(>1)` is NaN (verified: pair (-44.4994,-178.95722)→(44.49939999, 1.04278001) yields h=1.0000000000000004). A NaN distance silently breaks all downstream `<`/`>` comparisons. Clamp into [0,1] before asin. Also pins the ±90° pole-singularity (cos(lat)=0 division) as no-panic; the ENU transform itself is unchanged (no behavior change for valid inputs). Tests: wifi-densepose-geo 9→15 lib (6 new), 8 integration unchanged. 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * test(pointcloud robustness): pin NaN-state-poisoning resistance + degenerate voxel fusion Numerical-robustness audit of wifi-densepose-pointcloud. No bug found — the crate is confirmed-robust against the proven NaN-state-poisoning class that bit calibration/vitals. This adds regression pins documenting why: 1. csi_pipeline.rs — persistent auto-accumulating state (occupancy EMA, vitals) is provably self-healing. The UDP parser only emits finite amplitudes/phases (sqrt/atan2 of i8), and even an adversarial hand-built CsiFrame with NaN/inf amplitudes+phases cannot latch non-finite state: motion_score = (NaN/100).min(1.0) → 1.0; breathing path → 0 → clamp(5,40) → 5.0; tomography EMA uses only integer rssi. The new test injects 40 poisoned frames and asserts occupancy/vitals stay finite AND the pipeline recovers to an in-range estimate afterward — so a future refactor that drops a `.min`/`.clamp` self-heal would fail this pin. 2. fusion.rs — fuse_clouds voxel averaging is div-by-zero-safe (per-voxel count >= 1 by construction). Pins empty / single-point / all-coincident inputs as no-panic with finite output. No behavior change. Tests: wifi-densepose-pointcloud 18→22 (4 new), 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(geo/pointcloud robustness): CHANGELOG + ADR-154 sibling-crate sweep note Record the wifi-densepose-geo + wifi-densepose-pointcloud numerical-robustness audit under CHANGELOG [Unreleased] → Fixed, and a sibling-crate-extension note on the ADR-154 horizon ledger (these crates are outside ADR-154's signal scope but the sweep is the same ADR-154 class). Co-Authored-By: claude-flow <ruv@ruv.net>
160 lines
5.5 KiB
Rust
160 lines
5.5 KiB
Rust
//! Coordinate transforms — WGS84, UTM, ENU, tile math.
|
|
|
|
use crate::types::{GeoBBox, GeoPoint, TileCoord};
|
|
|
|
const WGS84_A: f64 = 6_378_137.0;
|
|
#[allow(dead_code)]
|
|
const WGS84_F: f64 = 1.0 / 298.257_223_563;
|
|
#[allow(dead_code)]
|
|
const WGS84_E2: f64 = 2.0 * WGS84_F - WGS84_F * WGS84_F;
|
|
|
|
/// Haversine distance in meters.
|
|
pub fn haversine(a: &GeoPoint, b: &GeoPoint) -> f64 {
|
|
let dlat = (b.lat - a.lat).to_radians();
|
|
let dlon = (b.lon - a.lon).to_radians();
|
|
let lat1 = a.lat.to_radians();
|
|
let lat2 = b.lat.to_radians();
|
|
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
|
|
// `asin` is only defined on [-1, 1]. For (near-)antipodal points floating
|
|
// rounding can push `h.sqrt()` to 1.0 + epsilon, and `asin(>1)` is NaN —
|
|
// which would silently poison any distance-based comparison downstream.
|
|
// Clamp into domain so the result is always a finite distance.
|
|
2.0 * WGS84_A * h.sqrt().clamp(0.0, 1.0).asin()
|
|
}
|
|
|
|
/// WGS84 to local ENU (East-North-Up) relative to origin, in meters.
|
|
pub fn wgs84_to_enu(point: &GeoPoint, origin: &GeoPoint) -> [f64; 3] {
|
|
let dlat = (point.lat - origin.lat).to_radians();
|
|
let dlon = (point.lon - origin.lon).to_radians();
|
|
let lat = origin.lat.to_radians();
|
|
let east = dlon * WGS84_A * lat.cos();
|
|
let north = dlat * WGS84_A;
|
|
let up = point.alt - origin.alt;
|
|
[east, north, up]
|
|
}
|
|
|
|
/// Local ENU to WGS84.
|
|
pub fn enu_to_wgs84(enu: &[f64; 3], origin: &GeoPoint) -> GeoPoint {
|
|
let lat = origin.lat.to_radians();
|
|
let dlat = enu[1] / WGS84_A;
|
|
let dlon = enu[0] / (WGS84_A * lat.cos());
|
|
GeoPoint {
|
|
lat: origin.lat + dlat.to_degrees(),
|
|
lon: origin.lon + dlon.to_degrees(),
|
|
alt: origin.alt + enu[2],
|
|
}
|
|
}
|
|
|
|
/// WGS84 to XYZ tile coordinates (Slippy Map).
|
|
pub fn wgs84_to_tile(lat: f64, lon: f64, zoom: u8) -> TileCoord {
|
|
let n = 2f64.powi(zoom as i32);
|
|
let x = ((lon + 180.0) / 360.0 * n).floor() as u32;
|
|
let lat_rad = lat.to_radians();
|
|
let y = ((1.0 - lat_rad.tan().asinh() / std::f64::consts::PI) / 2.0 * n).floor() as u32;
|
|
TileCoord { z: zoom, x, y }
|
|
}
|
|
|
|
/// Tile bounds in WGS84.
|
|
pub fn tile_bounds(coord: &TileCoord) -> GeoBBox {
|
|
let n = 2f64.powi(coord.z as i32);
|
|
let west = coord.x as f64 / n * 360.0 - 180.0;
|
|
let east = (coord.x + 1) as f64 / n * 360.0 - 180.0;
|
|
let north = (std::f64::consts::PI * (1.0 - 2.0 * coord.y as f64 / n))
|
|
.sinh()
|
|
.atan()
|
|
.to_degrees();
|
|
let south = (std::f64::consts::PI * (1.0 - 2.0 * (coord.y + 1) as f64 / n))
|
|
.sinh()
|
|
.atan()
|
|
.to_degrees();
|
|
GeoBBox {
|
|
south,
|
|
west,
|
|
north,
|
|
east,
|
|
}
|
|
}
|
|
|
|
/// Get all tile coordinates covering a bounding box at a zoom level.
|
|
pub fn tiles_for_bbox(bbox: &GeoBBox, zoom: u8) -> Vec<TileCoord> {
|
|
let tl = wgs84_to_tile(bbox.north, bbox.west, zoom);
|
|
let br = wgs84_to_tile(bbox.south, bbox.east, zoom);
|
|
let mut tiles = Vec::new();
|
|
for y in tl.y..=br.y {
|
|
for x in tl.x..=br.x {
|
|
tiles.push(TileCoord { z: zoom, x, y });
|
|
}
|
|
}
|
|
tiles
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// ── haversine asin-domain robustness ───────────────────────────────────
|
|
//
|
|
// For (near-)antipodal points, floating rounding can push the haversine
|
|
// term `h` to 1.0 + ~4e-16, and `asin(sqrt(h)) = asin(>1)` is NaN. A NaN
|
|
// distance silently breaks every downstream comparison (all `<`/`>` become
|
|
// false), so the result must stay finite. This exact pair produced
|
|
// h = 1.0000000000000004 pre-fix (verified empirically).
|
|
|
|
#[test]
|
|
fn haversine_near_antipodal_is_finite_not_nan() {
|
|
let a = GeoPoint {
|
|
lat: -44.4994,
|
|
lon: -178.957_22,
|
|
alt: 0.0,
|
|
};
|
|
let b = GeoPoint {
|
|
lat: 44.499_399_99,
|
|
lon: 1.042_780_01,
|
|
alt: 0.0,
|
|
};
|
|
let d = haversine(&a, &b);
|
|
assert!(d.is_finite(), "near-antipodal haversine must be finite, got {d}");
|
|
// Half-circumference is ~20_037 km; result must be close to that.
|
|
assert!(
|
|
(19_000_000.0..21_000_000.0).contains(&d),
|
|
"antipodal distance should be ~half-circumference, got {d}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn haversine_identical_points_is_zero() {
|
|
let p = GeoPoint {
|
|
lat: 43.65,
|
|
lon: -79.38,
|
|
alt: 0.0,
|
|
};
|
|
let d = haversine(&p, &p);
|
|
assert!(d.is_finite() && d < 1e-6, "identical points → 0, got {d}");
|
|
}
|
|
|
|
// ── pole-singularity robustness (degenerate geometry) ──────────────────
|
|
//
|
|
// The ENU transforms divide by cos(lat); at the poles cos(±90°) = 0, so
|
|
// the longitude term is non-finite. We do not change the transform (that
|
|
// would alter near-pole results), but we pin that the call does NOT panic.
|
|
|
|
#[test]
|
|
fn wgs84_to_enu_at_pole_does_not_panic() {
|
|
let origin = GeoPoint {
|
|
lat: 90.0,
|
|
lon: 0.0,
|
|
alt: 0.0,
|
|
};
|
|
let point = GeoPoint {
|
|
lat: 89.99,
|
|
lon: 10.0,
|
|
alt: 0.0,
|
|
};
|
|
// Must return without panicking. North/up stay finite; east may be
|
|
// non-finite at the exact pole — assert the bounded components only.
|
|
let enu = wgs84_to_enu(&point, &origin);
|
|
assert!(enu[1].is_finite(), "north component must be finite");
|
|
assert!(enu[2].is_finite(), "up component must be finite");
|
|
}
|
|
}
|