From aeac5f5543cdc313e616aa5306db11853eb9a114 Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 16 Jun 2026 14:14:34 -0400 Subject: [PATCH] chore(worldgraph): extract geo+worldgraph+worldmodel to ruvnet/worldgraph submodule - published as github.com/ruvnet/worldgraph (3-crate workspace, history via git-filter-repo) - replace the 3 in-tree crates with one submodule at v2/crates/worldgraph - parent workspace: drop the 3 members, exclude the submodule (it is its own workspace), repoint workspace.dependencies(worldmodel) + engine/sensing-server path-deps into it - cargo metadata resolves clean (geo/worldgraph/worldmodel consumed from the submodule) Co-Authored-By: claude-flow --- .gitmodules | 4 + v2/Cargo.toml | 8 +- v2/crates/wifi-densepose-engine/Cargo.toml | 4 +- v2/crates/wifi-densepose-geo/Cargo.toml | 15 - v2/crates/wifi-densepose-geo/README.md | 105 ---- .../wifi-densepose-geo/examples/validate.rs | 69 --- v2/crates/wifi-densepose-geo/src/brain.rs | 48 -- v2/crates/wifi-densepose-geo/src/cache.rs | 64 -- v2/crates/wifi-densepose-geo/src/coord.rs | 159 ----- v2/crates/wifi-densepose-geo/src/fuse.rs | 92 --- v2/crates/wifi-densepose-geo/src/lib.rs | 19 - v2/crates/wifi-densepose-geo/src/locate.rs | 42 -- v2/crates/wifi-densepose-geo/src/osm.rs | 267 --------- v2/crates/wifi-densepose-geo/src/register.rs | 41 -- v2/crates/wifi-densepose-geo/src/temporal.rs | 333 ----------- v2/crates/wifi-densepose-geo/src/terrain.rs | 185 ------ v2/crates/wifi-densepose-geo/src/tiles.rs | 97 --- v2/crates/wifi-densepose-geo/src/types.rs | 123 ---- .../wifi-densepose-geo/tests/geo_test.rs | 132 ---- .../wifi-densepose-sensing-server/Cargo.toml | 4 +- .../wifi-densepose-worldgraph/Cargo.toml | 20 - v2/crates/wifi-densepose-worldgraph/README.md | 109 ---- .../wifi-densepose-worldgraph/src/error.rs | 15 - .../wifi-densepose-worldgraph/src/graph.rs | 566 ------------------ .../wifi-densepose-worldgraph/src/lib.rs | 30 - .../wifi-densepose-worldgraph/src/model.rs | 385 ------------ .../wifi-densepose-worldmodel/Cargo.toml | 20 - v2/crates/wifi-densepose-worldmodel/README.md | 127 ---- .../wifi-densepose-worldmodel/src/bridge.rs | 211 ------- .../wifi-densepose-worldmodel/src/error.rs | 40 -- .../wifi-densepose-worldmodel/src/lib.rs | 321 ---------- .../src/occupancy.rs | 210 ------- v2/crates/worldgraph | 1 + 33 files changed, 13 insertions(+), 3853 deletions(-) delete mode 100644 v2/crates/wifi-densepose-geo/Cargo.toml delete mode 100644 v2/crates/wifi-densepose-geo/README.md delete mode 100644 v2/crates/wifi-densepose-geo/examples/validate.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/brain.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/cache.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/coord.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/fuse.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/lib.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/locate.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/osm.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/register.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/temporal.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/terrain.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/tiles.rs delete mode 100644 v2/crates/wifi-densepose-geo/src/types.rs delete mode 100644 v2/crates/wifi-densepose-geo/tests/geo_test.rs delete mode 100644 v2/crates/wifi-densepose-worldgraph/Cargo.toml delete mode 100644 v2/crates/wifi-densepose-worldgraph/README.md delete mode 100644 v2/crates/wifi-densepose-worldgraph/src/error.rs delete mode 100644 v2/crates/wifi-densepose-worldgraph/src/graph.rs delete mode 100644 v2/crates/wifi-densepose-worldgraph/src/lib.rs delete mode 100644 v2/crates/wifi-densepose-worldgraph/src/model.rs delete mode 100644 v2/crates/wifi-densepose-worldmodel/Cargo.toml delete mode 100644 v2/crates/wifi-densepose-worldmodel/README.md delete mode 100644 v2/crates/wifi-densepose-worldmodel/src/bridge.rs delete mode 100644 v2/crates/wifi-densepose-worldmodel/src/error.rs delete mode 100644 v2/crates/wifi-densepose-worldmodel/src/lib.rs delete mode 100644 v2/crates/wifi-densepose-worldmodel/src/occupancy.rs create mode 160000 v2/crates/worldgraph diff --git a/.gitmodules b/.gitmodules index 376ff602..1b423f75 100644 --- a/.gitmodules +++ b/.gitmodules @@ -25,3 +25,7 @@ path = v2/crates/ruview-swarm url = https://github.com/ruvnet/ruv-drone.git branch = main +[submodule "v2/crates/worldgraph"] + path = v2/crates/worldgraph + url = https://github.com/ruvnet/worldgraph.git + branch = main diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 1988c30e..a85f5de8 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -25,8 +25,7 @@ members = [ "crates/wifi-densepose-ruvector", "crates/wifi-densepose-desktop", "crates/wifi-densepose-pointcloud", - "crates/wifi-densepose-geo", - "crates/wifi-densepose-worldgraph", # ADR-139 — WorldGraph environmental digital twin + # geo + worldgraph extracted to ruvnet/worldgraph submodule (see crates/worldgraph) "crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer "crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training "crates/nvsim", @@ -58,7 +57,7 @@ members = [ "crates/wifi-densepose-bfld", # ADR-147: OccWorld thin-client bridge — WorldGraph PersonTrack history → # OccWorld Python subprocess → TrajectoryPrior injection into pose tracker. - "crates/wifi-densepose-worldmodel", + # worldmodel extracted to ruvnet/worldgraph submodule (consumed via path dep) # ADR-147 (Phase 5): OccWorld TransVQVAE ported to Candle — native Rust # inference without Python/IPC overhead. Loaded alongside the Python bridge # as a faster alternative once Phase-5 weights are available. @@ -88,6 +87,7 @@ members = [ exclude = [ "crates/wifi-densepose-wasm-edge", "crates/homecore-plugin-example", + "crates/worldgraph", # ruvnet/worldgraph submodule — its own workspace (geo/worldgraph/worldmodel) ] [workspace.package] @@ -215,7 +215,7 @@ wifi-densepose-hardware = { version = "0.3.0", path = "crates/wifi-densepose-har wifi-densepose-wasm = { version = "0.3.0", path = "crates/wifi-densepose-wasm" } wifi-densepose-mat = { version = "0.3.0", path = "crates/wifi-densepose-mat" } wifi-densepose-ruvector = { version = "0.3.0", path = "crates/wifi-densepose-ruvector" } -wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/wifi-densepose-worldmodel" } +wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/worldgraph/wifi-densepose-worldmodel" } [profile.release] lto = true diff --git a/v2/crates/wifi-densepose-engine/Cargo.toml b/v2/crates/wifi-densepose-engine/Cargo.toml index 8220063c..08ec54f5 100644 --- a/v2/crates/wifi-densepose-engine/Cargo.toml +++ b/v2/crates/wifi-densepose-engine/Cargo.toml @@ -15,8 +15,8 @@ wifi-densepose-ruvector = { version = "0.3.0", path = "../wifi-densepose-ruvecto # bfld is no_std by default; the privacy CONTROL PLANE (PrivacyModeRegistry) is # std-gated, so request std explicitly even under a workspace --no-default-features build. wifi-densepose-bfld = { version = "0.3.0", path = "../wifi-densepose-bfld", features = ["std"] } -wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" } -wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" } +wifi-densepose-worldgraph = { version = "0.3.0", path = "../worldgraph/wifi-densepose-worldgraph" } +wifi-densepose-geo = { version = "0.1.0", path = "../worldgraph/wifi-densepose-geo" } # Deterministic witness over the trust decision (ADR-137 §2.7 / ADR-028). blake3 = { version = "1.5", default-features = false } # Dynamic min-cut over the live mesh coupling graph (mesh_guard.rs): diff --git a/v2/crates/wifi-densepose-geo/Cargo.toml b/v2/crates/wifi-densepose-geo/Cargo.toml deleted file mode 100644 index ce987034..00000000 --- a/v2/crates/wifi-densepose-geo/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "wifi-densepose-geo" -version = "0.1.0" -edition = "2021" -description = "Geospatial satellite integration — free satellite tiles, DEM, OSM, temporal tracking" -license.workspace = true -repository.workspace = true - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -anyhow = { workspace = true } -reqwest = { version = "0.12", features = ["json", "native-tls"], default-features = false } -chrono = "0.4" diff --git a/v2/crates/wifi-densepose-geo/README.md b/v2/crates/wifi-densepose-geo/README.md deleted file mode 100644 index 9fc6c874..00000000 --- a/v2/crates/wifi-densepose-geo/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# wifi-densepose-geo — Geospatial Satellite Integration - -Free satellite imagery, terrain elevation, and map data for RuView spatial sensing. No API keys required. - -## What It Does - -Integrates your local sensor data (camera + WiFi CSI point cloud) with geographic context: - -- **Satellite tiles** — 10m Sentinel-2 cloudless imagery for your location -- **Elevation** — SRTM 30m DEM for terrain modeling -- **Buildings + roads** — OpenStreetMap data via Overpass API -- **Weather** — Open Meteo current conditions + forecast -- **Geo-registration** — maps local sensor coordinates to WGS84 -- **Temporal tracking** — detects changes over time (construction, vegetation, weather) -- **Brain integration** — stores geospatial context as ruOS brain memories - -## Data Sources (all free, no API keys) - -| Source | Data | Resolution | License | -|--------|------|-----------|---------| -| [EOX S2 Cloudless](https://s2maps.eu/) | Satellite tiles | 10m | CC-BY-4.0 | -| [SRTM GL1](https://portal.opentopography.org/) | Elevation/DEM | 30m | Public domain | -| [Overpass API](https://overpass-api.de/) | OSM buildings/roads | Vector | ODbL | -| [ip-api.com](http://ip-api.com/) | IP geolocation | ~1km | Free | -| [Open Meteo](https://open-meteo.com/) | Weather | Point | CC-BY-4.0 | - -## Modules - -| Module | LOC | Purpose | -|--------|-----|---------| -| `types.rs` | 140 | GeoPoint, GeoBBox, TileCoord, ElevationGrid, OsmFeature | -| `coord.rs` | 80 | WGS84/ENU transforms, tile math, haversine distance | -| `locate.rs` | 45 | IP geolocation with caching | -| `cache.rs` | 55 | Disk cache (`~/.local/share/ruview/geo-cache/`) | -| `tiles.rs` | 80 | Sentinel-2/ESRI/OSM tile fetcher | -| `terrain.rs` | 100 | SRTM HGT parser, elevation lookup | -| `osm.rs` | 150 | Overpass API client, building/road extraction | -| `register.rs` | 50 | Local-to-WGS84 coordinate registration | -| `fuse.rs` | 70 | Multi-source scene builder + summary | -| `brain.rs` | 30 | Store geo context in ruOS brain | -| `temporal.rs` | 100 | Weather, OSM change detection | - -## Usage - -```rust -use wifi_densepose_geo::{fuse, brain, temporal}; - -// Build geo scene for current location -let scene = fuse::build_scene(500.0).await?; // 500m radius -println!("{}", fuse::summarize(&scene)); -// "Location: 43.6532N, 79.3832W, elevation 76m ASL. -// 23 buildings within view. 8 roads nearby (King St, Queen St). -// 12 satellite tiles at zoom 16." - -// Store in brain -brain::store_geo_context(&scene).await?; - -// Fetch weather -let weather = temporal::fetch_weather(&scene.location).await?; -// temperature: 12°C, partly cloudy, humidity 65% -``` - -## Brain Integration - -Geospatial context is stored as brain memories: - -| Category | Content | Frequency | -|----------|---------|-----------| -| `spatial-geo` | Location, elevation, buildings, roads | On startup + daily | -| `spatial-weather` | Temperature, conditions, humidity, wind | Nightly | -| `spatial-change` | New/removed buildings, road changes | Nightly diff | - -The ruOS agent can search: "what buildings are near me?" or "what's the weather?" and get geospatial context from the brain. - -## Security - -- No API keys stored or transmitted -- IP geolocation uses HTTP (not HTTPS) — location is approximate (~1km) -- All tile fetches use HTTPS except ip-api.com -- Path traversal protection in cache key sanitization -- No user data sent to external services -- All data cached locally after first fetch - -## Architecture - -``` -IP Geolocation ──→ (lat, lon) - │ - ┌─────────────┼─────────────┐ - ▼ ▼ ▼ - Sentinel-2 SRTM DEM Overpass API - (tiles) (elevation) (buildings/roads) - │ │ │ - └─────────────┼─────────────┘ - ▼ - GeoScene (fused) - │ - ┌───────┴───────┐ - ▼ ▼ - Brain Memory Three.js Viewer -``` - -## License - -MIT (same as RuView) diff --git a/v2/crates/wifi-densepose-geo/examples/validate.rs b/v2/crates/wifi-densepose-geo/examples/validate.rs deleted file mode 100644 index de1cdd0d..00000000 --- a/v2/crates/wifi-densepose-geo/examples/validate.rs +++ /dev/null @@ -1,69 +0,0 @@ -use wifi_densepose_geo::*; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - println!("╔══════════════════════════════════════════════╗"); - println!("║ ruview-geo — Real Data Validation ║"); - println!("╚══════════════════════════════════════════════╝\n"); - - let t0 = std::time::Instant::now(); - let cache = cache::TileCache::new("/tmp/ruview-geo-validate"); - - let loc = locate::get_location(&format!("{}/location.json", cache.base_dir.display())).await?; - println!(" Location: {:.4}N, {:.4}W", loc.lat, loc.lon); - - let bbox = GeoBBox::from_center(&loc, 300.0); - let tiles_list = - tiles::fetch_area(&tiles::TileProvider::Sentinel2Cloudless, &bbox, 16, &cache).await?; - println!( - " Tiles: {} ({:.0}KB)", - tiles_list.len(), - tiles_list.iter().map(|t| t.data.len()).sum::() as f64 / 1024.0 - ); - - let dem = terrain::fetch_elevation(&loc, &cache).await?; - println!( - " Elevation: {:.0}m (grid {}x{})", - terrain::elevation_at(&dem, &loc), - dem.cols, - dem.rows - ); - - let buildings = osm::fetch_buildings(&loc, 300.0).await.unwrap_or_default(); - let roads = osm::fetch_roads(&loc, 300.0).await.unwrap_or_default(); - println!( - " OSM: {} buildings, {} roads", - buildings.len(), - roads.len() - ); - - let weather = temporal::fetch_weather(&loc).await?; - println!( - " Weather: {:.0}°C humidity={:.0}% wind={:.1}m/s", - weather.temperature_c, weather.humidity_pct, weather.wind_speed_ms - ); - - let scene = GeoScene { - location: loc.clone(), - bbox, - elevation_m: terrain::elevation_at(&dem, &loc), - buildings, - roads, - tile_count: tiles_list.len(), - registration: register::auto_register(&loc), - last_updated: chrono::Utc::now().to_rfc3339(), - }; - println!("\n {}", fuse::summarize(&scene)); - - match brain::store_geo_context(&scene).await { - Ok(n) => println!(" Brain: {} memories stored", n), - Err(e) => println!(" Brain: {e}"), - } - - println!( - "\n Total: {}ms | Cache: {:.0}KB", - t0.elapsed().as_millis(), - cache.size_bytes() as f64 / 1024.0 - ); - Ok(()) -} diff --git a/v2/crates/wifi-densepose-geo/src/brain.rs b/v2/crates/wifi-densepose-geo/src/brain.rs deleted file mode 100644 index a845badd..00000000 --- a/v2/crates/wifi-densepose-geo/src/brain.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Brain integration — store geospatial context in ruOS brain. -//! -//! Brain URL is read from `RUVIEW_BRAIN_URL` env var (default -//! `http://127.0.0.1:9876`). The resolved URL is logged once on first use. - -use crate::fuse; -use crate::types::GeoScene; -use anyhow::Result; -use std::sync::OnceLock; - -const DEFAULT_BRAIN_URL: &str = "http://127.0.0.1:9876"; - -pub(crate) fn brain_url() -> &'static str { - static BRAIN_URL: OnceLock = OnceLock::new(); - BRAIN_URL.get_or_init(|| { - let url = - std::env::var("RUVIEW_BRAIN_URL").unwrap_or_else(|_| DEFAULT_BRAIN_URL.to_string()); - eprintln!(" wifi-densepose-geo: using brain URL {url}"); - url - }) -} - -/// Store geospatial context in the brain. -pub async fn store_geo_context(scene: &GeoScene) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build()?; - - let mut stored = 0u32; - - // Store location summary - let summary = fuse::summarize(scene); - let body = serde_json::json!({ - "category": "spatial-geo", - "content": summary, - }); - if client - .post(format!("{}/memories", brain_url())) - .json(&body) - .send() - .await - .is_ok() - { - stored += 1; - } - - Ok(stored) -} diff --git a/v2/crates/wifi-densepose-geo/src/cache.rs b/v2/crates/wifi-densepose-geo/src/cache.rs deleted file mode 100644 index a3c66450..00000000 --- a/v2/crates/wifi-densepose-geo/src/cache.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Disk cache for tiles, DEM, and OSM data. - -use anyhow::Result; -use std::path::{Path, PathBuf}; - -pub struct TileCache { - pub base_dir: PathBuf, -} - -impl TileCache { - pub fn new(base_dir: &str) -> Self { - let expanded = base_dir.replace('~', &std::env::var("HOME").unwrap_or_default()); - let path = PathBuf::from(expanded); - let _ = std::fs::create_dir_all(&path); - Self { base_dir: path } - } - - pub fn default_cache() -> Self { - Self::new("~/.local/share/ruview/geo-cache") - } - - pub fn get(&self, key: &str) -> Option> { - let path = self.key_path(key); - std::fs::read(&path).ok() - } - - pub fn put(&self, key: &str, data: &[u8]) -> Result<()> { - let path = self.key_path(key); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&path, data)?; - Ok(()) - } - - pub fn has(&self, key: &str) -> bool { - self.key_path(key).exists() - } - - pub fn size_bytes(&self) -> u64 { - walkdir(self.base_dir.as_path()) - } - - fn key_path(&self, key: &str) -> PathBuf { - // Sanitize key to prevent path traversal - let safe_key = key.replace("..", "_").replace('/', "_"); - self.base_dir.join(safe_key) - } -} - -fn walkdir(path: &Path) -> u64 { - std::fs::read_dir(path) - .into_iter() - .flatten() - .filter_map(|e| e.ok()) - .map(|e| { - if e.path().is_dir() { - walkdir(&e.path()) - } else { - e.metadata().map(|m| m.len()).unwrap_or(0) - } - }) - .sum() -} diff --git a/v2/crates/wifi-densepose-geo/src/coord.rs b/v2/crates/wifi-densepose-geo/src/coord.rs deleted file mode 100644 index 963fcf14..00000000 --- a/v2/crates/wifi-densepose-geo/src/coord.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! 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 { - 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"); - } -} diff --git a/v2/crates/wifi-densepose-geo/src/fuse.rs b/v2/crates/wifi-densepose-geo/src/fuse.rs deleted file mode 100644 index 0c59188f..00000000 --- a/v2/crates/wifi-densepose-geo/src/fuse.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Multi-source fusion — satellite + terrain + OSM + local sensor data. - -use crate::cache::TileCache; -use crate::types::*; -use crate::{locate, osm, terrain, tiles}; -use anyhow::Result; - -/// Build a complete geo scene for a location. -pub async fn build_scene(radius_m: f64) -> Result { - let cache = TileCache::default_cache(); - - // 1. Locate - let cache_path = cache.base_dir.join("location.json"); - let location = locate::get_location(cache_path.to_str().unwrap_or("")).await?; - eprintln!( - " Geo: located at {:.4}N, {:.4}W", - location.lat, location.lon - ); - - // 2. Fetch satellite tiles - let bbox = GeoBBox::from_center(&location, radius_m); - let tile_list = - tiles::fetch_area(&tiles::TileProvider::Sentinel2Cloudless, &bbox, 16, &cache).await?; - eprintln!(" Geo: fetched {} satellite tiles", tile_list.len()); - - // 3. Fetch elevation - let dem = terrain::fetch_elevation(&location, &cache).await?; - let elevation = terrain::elevation_at(&dem, &location); - eprintln!(" Geo: elevation {:.0}m ASL", elevation); - - // 4. Fetch OSM buildings + roads - let buildings = osm::fetch_buildings(&location, radius_m) - .await - .unwrap_or_default(); - let roads = osm::fetch_roads(&location, radius_m) - .await - .unwrap_or_default(); - eprintln!( - " Geo: {} buildings, {} roads", - buildings.len(), - roads.len() - ); - - // 5. Build registration - let mut reg_origin = location.clone(); - reg_origin.alt = elevation as f64; - let registration = crate::register::auto_register(®_origin); - - Ok(GeoScene { - location: reg_origin, - bbox, - elevation_m: elevation, - buildings, - roads, - tile_count: tile_list.len(), - registration, - last_updated: chrono::Utc::now().to_rfc3339(), - }) -} - -/// Generate a text summary of the geo scene. -pub fn summarize(scene: &GeoScene) -> String { - let building_count = scene.buildings.len(); - let road_count = scene.roads.len(); - let road_names: Vec<&str> = scene - .roads - .iter() - .filter_map(|r| match r { - OsmFeature::Road { name, .. } => name.as_deref(), - _ => None, - }) - .take(3) - .collect(); - - format!( - "Location: {:.4}N, {:.4}W, elevation {:.0}m ASL. \ - {} buildings within view. {} roads nearby{}. \ - {} satellite tiles at zoom 16. Updated: {}.", - scene.location.lat, - scene.location.lon, - scene.elevation_m, - building_count, - road_count, - if road_names.is_empty() { - String::new() - } else { - format!(" ({})", road_names.join(", ")) - }, - scene.tile_count, - &scene.last_updated[..10], - ) -} diff --git a/v2/crates/wifi-densepose-geo/src/lib.rs b/v2/crates/wifi-densepose-geo/src/lib.rs deleted file mode 100644 index 5c0a0ff3..00000000 --- a/v2/crates/wifi-densepose-geo/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! wifi-densepose-geo — geospatial satellite integration for RuView. -//! -//! Provides: IP geolocation, satellite tile fetching (Sentinel-2), -//! SRTM elevation, OSM buildings/roads, coordinate transforms, -//! temporal change tracking, and brain memory integration. - -pub mod brain; -pub mod cache; -pub mod coord; -pub mod fuse; -pub mod locate; -pub mod osm; -pub mod register; -pub mod temporal; -pub mod terrain; -pub mod tiles; -pub mod types; - -pub use types::*; diff --git a/v2/crates/wifi-densepose-geo/src/locate.rs b/v2/crates/wifi-densepose-geo/src/locate.rs deleted file mode 100644 index bd02ef06..00000000 --- a/v2/crates/wifi-densepose-geo/src/locate.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! IP geolocation — determine location from public IP. - -use crate::types::GeoPoint; -use anyhow::Result; - -/// Locate by IP address (free, no API key). -pub async fn locate_by_ip() -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build()?; - - // Primary: ip-api.com (free, 45 req/min) - let resp: serde_json::Value = client - .get("http://ip-api.com/json/?fields=lat,lon,city,regionName,country") - .send() - .await? - .json() - .await?; - - let lat = resp.get("lat").and_then(|v| v.as_f64()).unwrap_or(0.0); - let lon = resp.get("lon").and_then(|v| v.as_f64()).unwrap_or(0.0); - - if lat == 0.0 && lon == 0.0 { - anyhow::bail!("IP geolocation returned (0,0)"); - } - - Ok(GeoPoint { lat, lon, alt: 0.0 }) -} - -/// Get location with caching. -pub async fn get_location(cache_path: &str) -> Result { - // Check cache - if let Ok(data) = std::fs::read_to_string(cache_path) { - if let Ok(point) = serde_json::from_str::(&data) { - return Ok(point); - } - } - - let point = locate_by_ip().await?; - let _ = std::fs::write(cache_path, serde_json::to_string(&point)?); - Ok(point) -} diff --git a/v2/crates/wifi-densepose-geo/src/osm.rs b/v2/crates/wifi-densepose-geo/src/osm.rs deleted file mode 100644 index e4a38c3d..00000000 --- a/v2/crates/wifi-densepose-geo/src/osm.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! OpenStreetMap data via Overpass API — buildings, roads, land use. - -use crate::types::{GeoBBox, GeoPoint, OsmFeature}; -use anyhow::{anyhow, Result}; - -const OVERPASS_URL: &str = "https://overpass-api.de/api/interpreter"; - -/// Maximum radius (in metres) accepted by the OSM fetchers. Requests larger -/// than this would produce Overpass queries covering hundreds of square -/// kilometres — which hammers the public endpoint and returns unworkably -/// large response payloads. Callers wanting wider areas must tile the queries. -pub const MAX_RADIUS_M: f64 = 5000.0; - -fn check_radius(radius_m: f64) -> Result<()> { - if !radius_m.is_finite() || radius_m <= 0.0 { - return Err(anyhow!( - "radius_m must be positive and finite (got {radius_m})" - )); - } - if radius_m > MAX_RADIUS_M { - return Err(anyhow!( - "radius_m {radius_m} exceeds MAX_RADIUS_M ({MAX_RADIUS_M}); \ - tile the query into smaller chunks" - )); - } - Ok(()) -} - -/// Fetch buildings within radius of a point. -/// -/// Uses an inclusive `["building"]` filter that matches all building values -/// (residential, commercial, yes, etc.) and also queries relations for -/// multipolygon buildings. Default recommended radius: 500 m. Max 5000 m. -pub async fn fetch_buildings(center: &GeoPoint, radius_m: f64) -> Result> { - check_radius(radius_m)?; - let bbox = GeoBBox::from_center(center, radius_m); - let query = format!( - r#"[out:json][timeout:25];(way["building"]({},{},{},{});relation["building"]({},{},{},{}););out body;>;out skel qt;"#, - bbox.south, bbox.west, bbox.north, bbox.east, bbox.south, bbox.west, bbox.north, bbox.east, - ); - let resp = overpass_query(&query).await?; - parse_buildings(&resp) -} - -/// Fetch roads within radius. Max 5000 m; returns an error otherwise. -pub async fn fetch_roads(center: &GeoPoint, radius_m: f64) -> Result> { - check_radius(radius_m)?; - let bbox = GeoBBox::from_center(center, radius_m); - let query = format!( - r#"[out:json][timeout:10];way["highway"]({},{},{},{});out body;>;out skel qt;"#, - bbox.south, bbox.west, bbox.north, bbox.east - ); - let resp = overpass_query(&query).await?; - parse_roads(&resp) -} - -async fn overpass_query(query: &str) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .user_agent("RuView/0.1") - .build()?; - - let resp = client - .post(OVERPASS_URL) - .form(&[("data", query)]) - .send() - .await?; - - if !resp.status().is_success() { - anyhow::bail!("Overpass API error: {}", resp.status()); - } - Ok(resp.json().await?) -} - -/// Parse an Overpass JSON response into building features. -/// -/// Returns an error if the response is not a JSON object or is missing the -/// top-level `elements` array (indicative of a malformed/non-Overpass payload). -pub fn parse_overpass_json(data: &serde_json::Value) -> Result> { - if !data.is_object() || data.get("elements").and_then(|e| e.as_array()).is_none() { - return Err(anyhow!( - "malformed Overpass response: missing `elements` array" - )); - } - parse_buildings(data) -} - -pub(crate) fn parse_buildings(data: &serde_json::Value) -> Result> { - let mut buildings = Vec::new(); - let mut nodes: std::collections::HashMap = std::collections::HashMap::new(); - - let elements = data - .get("elements") - .and_then(|e| e.as_array()) - .cloned() - .unwrap_or_default(); - - // First pass: collect nodes - for el in &elements { - if el.get("type").and_then(|t| t.as_str()) == Some("node") { - if let (Some(id), Some(lat), Some(lon)) = ( - el.get("id").and_then(|v| v.as_u64()), - el.get("lat").and_then(|v| v.as_f64()), - el.get("lon").and_then(|v| v.as_f64()), - ) { - nodes.insert(id, [lat, lon]); - } - } - } - - // Second pass: build ways - for el in &elements { - if el.get("type").and_then(|t| t.as_str()) != Some("way") { - continue; - } - let tags = el.get("tags").cloned().unwrap_or(serde_json::json!({})); - if tags.get("building").is_none() { - continue; - } - - let node_ids = el - .get("nodes") - .and_then(|n| n.as_array()) - .cloned() - .unwrap_or_default(); - let outline: Vec<[f64; 2]> = node_ids - .iter() - .filter_map(|id| id.as_u64().and_then(|id| nodes.get(&id).copied())) - .collect(); - - if outline.len() < 3 { - continue; - } - - let height = tags - .get("height") - .and_then(|h| h.as_str()) - .and_then(|s| s.trim_end_matches('m').trim().parse::().ok()) - .or(Some(8.0)); // default building height - - let name = tags - .get("name") - .and_then(|n| n.as_str()) - .map(|s| s.to_string()); - - buildings.push(OsmFeature::Building { - outline, - height, - name, - }); - } - - Ok(buildings) -} - -fn parse_roads(data: &serde_json::Value) -> Result> { - let mut roads = Vec::new(); - let mut nodes: std::collections::HashMap = std::collections::HashMap::new(); - - let elements = data - .get("elements") - .and_then(|e| e.as_array()) - .cloned() - .unwrap_or_default(); - - for el in &elements { - if el.get("type").and_then(|t| t.as_str()) == Some("node") { - if let (Some(id), Some(lat), Some(lon)) = ( - el.get("id").and_then(|v| v.as_u64()), - el.get("lat").and_then(|v| v.as_f64()), - el.get("lon").and_then(|v| v.as_f64()), - ) { - nodes.insert(id, [lat, lon]); - } - } - } - - for el in &elements { - if el.get("type").and_then(|t| t.as_str()) != Some("way") { - continue; - } - let tags = el.get("tags").cloned().unwrap_or(serde_json::json!({})); - let highway = tags.get("highway").and_then(|h| h.as_str()); - if highway.is_none() { - continue; - } - - let node_ids = el - .get("nodes") - .and_then(|n| n.as_array()) - .cloned() - .unwrap_or_default(); - let path: Vec<[f64; 2]> = node_ids - .iter() - .filter_map(|id| id.as_u64().and_then(|id| nodes.get(&id).copied())) - .collect(); - - if path.len() < 2 { - continue; - } - - let name = tags - .get("name") - .and_then(|n| n.as_str()) - .map(|s| s.to_string()); - - roads.push(OsmFeature::Road { - path, - road_type: highway.unwrap_or("unknown").to_string(), - name, - }); - } - - Ok(roads) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_overpass_json_accepts_minimal_fixture() { - // Minimal fixture: three nodes forming a triangular building. - let j = serde_json::json!({ - "elements": [ - { "type": "node", "id": 1, "lat": 43.0, "lon": -79.0 }, - { "type": "node", "id": 2, "lat": 43.0001, "lon": -79.0 }, - { "type": "node", "id": 3, "lat": 43.0, "lon": -79.0001 }, - { - "type": "way", "id": 100, - "nodes": [1, 2, 3, 1], - "tags": { "building": "yes", "name": "Test Hall" } - } - ] - }); - let features = parse_overpass_json(&j).expect("minimal payload should parse"); - assert_eq!(features.len(), 1); - match &features[0] { - OsmFeature::Building { outline, name, .. } => { - assert_eq!(outline.len(), 4); - assert_eq!(name.as_deref(), Some("Test Hall")); - } - _ => panic!("expected a Building"), - } - } - - #[test] - fn parse_overpass_json_rejects_malformed() { - // Missing the `elements` array entirely. - let j = serde_json::json!({ "version": 0.6 }); - assert!(parse_overpass_json(&j).is_err()); - // Not even an object. - let arr = serde_json::json!([1, 2, 3]); - assert!(parse_overpass_json(&arr).is_err()); - } - - #[tokio::test] - async fn fetch_buildings_rejects_oversized_radius() { - let center = GeoPoint { - lat: 43.0, - lon: -79.0, - alt: 0.0, - }; - let err = fetch_buildings(¢er, MAX_RADIUS_M + 1.0).await.err(); - assert!(err.is_some(), "should reject radius > MAX_RADIUS_M"); - } -} diff --git a/v2/crates/wifi-densepose-geo/src/register.rs b/v2/crates/wifi-densepose-geo/src/register.rs deleted file mode 100644 index a3be71f6..00000000 --- a/v2/crates/wifi-densepose-geo/src/register.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Geo-registration — maps local sensor coordinates to WGS84. - -use crate::coord; -use crate::types::{GeoPoint, GeoRegistration}; - -/// Auto-register using IP location (sensor at IP location, facing north). -pub fn auto_register(ip_location: &GeoPoint) -> GeoRegistration { - GeoRegistration { - origin: ip_location.clone(), - heading_deg: 0.0, - scale: 1.0, - } -} - -/// Transform local point [x, y, z] to WGS84. -pub fn local_to_wgs84(reg: &GeoRegistration, local: &[f32; 3]) -> GeoPoint { - let heading_rad = reg.heading_deg.to_radians(); - let cos_h = heading_rad.cos(); - let sin_h = heading_rad.sin(); - - // Rotate local by heading (local X → East when heading=0) - let east = (local[0] as f64 * cos_h - local[2] as f64 * sin_h) * reg.scale; - let north = (local[0] as f64 * sin_h + local[2] as f64 * cos_h) * reg.scale; - let up = local[1] as f64 * reg.scale; - - coord::enu_to_wgs84(&[east, north, up], ®.origin) -} - -/// Transform WGS84 to local point. -pub fn wgs84_to_local(reg: &GeoRegistration, geo: &GeoPoint) -> [f32; 3] { - let enu = coord::wgs84_to_enu(geo, ®.origin); - let heading_rad = (-reg.heading_deg).to_radians(); - let cos_h = heading_rad.cos(); - let sin_h = heading_rad.sin(); - - let x = ((enu[0] * cos_h - enu[1] * sin_h) / reg.scale) as f32; - let z = ((enu[0] * sin_h + enu[1] * cos_h) / reg.scale) as f32; - let y = (enu[2] / reg.scale) as f32; - - [x, y, z] -} diff --git a/v2/crates/wifi-densepose-geo/src/temporal.rs b/v2/crates/wifi-densepose-geo/src/temporal.rs deleted file mode 100644 index d937a593..00000000 --- a/v2/crates/wifi-densepose-geo/src/temporal.rs +++ /dev/null @@ -1,333 +0,0 @@ -//! Temporal change tracking — detect changes in satellite/OSM/weather over time. - -use crate::cache::TileCache; -use crate::types::GeoPoint; -#[allow(unused_imports)] -use crate::types::GeoScene; -use anyhow::Result; - -/// Fetch current weather (Open Meteo, free, no key). -pub async fn fetch_weather(point: &GeoPoint) -> Result { - let url = format!( - "https://api.open-meteo.com/v1/forecast?latitude={:.4}&longitude={:.4}¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code", - point.lat, point.lon - ); - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build()?; - - let resp: serde_json::Value = client.get(&url).send().await?.json().await?; - let current = resp - .get("current") - .cloned() - .unwrap_or(serde_json::json!({})); - - Ok(WeatherData { - temperature_c: current - .get("temperature_2m") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) as f32, - humidity_pct: current - .get("relative_humidity_2m") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) as f32, - wind_speed_ms: current - .get("wind_speed_10m") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) as f32, - weather_code: current - .get("weather_code") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u16, - }) -} - -/// Check for OSM changes since last fetch. -pub async fn check_osm_changes(scene: &GeoScene, cache: &TileCache) -> Result> { - let mut changes = Vec::new(); - - let cache_key = "osm_building_count"; - let prev_count: usize = cache - .get(cache_key) - .and_then(|d| String::from_utf8(d).ok()) - .and_then(|s| s.trim().parse().ok()) - .unwrap_or(0); - - let current_count = scene.buildings.len(); - if prev_count > 0 && current_count != prev_count { - let diff = current_count as i64 - prev_count as i64; - changes.push(format!( - "Building count changed: {} → {} ({:+})", - prev_count, current_count, diff - )); - } - - cache.put(cache_key, current_count.to_string().as_bytes())?; - Ok(changes) -} - -/// Generate temporal summary for brain storage. -pub fn temporal_summary(weather: &WeatherData, changes: &[String]) -> String { - let weather_desc = match weather.weather_code { - 0 => "clear sky", - 1..=3 => "partly cloudy", - 45 | 48 => "foggy", - 51..=57 => "drizzle", - 61..=67 => "rain", - 71..=77 => "snow", - 80..=82 => "showers", - 95..=99 => "thunderstorm", - _ => "unknown", - }; - - let mut summary = format!( - "Weather: {:.0}°C, {weather_desc}, humidity {:.0}%, wind {:.1}m/s.", - weather.temperature_c, weather.humidity_pct, weather.wind_speed_ms, - ); - - for change in changes { - summary.push_str(&format!(" Change: {change}.")); - } - - summary -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct WeatherData { - pub temperature_c: f32, - pub humidity_pct: f32, - pub wind_speed_ms: f32, - pub weather_code: u16, -} - -// --------------------------------------------------------------------------- -// Satellite tile change detection -// --------------------------------------------------------------------------- - -/// Result of comparing two tile snapshots. -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct TileChangeResult { - /// 0.0 = identical, 1.0 = completely different. - pub diff_score: f64, - /// Number of pixels that changed. - pub changed_pixels: usize, - /// Total pixels compared. - pub total_pixels: usize, -} - -/// Compare a newly-fetched tile against its previously-cached version. -/// -/// Returns a `TileChangeResult` with a diff score between 0.0 (identical) and -/// 1.0 (completely different). When the diff exceeds 0.1 the function stores -/// a change event as a brain memory via the local ruOS brain endpoint. -pub async fn detect_tile_changes( - cache_key: &str, - new_data: &[u8], - cache: &TileCache, -) -> Result { - let previous = cache.get(cache_key); - - let result = match previous { - Some(ref old_data) => { - let total = old_data.len().max(new_data.len()).max(1); - let comparable = old_data.len().min(new_data.len()); - let mut changed: usize = 0; - for i in 0..comparable { - if old_data[i] != new_data[i] { - changed += 1; - } - } - // Any extra bytes in the longer slice count as changed. - changed += total - comparable; - - TileChangeResult { - diff_score: changed as f64 / total as f64, - changed_pixels: changed, - total_pixels: total, - } - } - None => { - // No previous data — treat as fully new (score 1.0). - TileChangeResult { - diff_score: 1.0, - changed_pixels: new_data.len(), - total_pixels: new_data.len().max(1), - } - } - }; - - // Persist new snapshot into cache for future comparisons. - cache.put(cache_key, new_data)?; - - // When significant change is detected, store a brain memory. - if result.diff_score > 0.1 { - let _ = store_change_event(cache_key, &result).await; - } - - Ok(result) -} - -/// Post a change event to the local ruOS brain. -/// -/// Brain URL honours `RUVIEW_BRAIN_URL` via [`crate::brain::brain_url`]. -async fn store_change_event(cache_key: &str, result: &TileChangeResult) -> Result<()> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build()?; - - let body = serde_json::json!({ - "category": "spatial-change", - "content": format!( - "Tile change detected for {cache_key}: diff={:.3}, changed={}/{}", - result.diff_score, result.changed_pixels, result.total_pixels, - ), - }); - - client - .post(format!("{}/memories", crate::brain::brain_url())) - .json(&body) - .send() - .await?; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Night mode detection -// --------------------------------------------------------------------------- - -/// Approximate check whether the current time is "night" at a given latitude. -/// -/// Uses a simplified sunrise/sunset model based on the solar declination and -/// hour angle. When it is night the system should rely on CSI data only -/// (satellite imagery is not useful in darkness). -pub fn is_night(lat_deg: f64) -> bool { - let now = chrono::Utc::now(); - is_night_at(lat_deg, now) -} - -/// Testable version of [`is_night`] that accepts an explicit timestamp. -pub fn is_night_at(lat_deg: f64, utc: chrono::DateTime) -> bool { - use chrono::Datelike; - use std::f64::consts::PI; - - let day_of_year = utc.ordinal() as f64; - let hour_utc = utc.timestamp() % 86400; - let solar_hour = (hour_utc as f64) / 3600.0; // 0..24 - - // Solar declination (Spencer, 1971 — simplified) - let gamma = 2.0 * PI * (day_of_year - 1.0) / 365.0; - let decl = 0.006918 - 0.399912 * gamma.cos() + 0.070257 * gamma.sin() - - 0.006758 * (2.0 * gamma).cos() - + 0.000907 * (2.0 * gamma).sin(); - - let lat_rad = lat_deg.to_radians(); - - // Cosine of the hour angle at sunrise/sunset (geometric, no refraction) - let cos_ha = -(lat_rad.tan() * decl.tan()); - - // Polar day / polar night - if cos_ha < -1.0 { - return false; // midnight sun — never night - } - if cos_ha > 1.0 { - return true; // polar night — always night - } - - let ha_sunrise = cos_ha.acos(); // radians, symmetric about solar noon - let daylight_hours = 2.0 * ha_sunrise * 12.0 / PI; - let solar_noon = 12.0; // approximation (ignores longitude offset) - let sunrise = solar_noon - daylight_hours / 2.0; - let sunset = solar_noon + daylight_hours / 2.0; - - solar_hour < sunrise || solar_hour > sunset -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_night_at_equator_noon() { - // Noon UTC at equator on March 20 — should be daytime. - let dt = chrono::NaiveDate::from_ymd_opt(2025, 3, 20) - .unwrap() - .and_hms_opt(12, 0, 0) - .unwrap() - .and_utc(); - assert!(!is_night_at(0.0, dt)); - } - - #[test] - fn test_is_night_at_equator_midnight() { - // Midnight UTC at equator — should be night. - let dt = chrono::NaiveDate::from_ymd_opt(2025, 3, 20) - .unwrap() - .and_hms_opt(2, 0, 0) - .unwrap() - .and_utc(); - assert!(is_night_at(0.0, dt)); - } - - #[test] - fn test_midnight_sun_arctic() { - // Late June at 70 N — midnight sun, never night. - let dt = chrono::NaiveDate::from_ymd_opt(2025, 6, 21) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc(); - assert!(!is_night_at(70.0, dt)); - } - - #[test] - fn test_polar_night_arctic() { - // Late December at 80 N — polar night, always night. - let dt = chrono::NaiveDate::from_ymd_opt(2025, 12, 21) - .unwrap() - .and_hms_opt(12, 0, 0) - .unwrap() - .and_utc(); - assert!(is_night_at(80.0, dt)); - } - - #[test] - fn test_detect_tile_changes_identical() { - let cache = TileCache::new("/tmp/ruview-test-tile-changes"); - let data = vec![1u8, 2, 3, 4, 5]; - // Prime the cache. - cache.put("test_tile_ident", &data).unwrap(); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let result = rt - .block_on(detect_tile_changes("test_tile_ident", &data, &cache)) - .unwrap(); - assert!((result.diff_score - 0.0).abs() < 1e-9); - assert_eq!(result.changed_pixels, 0); - } - - #[test] - fn test_detect_tile_changes_fully_different() { - let cache = TileCache::new("/tmp/ruview-test-tile-changes"); - let old = vec![0u8; 100]; - let new = vec![255u8; 100]; - cache.put("test_tile_diff", &old).unwrap(); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let result = rt - .block_on(detect_tile_changes("test_tile_diff", &new, &cache)) - .unwrap(); - assert!((result.diff_score - 1.0).abs() < 1e-9); - } -} diff --git a/v2/crates/wifi-densepose-geo/src/terrain.rs b/v2/crates/wifi-densepose-geo/src/terrain.rs deleted file mode 100644 index 70b7f37f..00000000 --- a/v2/crates/wifi-densepose-geo/src/terrain.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! SRTM DEM parser — elevation data from NASA 1-arcsecond HGT files. - -use crate::cache::TileCache; -use crate::types::{ElevationGrid, GeoPoint}; -use anyhow::Result; - -/// Download and parse SRTM HGT for a location. -pub async fn fetch_elevation(point: &GeoPoint, cache: &TileCache) -> Result { - let lat_int = point.lat.floor() as i32; - let lon_int = point.lon.floor() as i32; - let ns = if lat_int >= 0 { 'N' } else { 'S' }; - let ew = if lon_int >= 0 { 'E' } else { 'W' }; - let filename = format!( - "{}{:02}{}{:03}.hgt", - ns, - lat_int.unsigned_abs(), - ew, - lon_int.unsigned_abs() - ); - let cache_key = format!("srtm_{filename}"); - - if let Some(data) = cache.get(&cache_key) { - return parse_hgt(&data, lat_int as f64, lon_int as f64); - } - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build()?; - - // Primary: NASA SRTM public mirror (no auth required for .hgt) - let nasa_url = - format!("https://e4ftl01.cr.usgs.gov/MEASURES/SRTMGL1.003/2000.02.11/{filename}"); - - if let Ok(resp) = client.get(&nasa_url).send().await { - if resp.status().is_success() { - let data = resp.bytes().await?.to_vec(); - cache.put(&cache_key, &data)?; - return parse_hgt(&data, lat_int as f64, lon_int as f64); - } - } - - // Fallback: viewfinderpanoramas.org - // Files are grouped by continent zip, but individual .hgt files can be - // fetched directly when the server exposes them. - let vfp_url = format!("http://viewfinderpanoramas.org/dem1/{filename}"); - - if let Ok(resp) = client.get(&vfp_url).send().await { - if resp.status().is_success() { - let data = resp.bytes().await?.to_vec(); - cache.put(&cache_key, &data)?; - return parse_hgt(&data, lat_int as f64, lon_int as f64); - } - } - - // Final fallback: flat terrain when all downloads fail - Ok(ElevationGrid { - origin_lat: lat_int as f64, - origin_lon: lon_int as f64, - cell_size_deg: 1.0 / 3600.0, - cols: 100, - rows: 100, - heights: vec![0.0; 10000], - }) -} - -/// Parse SRTM HGT binary (3601x3601 big-endian i16). -pub fn parse_hgt(data: &[u8], origin_lat: f64, origin_lon: f64) -> Result { - let n_samples = data.len() / 2; - let side = (n_samples as f64).sqrt() as usize; - - // A valid SRTM grid is at least 2x2 — anything smaller has no cell spacing. - // Without this guard, `side - 1` underflows (panic in debug, wraps to a - // huge value in release) and `1.0 / (side - 1)` yields a garbage/inf - // `cell_size_deg` that then poisons every `ElevationGrid::get` lookup. A - // truncated download, a 404 HTML body, or an empty response can all reach - // here, so fail loudly instead of corrupting the persisted grid. - if side < 2 { - anyhow::bail!( - "HGT data too small: {} bytes ({} samples, side {}) — need at least a 2x2 grid", - data.len(), - n_samples, - side - ); - } - - let heights: Vec = data - .chunks_exact(2) - .map(|c| { - let v = i16::from_be_bytes([c[0], c[1]]); - if v == -32768 { - 0.0 - } else { - v as f32 - } // -32768 = void - }) - .collect(); - - Ok(ElevationGrid { - origin_lat, - origin_lon, - cell_size_deg: 1.0 / (side - 1) as f64, - cols: side, - rows: side, - heights, - }) -} - -/// Get elevation at a specific point from a grid. -pub fn elevation_at(grid: &ElevationGrid, point: &GeoPoint) -> f32 { - grid.get(point.lat, point.lon).unwrap_or(0.0) -} - -/// Extract a small subgrid around a point. -pub fn extract_subgrid(grid: &ElevationGrid, center: &GeoPoint, radius_m: f64) -> ElevationGrid { - let radius_deg = radius_m / 111_320.0; - let min_row = - ((grid.origin_lat + (grid.rows as f64 * grid.cell_size_deg) - center.lat - radius_deg) - / grid.cell_size_deg) - .max(0.0) as usize; - let max_row = ((grid.origin_lat + (grid.rows as f64 * grid.cell_size_deg) - center.lat - + radius_deg) - / grid.cell_size_deg) - .min(grid.rows as f64) as usize; - let min_col = - ((center.lon - radius_deg - grid.origin_lon) / grid.cell_size_deg).max(0.0) as usize; - let max_col = ((center.lon + radius_deg - grid.origin_lon) / grid.cell_size_deg) - .min(grid.cols as f64) as usize; - - let rows = max_row.saturating_sub(min_row); - let cols = max_col.saturating_sub(min_col); - let mut heights = Vec::with_capacity(rows * cols); - for r in min_row..max_row { - for c in min_col..max_col { - heights.push(grid.heights.get(r * grid.cols + c).copied().unwrap_or(0.0)); - } - } - - ElevationGrid { - origin_lat: grid.origin_lat + (grid.rows - max_row) as f64 * grid.cell_size_deg, - origin_lon: grid.origin_lon + min_col as f64 * grid.cell_size_deg, - cell_size_deg: grid.cell_size_deg, - cols, - rows, - heights, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── parse_hgt degenerate-input robustness ────────────────────────────── - // - // Before the `side < 2` guard, an empty or sub-2x2 buffer made - // `1.0 / (side - 1)` underflow `side` (panic in debug / huge wrap in - // release) and produce a garbage `cell_size_deg`. A truncated download or - // a 404 HTML page reaches `parse_hgt`, so these must Err, not panic/poison. - - #[test] - fn parse_hgt_empty_data_errors_not_panics() { - let res = parse_hgt(&[], 40.0, -75.0); - assert!(res.is_err(), "empty HGT must Err, got {res:?}"); - } - - #[test] - fn parse_hgt_single_sample_errors() { - // 2 bytes = 1 sample → side 1 → div-by-zero cell_size (inf) pre-fix. - let res = parse_hgt(&[0u8, 0u8], 40.0, -75.0); - assert!(res.is_err(), "1-sample HGT must Err, got {res:?}"); - } - - #[test] - fn parse_hgt_minimal_2x2_is_finite() { - // 4 samples = 8 bytes → side 2 → cell_size = 1.0 (finite, valid). - let data = vec![0u8; 8]; - let grid = parse_hgt(&data, 40.0, -75.0).expect("2x2 HGT should parse"); - assert_eq!(grid.cols, 2); - assert_eq!(grid.rows, 2); - assert!( - grid.cell_size_deg.is_finite() && grid.cell_size_deg > 0.0, - "cell_size must be finite positive, got {}", - grid.cell_size_deg - ); - } -} diff --git a/v2/crates/wifi-densepose-geo/src/tiles.rs b/v2/crates/wifi-densepose-geo/src/tiles.rs deleted file mode 100644 index 72ceab4c..00000000 --- a/v2/crates/wifi-densepose-geo/src/tiles.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Satellite tile fetcher — XYZ/TMS tile download with caching. - -use crate::cache::TileCache; -use crate::coord; -use crate::types::{GeoBBox, RasterTile, TileCoord}; -use anyhow::Result; - -/// Tile provider (all free, no API keys). -pub enum TileProvider { - /// Sentinel-2 cloudless mosaic (EOX, 10m, CC-BY-4.0) - Sentinel2Cloudless, - /// ESRI World Imagery (sub-meter, free tier) - EsriWorldImagery, - /// OpenStreetMap (map tiles, not satellite) - Osm, -} - -impl TileProvider { - pub fn url(&self, coord: &TileCoord) -> String { - match self { - Self::Sentinel2Cloudless => format!( - "https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2021_3857/default/g/{}/{}/{}.jpg", - coord.z, coord.y, coord.x - ), - Self::EsriWorldImagery => format!( - "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{}/{}/{}", - coord.z, coord.y, coord.x - ), - Self::Osm => format!( - "https://tile.openstreetmap.org/{}/{}/{}.png", - coord.z, coord.x, coord.y - ), - } - } - - pub fn name(&self) -> &str { - match self { - Self::Sentinel2Cloudless => "sentinel2", - Self::EsriWorldImagery => "esri", - Self::Osm => "osm", - } - } -} - -/// Fetch a single tile with caching. -pub async fn fetch_tile( - provider: &TileProvider, - coord: &TileCoord, - cache: &TileCache, -) -> Result { - let cache_key = format!("tiles_{}_{}_{}.dat", coord.z, coord.x, coord.y); - - if let Some(data) = cache.get(&cache_key) { - return Ok(RasterTile { - coord: coord.clone(), - data, - bounds: coord::tile_bounds(coord), - }); - } - - let url = provider.url(coord); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .user_agent("RuView/0.1 (https://github.com/ruvnet/RuView)") - .build()?; - - let resp = client.get(&url).send().await?; - if !resp.status().is_success() { - anyhow::bail!("Tile fetch failed: {} → {}", url, resp.status()); - } - let data = resp.bytes().await?.to_vec(); - cache.put(&cache_key, &data)?; - - Ok(RasterTile { - coord: coord.clone(), - data, - bounds: coord::tile_bounds(coord), - }) -} - -/// Fetch all tiles covering a bounding box. -pub async fn fetch_area( - provider: &TileProvider, - bbox: &GeoBBox, - zoom: u8, - cache: &TileCache, -) -> Result> { - let coords = coord::tiles_for_bbox(bbox, zoom); - let mut tiles = Vec::with_capacity(coords.len()); - for c in &coords { - match fetch_tile(provider, c, cache).await { - Ok(t) => tiles.push(t), - Err(e) => eprintln!(" Tile {}/{}/{} failed: {}", c.z, c.x, c.y, e), - } - } - Ok(tiles) -} diff --git a/v2/crates/wifi-densepose-geo/src/types.rs b/v2/crates/wifi-densepose-geo/src/types.rs deleted file mode 100644 index afa492e7..00000000 --- a/v2/crates/wifi-densepose-geo/src/types.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Core geospatial types. - -use serde::{Deserialize, Serialize}; - -/// WGS84 geographic coordinate. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct GeoPoint { - pub lat: f64, - pub lon: f64, - pub alt: f64, -} - -/// Axis-aligned bounding box in WGS84. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct GeoBBox { - pub south: f64, - pub west: f64, - pub north: f64, - pub east: f64, -} - -impl GeoBBox { - pub fn from_center(center: &GeoPoint, radius_m: f64) -> Self { - let dlat = radius_m / 111_320.0; - let dlon = radius_m / (111_320.0 * center.lat.to_radians().cos()); - Self { - south: center.lat - dlat, - west: center.lon - dlon, - north: center.lat + dlat, - east: center.lon + dlon, - } - } -} - -/// XYZ tile address. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct TileCoord { - pub z: u8, - pub x: u32, - pub y: u32, -} - -/// Satellite raster tile. -#[derive(Clone, Debug)] -pub struct RasterTile { - pub coord: TileCoord, - pub data: Vec, - pub bounds: GeoBBox, -} - -/// Elevation grid from SRTM DEM. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ElevationGrid { - pub origin_lat: f64, - pub origin_lon: f64, - pub cell_size_deg: f64, - pub cols: usize, - pub rows: usize, - pub heights: Vec, -} - -impl ElevationGrid { - pub fn get(&self, lat: f64, lon: f64) -> Option { - let row = ((self.origin_lat + (self.rows as f64 * self.cell_size_deg) - lat) - / self.cell_size_deg) as usize; - let col = ((lon - self.origin_lon) / self.cell_size_deg) as usize; - if row < self.rows && col < self.cols { - Some(self.heights[row * self.cols + col]) - } else { - None - } - } -} - -/// OpenStreetMap feature. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum OsmFeature { - Building { - outline: Vec<[f64; 2]>, - height: Option, - name: Option, - }, - Road { - path: Vec<[f64; 2]>, - road_type: String, - name: Option, - }, -} - -/// Geo-registration transform. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct GeoRegistration { - pub origin: GeoPoint, - pub heading_deg: f64, - pub scale: f64, -} - -impl Default for GeoRegistration { - fn default() -> Self { - Self { - origin: GeoPoint { - lat: 0.0, - lon: 0.0, - alt: 0.0, - }, - heading_deg: 0.0, - scale: 1.0, - } - } -} - -/// Complete geo scene. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct GeoScene { - pub location: GeoPoint, - pub bbox: GeoBBox, - pub elevation_m: f32, - pub buildings: Vec, - pub roads: Vec, - pub tile_count: usize, - pub registration: GeoRegistration, - pub last_updated: String, -} diff --git a/v2/crates/wifi-densepose-geo/tests/geo_test.rs b/v2/crates/wifi-densepose-geo/tests/geo_test.rs deleted file mode 100644 index 40d71487..00000000 --- a/v2/crates/wifi-densepose-geo/tests/geo_test.rs +++ /dev/null @@ -1,132 +0,0 @@ -use wifi_densepose_geo::coord; -use wifi_densepose_geo::*; - -#[test] -fn test_haversine() { - let toronto = GeoPoint { - lat: 43.6532, - lon: -79.3832, - alt: 0.0, - }; - let ottawa = GeoPoint { - lat: 45.4215, - lon: -75.6972, - alt: 0.0, - }; - let dist = coord::haversine(&toronto, &ottawa); - assert!( - (dist - 353_000.0).abs() < 5_000.0, - "Toronto-Ottawa ~353km, got {:.0}m", - dist - ); -} - -#[test] -fn test_wgs84_to_enu() { - let origin = GeoPoint { - lat: 43.0, - lon: -79.0, - alt: 100.0, - }; - let point = GeoPoint { - lat: 43.001, - lon: -79.0, - alt: 100.0, - }; - let enu = coord::wgs84_to_enu(&point, &origin); - assert!( - (enu[1] - 111.0).abs() < 5.0, - "0.001 deg lat ~111m north, got {:.1}m", - enu[1] - ); - assert!( - enu[0].abs() < 1.0, - "same longitude should have ~0 east, got {:.1}m", - enu[0] - ); -} - -#[test] -fn test_enu_roundtrip() { - let origin = GeoPoint { - lat: 43.6532, - lon: -79.3832, - alt: 76.0, - }; - let local = [100.0, 200.0, 5.0]; // 100m east, 200m north, 5m up - let geo = coord::enu_to_wgs84(&local, &origin); - let back = coord::wgs84_to_enu(&geo, &origin); - assert!((back[0] - local[0]).abs() < 0.01); - assert!((back[1] - local[1]).abs() < 0.01); - assert!((back[2] - local[2]).abs() < 0.01); -} - -#[test] -fn test_tile_coords() { - let tile = coord::wgs84_to_tile(43.6532, -79.3832, 16); - assert!(tile.x > 0 && tile.y > 0); - assert_eq!(tile.z, 16); - let bounds = coord::tile_bounds(&tile); - assert!(bounds.south < 43.66 && bounds.north > 43.64); -} - -#[test] -fn test_tiles_for_bbox() { - let bbox = GeoBBox::from_center( - &GeoPoint { - lat: 43.6532, - lon: -79.3832, - alt: 0.0, - }, - 500.0, - ); - let tiles = coord::tiles_for_bbox(&bbox, 16); - assert!( - tiles.len() >= 4 && tiles.len() <= 25, - "500m radius should need 4-25 tiles, got {}", - tiles.len() - ); -} - -#[test] -fn test_geo_bbox_from_center() { - let center = GeoPoint { - lat: 43.0, - lon: -79.0, - alt: 0.0, - }; - let bbox = GeoBBox::from_center(¢er, 1000.0); - assert!(bbox.south < 43.0 && bbox.north > 43.0); - assert!(bbox.west < -79.0 && bbox.east > -79.0); -} - -#[test] -fn test_hgt_parse() { - // Create minimal 3x3 HGT data (big-endian i16) - let mut data = Vec::new(); - for h in [100i16, 110, 120, 105, 115, 125, 110, 120, 130] { - data.extend_from_slice(&h.to_be_bytes()); - } - let grid = wifi_densepose_geo::terrain::parse_hgt(&data, 43.0, -79.0).unwrap(); - assert_eq!(grid.heights[0], 100.0); - assert_eq!(grid.heights[4], 115.0); -} - -#[test] -fn test_registration() { - let origin = GeoPoint { - lat: 43.6532, - lon: -79.3832, - alt: 76.0, - }; - let reg = wifi_densepose_geo::register::auto_register(&origin); - - let local = [10.0f32, 0.0, 20.0]; // 10m east, 20m forward - let geo = wifi_densepose_geo::register::local_to_wgs84(®, &local); - assert!((geo.lat - origin.lat).abs() < 0.001); - assert!((geo.lon - origin.lon).abs() < 0.001); - - let back = wifi_densepose_geo::register::wgs84_to_local(®, &geo); - assert!((back[0] - local[0]).abs() < 0.1); - assert!((back[2] - local[2]).abs() < 0.1); -} diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 110aca54..20925f15 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -59,9 +59,9 @@ wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardwar # amplitudes from the live publish; full output gating is a tracked follow-up — # see engine_bridge.rs ("Honest scope of the live-path governance"). wifi-densepose-engine = { version = "0.3.0", path = "../wifi-densepose-engine" } -wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" } +wifi-densepose-worldgraph = { version = "0.3.0", path = "../worldgraph/wifi-densepose-worldgraph" } wifi-densepose-bfld = { version = "0.3.1", path = "../wifi-densepose-bfld", default-features = false } -wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" } +wifi-densepose-geo = { version = "0.1.0", path = "../worldgraph/wifi-densepose-geo" } # ADR-262 P3: live RuField surface. The thin anti-corruption bridge that turns # this server's governed sensing cycle into signed RuField `FieldEvent`s on diff --git a/v2/crates/wifi-densepose-worldgraph/Cargo.toml b/v2/crates/wifi-densepose-worldgraph/Cargo.toml deleted file mode 100644 index 6859c85f..00000000 --- a/v2/crates/wifi-densepose-worldgraph/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "wifi-densepose-worldgraph" -description = "ADR-139 — WorldGraph environmental digital twin (typed petgraph) for RuView" -readme = "README.md" -version = "0.3.1" -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -petgraph.workspace = true -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true -thiserror.workspace = true -wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" } - -[lints.rust] -unsafe_code = "forbid" -missing_docs = "warn" diff --git a/v2/crates/wifi-densepose-worldgraph/README.md b/v2/crates/wifi-densepose-worldgraph/README.md deleted file mode 100644 index 0983918c..00000000 --- a/v2/crates/wifi-densepose-worldgraph/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# wifi-densepose-worldgraph - -**The environmental digital twin for RF sensing — a typed, evidence-tracked graph of a building and the people in it.** - -[![crates.io](https://img.shields.io/crates/v/wifi-densepose-worldgraph.svg)](https://crates.io/crates/wifi-densepose-worldgraph) -[![docs.rs](https://docs.rs/wifi-densepose-worldgraph/badge.svg)](https://docs.rs/wifi-densepose-worldgraph) - -Part of the [RuView / WiFi-DensePose](https://github.com/ruvnet/RuView) project. Implements **ADR-139**. - ---- - -## What it is (plain language) - -When you sense a space with WiFi/RF (people, motion, vital signs), you get a firehose of *frames*. -What you actually want is a **living map**: which rooms exist, where the walls and doorways are, which -sensors watch which zones, where each person is right now, and *why the system believes that* — with -enough structure to reason over and enough provenance to trust. - -`wifi-densepose-worldgraph` is that map. It's a **typed graph** (built on [`petgraph`](https://crates.io/crates/petgraph)): - -- **Nodes** are real things — `Room`, `Zone`, `Wall`, `Doorway`, `Sensor`, `RfLink`, `PersonTrack`, `ObjectAnchor`, `Event`, and `SemanticState` (a belief). -- **Edges** are typed relations — `Observes`, `LocatedIn`, `AdjacentTo`, `Supports`, `Contradicts`, `DerivedFrom`, `PrivacyLimitedBy`. - -It stores **fused beliefs, not raw frames** — it sits *downstream* of signal fusion and *upstream* of the -semantic/agent layer. Every belief (`SemanticState`) is required to carry **provenance**: the signal -evidence, the model, the calibration id, and the privacy decision that produced it. That's enforced -*structurally*, so "where did this conclusion come from?" always has an answer. - -## Why a graph (and not an occupancy grid or an event log)? - -| Approach | Good at | Misses | -|---|---|---| -| **Raw event log** | append-only history, audit | no structure; can't ask "who's in the kitchen?" without re-deriving it | -| **Occupancy grid / voxels** | dense geometry, ML input | no identity, no relations, no provenance, no semantics | -| **Scene graph (this crate)** | relations, identity, semantics, provenance, privacy | not a dense field — pair it with a grid for ML (see [`wifi-densepose-worldmodel`](https://crates.io/crates/wifi-densepose-worldmodel)) | - -The graph is the **symbolic, interpretable** layer. It answers *relational* questions ("is this person in a -zone observed by sensor X?", "are these two beliefs contradictory?") in O(neighbors), and it keeps the -*why* attached to every *what*. - -## Features - -- 🧱 **Typed node/edge model** — a closed `enum` schema (serde-tagged) → deterministic, schema-versioned wire format. -- 🧭 **Geometry in ENU meters** — rooms/zones/walls/doorways carry East-North-Up bounds; walls carry `rf_attenuation_db`. -- 🧠 **Beliefs with mandatory provenance** — `SemanticState` → `SemanticProvenance { signal evidence, model, calibration_id, privacy_decision }`. -- 🔀 **Evidence reasoning built in** — `Supports` / `Contradicts` / `DerivedFrom` edges let you score and challenge conclusions, not just store them. -- 🔒 **Privacy as a first-class edge** — `PrivacyLimitedBy` + `apply_privacy_mode()` roll up what a given mode/action is allowed to see. -- 💾 **Deterministic JSON persistence** — `to_json` / `from_json` (the RVF payload), schema-versioned. -- 🚫 **`#![forbid(unsafe_code)]`**, `missing_docs = warn`. Pure Rust, no async, edge-deployable (builds clean on aarch64 — runs on a Raspberry Pi). - -## Install - -```toml -[dependencies] -wifi-densepose-worldgraph = "0.3" -``` - -## Usage - -```rust -use wifi_densepose_worldgraph::{WorldGraph, WorldNode, WorldEdge, ZoneBoundsEnu}; -// (GeoRegistration comes from wifi-densepose-geo — it anchors ENU to a real lat/lon origin) - -let mut wg = WorldGraph::new(registration); - -// Add a room and a sensor that observes it. -let living_room = wg.upsert_node(WorldNode::Room { - id: Default::default(), - area_id: Some("living_room".into()), - name: "Living Room".into(), - bounds_enu: ZoneBoundsEnu { /* … */ }, - floor: 0, -}); -let sensor = wg.upsert_node(/* WorldNode::Sensor { … } */); -wg.add_edge(sensor, living_room, WorldEdge::Observes { quality: 0.9, last_seen_unix_ms: now }); - -// Query relations. -let watched = wg.observed_by(sensor); // what this sensor sees -let room = wg.room_for_area("living_room"); // area_id → room node - -// Record a belief WITH provenance, and a contradiction against it. -wg.add_semantic_state(/* state + SemanticProvenance */); -wg.add_contradiction(belief_a, belief_b, /* magnitude */, "two sensors disagree"); - -// Privacy rollup for a mode/action, then persist. -let rollup = wg.apply_privacy_mode("HOME", "occworld_inference", |node| /* allow? */ true); -let bytes = wg.to_json()?; // RVF payload -let restored = WorldGraph::from_json(&bytes)?; -``` - -## Technical details - -- **Backing store:** `petgraph::StableDiGraph` (stable indices across removals) wrapped as `WorldGraph`. -- **Identity:** every node has a `WorldId`; `upsert_node` is idempotent on identity. -- **Snapshots:** `snapshot()` → `WorldGraphSnapshot` (a serializable point-in-time view) with a `PrivacyRollup`. -- **Schema versioning:** `SCHEMA_VERSION` is embedded in the JSON; the closed enum model means readers fail fast on incompatible payloads rather than silently mis-parsing. -- **Coordinates:** ENU (East/North/Up) meters relative to a `GeoRegistration` origin (`wifi-densepose-geo`), so the twin can be georeferenced to a real building. -- **Position in the pipeline:** `fusion (ADR-137) → WorldGraph (ADR-139) → semantic/agent layer (ADR-140) → eval harness (ADR-145)`. For **forward prediction** (where will people be next?), pair it with [`wifi-densepose-worldmodel`](https://crates.io/crates/wifi-densepose-worldmodel), which turns `PersonTrack` history into predicted occupancy + trajectory priors. - -## Related crates - -| Crate | Role | -|---|---| -| [`wifi-densepose-worldmodel`](https://crates.io/crates/wifi-densepose-worldmodel) | Forward **prediction** — occupancy world model over this graph's tracks | -| [`wifi-densepose-geo`](https://crates.io/crates/wifi-densepose-geo) | Geospatial registration (ENU ↔ lat/lon, DEM, OSM) | - -## License - -Licensed as the parent project. See the [repository](https://github.com/ruvnet/RuView). diff --git a/v2/crates/wifi-densepose-worldgraph/src/error.rs b/v2/crates/wifi-densepose-worldgraph/src/error.rs deleted file mode 100644 index a1e697df..00000000 --- a/v2/crates/wifi-densepose-worldgraph/src/error.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! WorldGraph error type. - -use crate::model::WorldId; - -/// Errors from WorldGraph operations. -#[derive(Debug, thiserror::Error)] -pub enum WorldGraphError { - /// An edge endpoint referenced an unknown node. - #[error("unknown node {0:?}")] - UnknownNode(WorldId), - - /// (De)serialisation of the persisted graph failed. - #[error("serialization error: {0}")] - Serde(#[from] serde_json::Error), -} diff --git a/v2/crates/wifi-densepose-worldgraph/src/graph.rs b/v2/crates/wifi-densepose-worldgraph/src/graph.rs deleted file mode 100644 index 6ffad2e2..00000000 --- a/v2/crates/wifi-densepose-worldgraph/src/graph.rs +++ /dev/null @@ -1,566 +0,0 @@ -//! ADR-139 §2.2–2.5 — graph container, provenance, privacy rollup, queries. - -use std::collections::HashMap; - -use petgraph::stable_graph::{NodeIndex, StableDiGraph}; -use petgraph::visit::{EdgeRef, IntoEdgeReferences}; -use petgraph::Direction; -use serde::{Deserialize, Serialize}; -use wifi_densepose_geo::types::GeoRegistration; - -use crate::error::WorldGraphError; -use crate::model::{SemanticProvenance, WorldEdge, WorldId, WorldNode}; - -/// Current persisted schema version (ADR-136 §2.1 reserved-flag pattern). -pub const SCHEMA_VERSION: u16 = 1; - -/// The typed environmental digital twin (ADR-139). Wraps a petgraph -/// `StableDiGraph` and exposes a domain API; stable `WorldId → NodeIndex` -/// mapping survives node removal. -#[derive(Debug)] -pub struct WorldGraph { - inner: StableDiGraph, - index: HashMap, - registration: GeoRegistration, - next_id: u64, - schema_version: u16, -} - -/// Serializable snapshot of a [`WorldGraph`] for RVF/JSON persistence. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct WorldGraphSnapshot { - schema_version: u16, - registration: GeoRegistration, - next_id: u64, - nodes: Vec, - /// Edges as (from_id, to_id, edge). - edges: Vec<(WorldId, WorldId, WorldEdge)>, -} - -/// Result of a privacy-impact rollup (ADR-139 §2.4). -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct PrivacyRollup { - /// Active mode name. - pub mode: String, - /// Nodes that become unobservable under this mode. - pub suppressed_nodes: Vec, - /// (sensor, node) pairs newly denied. - pub denied_pairs: Vec<(WorldId, WorldId)>, - /// Count of still-allowed (sensor, node) pairs. - pub allowed_pairs: usize, -} - -impl WorldGraph { - /// Create an empty graph registered to an installation origin. - #[must_use] - pub fn new(registration: GeoRegistration) -> Self { - Self { - inner: StableDiGraph::new(), - index: HashMap::new(), - registration, - next_id: 1, - schema_version: SCHEMA_VERSION, - } - } - - /// Installation geo-registration (ADR-044). - #[must_use] - pub fn registration(&self) -> &GeoRegistration { - &self.registration - } - - /// Number of live nodes. - #[must_use] - pub fn node_count(&self) -> usize { - self.inner.node_count() - } - - /// Insert or replace a node, returning its stable `WorldId`. If the node's - /// embedded id is `UNASSIGNED`, a fresh id is allocated; if it names an - /// existing id, that node's weight is replaced in place (upsert). - pub fn upsert_node(&mut self, mut node: WorldNode) -> WorldId { - let id = if node.id().is_unassigned() { - let fresh = WorldId(self.next_id); - self.next_id += 1; - node.set_id(fresh); - fresh - } else { - self.next_id = self.next_id.max(node.id().0 + 1); - node.id() - }; - - if let Some(&idx) = self.index.get(&id) { - self.inner[idx] = node; - } else { - let idx = self.inner.add_node(node); - self.index.insert(id, idx); - } - id - } - - /// Add a typed edge between two known nodes. - /// - /// # Errors - /// [`WorldGraphError::UnknownNode`] if either endpoint is unknown. - pub fn add_edge( - &mut self, - from: WorldId, - to: WorldId, - edge: WorldEdge, - ) -> Result<(), WorldGraphError> { - let f = *self.index.get(&from).ok_or(WorldGraphError::UnknownNode(from))?; - let t = *self.index.get(&to).ok_or(WorldGraphError::UnknownNode(to))?; - self.inner.add_edge(f, t, edge); - Ok(()) - } - - /// Borrow a node by id. - #[must_use] - pub fn node(&self, id: WorldId) -> Option<&WorldNode> { - self.index.get(&id).map(|&idx| &self.inner[idx]) - } - - /// Remove a node and its incident edges (e.g. a person leaves). - pub fn remove_node(&mut self, id: WorldId) -> Option { - let idx = self.index.remove(&id)?; - self.inner.remove_node(idx) - } - - /// Outgoing neighbours of a node with the connecting edge. - pub fn neighbors(&self, id: WorldId) -> Vec<(WorldId, WorldEdge)> { - let Some(&idx) = self.index.get(&id) else { - return Vec::new(); - }; - self.inner - .edges_directed(idx, Direction::Outgoing) - .map(|e| (self.inner[e.target()].id(), e.weight().clone())) - .collect() - } - - /// Resolve a HomeCore `area_id` to its Room node (entity linkage, ADR-127). - #[must_use] - pub fn room_for_area(&self, area_id: &str) -> Option { - self.inner.node_weights().find_map(|n| match n { - WorldNode::Room { id, area_id: Some(a), .. } if a == area_id => Some(*id), - _ => None, - }) - } - - // ---- ADR-139 §2.5 query API (v1) ---- - - /// Observability chain: which nodes a sensor currently `observes`. - #[must_use] - pub fn observed_by(&self, sensor: WorldId) -> Vec { - self.neighbors(sensor) - .into_iter() - .filter(|(_, e)| matches!(e, WorldEdge::Observes { .. })) - .map(|(id, _)| id) - .collect() - } - - /// Location query: contents of a room/zone (incoming `located_in` edges). - #[must_use] - pub fn contents_of(&self, container: WorldId) -> Vec { - let Some(&idx) = self.index.get(&container) else { - return Vec::new(); - }; - self.inner - .edges_directed(idx, Direction::Incoming) - .filter(|e| matches!(e.weight(), WorldEdge::LocatedIn { .. })) - .map(|e| self.inner[e.source()].id()) - .collect() - } - - /// Append-with-provenance: insert a `SemanticState` and wire `DerivedFrom` - /// edges to its evidence sources (ADR-139 §2.3). Sources unknown to the - /// graph are skipped (evidence may be raw frames not modelled as nodes). - pub fn add_semantic_state( - &mut self, - statement: String, - confidence: f32, - valid_from_unix_ms: i64, - provenance: SemanticProvenance, - evidence_sources: &[WorldId], - ) -> WorldId { - let evidence_handles = provenance.evidence.clone(); - let id = self.upsert_node(WorldNode::SemanticState { - id: WorldId::UNASSIGNED, - statement, - confidence, - provenance, - valid_from_unix_ms, - }); - for (src, handle) in evidence_sources.iter().zip( - evidence_handles - .iter() - .cloned() - .chain(std::iter::repeat(String::new())), - ) { - let _ = self.add_edge(id, *src, WorldEdge::DerivedFrom { evidence: handle }); - } - id - } - - /// Retention: evict the oldest `SemanticState` nodes (with their incident - /// edges) until at most `max_states` remain. Returns the evicted ids, - /// oldest first. - /// - /// The live loop appends one belief per cycle (`StreamingEngine:: - /// process_cycle`), which at 20 Hz is ~1.7M nodes/day — unbounded without - /// this. The WorldGraph holds *current* beliefs; durable history belongs to - /// the recorder (`homecore-recorder`), so evicting old beliefs loses no - /// audit data. - /// - /// Deterministic: eviction order is ascending `(valid_from_unix_ms, id)`, - /// so replaying the same cycle sequence prunes identically. Only - /// `SemanticState` nodes are eligible — rooms, zones, sensors, anchors, - /// person tracks, and events are never evicted by this method. - pub fn prune_semantic_states(&mut self, max_states: usize) -> Vec { - let mut states: Vec<(i64, u64)> = self - .inner - .node_weights() - .filter_map(|n| match n { - WorldNode::SemanticState { id, valid_from_unix_ms, .. } => { - Some((*valid_from_unix_ms, id.0)) - } - _ => None, - }) - .collect(); - if states.len() <= max_states { - return Vec::new(); - } - states.sort_unstable(); - let n_evict = states.len() - max_states; - states.truncate(n_evict); - states - .into_iter() - .map(|(_, raw)| { - let id = WorldId(raw); - self.remove_node(id); - id - }) - .collect() - } - - /// Record a contradiction between two still-live beliefs (ADR-139 §2.3). - /// Neither node is deleted — the disagreement stays queryable. - /// - /// # Errors - /// [`WorldGraphError::UnknownNode`] if either node is unknown. - pub fn add_contradiction( - &mut self, - a: WorldId, - b: WorldId, - magnitude: f32, - flag: String, - ) -> Result<(), WorldGraphError> { - self.add_edge(a, b, WorldEdge::Contradicts { magnitude, flag }) - } - - /// Recompute `PrivacyLimitedBy` edges for the active mode (ADR-139 §2.4). - /// - /// `policy(modality_kind, node_kind) -> allowed` decides, for each existing - /// `Observes` edge, whether the sensor may still observe the target under - /// `mode`. A matching `PrivacyLimitedBy` edge is appended recording the - /// decision; denied pairs are rolled up. - pub fn apply_privacy_mode(&mut self, mode: &str, action: &str, policy: F) -> PrivacyRollup - where - F: Fn(&str, &str) -> bool, - { - // Collect (sensor, target, allowed) from current Observes edges. - let mut decisions: Vec<(WorldId, WorldId, bool)> = Vec::new(); - for e in self.inner.edge_references() { - if matches!(e.weight(), WorldEdge::Observes { .. }) { - let sensor = &self.inner[e.source()]; - let target = &self.inner[e.target()]; - let allowed = policy(sensor.kind(), target.kind()); - decisions.push((sensor.id(), target.id(), allowed)); - } - } - - let mut denied_pairs = Vec::new(); - let mut suppressed = Vec::new(); - let mut allowed_pairs = 0usize; - for (sensor, target, allowed) in &decisions { - let _ = self.add_edge( - *sensor, - *target, - WorldEdge::PrivacyLimitedBy { - mode: mode.to_string(), - action: action.to_string(), - allowed: *allowed, - }, - ); - if *allowed { - allowed_pairs += 1; - } else { - denied_pairs.push((*sensor, *target)); - if !suppressed.contains(target) { - suppressed.push(*target); - } - } - } - - PrivacyRollup { - mode: mode.to_string(), - suppressed_nodes: suppressed, - denied_pairs, - allowed_pairs, - } - } - - // ---- Persistence (RVF/JSON) ---- - - /// Snapshot the graph for persistence. - #[must_use] - pub fn snapshot(&self) -> WorldGraphSnapshot { - let nodes: Vec = self.inner.node_weights().cloned().collect(); - let edges: Vec<(WorldId, WorldId, WorldEdge)> = self - .inner - .edge_references() - .map(|e| { - ( - self.inner[e.source()].id(), - self.inner[e.target()].id(), - e.weight().clone(), - ) - }) - .collect(); - WorldGraphSnapshot { - schema_version: self.schema_version, - registration: self.registration.clone(), - next_id: self.next_id, - nodes, - edges, - } - } - - /// Serialize to deterministic JSON bytes (RVF payload). - /// - /// # Errors - /// [`WorldGraphError::Serde`] on serialisation failure. - pub fn to_json(&self) -> Result, WorldGraphError> { - Ok(serde_json::to_vec(&self.snapshot())?) - } - - /// Reconstruct a graph from a snapshot's JSON bytes. - /// - /// # Errors - /// [`WorldGraphError::Serde`] on parse failure. - pub fn from_json(bytes: &[u8]) -> Result { - let snap: WorldGraphSnapshot = serde_json::from_slice(bytes)?; - let mut g = Self::new(snap.registration); - g.schema_version = snap.schema_version; - for node in snap.nodes { - g.upsert_node(node); - } - for (from, to, edge) in snap.edges { - g.add_edge(from, to, edge)?; - } - g.next_id = snap.next_id; - Ok(g) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::{EnuPoint, SensorModality, WorldEdge, ZoneBoundsEnu}; - - fn enu(e: f64, n: f64) -> EnuPoint { - EnuPoint { east_m: e, north_m: n, up_m: 0.0 } - } - - fn living_room() -> WorldNode { - WorldNode::Room { - id: WorldId::UNASSIGNED, - area_id: Some("living_room".into()), - name: "Living Room".into(), - bounds_enu: ZoneBoundsEnu::Rectangle { min_e: 0.0, min_n: 0.0, max_e: 5.0, max_n: 4.0 }, - floor: 0, - } - } - - #[test] - fn upsert_allocates_and_replaces() { - let mut g = WorldGraph::new(GeoRegistration::default()); - let id = g.upsert_node(living_room()); - assert!(!id.is_unassigned()); - assert_eq!(g.node_count(), 1); - // Upsert same id with new name → replace in place, count unchanged. - g.upsert_node(WorldNode::Room { - id, - area_id: Some("living_room".into()), - name: "Lounge".into(), - bounds_enu: ZoneBoundsEnu::Rectangle { min_e: 0.0, min_n: 0.0, max_e: 5.0, max_n: 4.0 }, - floor: 0, - }); - assert_eq!(g.node_count(), 1); - assert!(matches!(g.node(id), Some(WorldNode::Room { name, .. }) if name == "Lounge")); - } - - #[test] - fn area_linkage_and_observability() { - let mut g = WorldGraph::new(GeoRegistration::default()); - let room = g.upsert_node(living_room()); - let sensor = g.upsert_node(WorldNode::Sensor { - id: WorldId::UNASSIGNED, - device_id: "esp32-com9".into(), - position: enu(1.0, 1.0), - modality: SensorModality::WifiCsi, - }); - g.add_edge(sensor, room, WorldEdge::Observes { quality: 0.9, last_seen_unix_ms: 1 }) - .unwrap(); - - assert_eq!(g.room_for_area("living_room"), Some(room)); - assert_eq!(g.observed_by(sensor), vec![room]); - } - - #[test] - fn add_edge_unknown_endpoint_errors() { - let mut g = WorldGraph::new(GeoRegistration::default()); - let room = g.upsert_node(living_room()); - let err = g.add_edge(room, WorldId(999), WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 }); - assert!(matches!(err, Err(WorldGraphError::UnknownNode(WorldId(999))))); - } - - #[test] - fn location_query_contents_of() { - let mut g = WorldGraph::new(GeoRegistration::default()); - let room = g.upsert_node(living_room()); - let person = g.upsert_node(WorldNode::PersonTrack { - id: WorldId::UNASSIGNED, - track_id: 7, - last_position: enu(2.0, 2.0), - reid_embedding_ref: None, - }); - g.add_edge(person, room, WorldEdge::LocatedIn { since_unix_ms: 100 }).unwrap(); - assert_eq!(g.contents_of(room), vec![person]); - } - - #[test] - fn semantic_state_provenance_and_contradiction() { - let mut g = WorldGraph::new(GeoRegistration::default()); - let event = g.upsert_node(WorldNode::Event { - id: WorldId::UNASSIGNED, - event_type: "motion".into(), - at_unix_ms: 10, - located_in: None, - }); - let prov = SemanticProvenance { - evidence: vec!["ev:abc".into()], - model_version: "rfenc-1.0".into(), - calibration_version: "cal:uuid".into(), - privacy_decision: "PrivateHome/Allow".into(), - }; - let s1 = g.add_semantic_state("present".into(), 0.9, 11, prov.clone(), &[event]); - // DerivedFrom edge to the evidence event exists. - assert!(g.neighbors(s1).iter().any(|(to, e)| *to == event - && matches!(e, WorldEdge::DerivedFrom { .. }))); - - let s2 = g.add_semantic_state("absent".into(), 0.6, 12, prov, &[event]); - g.add_contradiction(s1, s2, 0.3, "flag:ts".into()).unwrap(); - // Both beliefs retained; contradiction queryable. - assert!(g.node(s1).is_some() && g.node(s2).is_some()); - assert!(g.neighbors(s1).iter().any(|(_, e)| matches!(e, WorldEdge::Contradicts { .. }))); - } - - #[test] - fn prune_semantic_states_evicts_oldest_only() { - let mut g = WorldGraph::new(GeoRegistration::default()); - let room = g.upsert_node(living_room()); - let prov = SemanticProvenance { - evidence: vec!["ev:abc".into()], - model_version: "rfenc-1.0".into(), - calibration_version: "cal:uuid".into(), - privacy_decision: "PrivateHome/Allow".into(), - }; - let ids: Vec = (0..10) - .map(|t| g.add_semantic_state(format!("s{t}"), 0.9, t, prov.clone(), &[room])) - .collect(); - assert_eq!(g.node_count(), 11); // room + 10 beliefs - - let evicted = g.prune_semantic_states(3); - // Oldest 7 evicted, in ascending timestamp order. - assert_eq!(evicted, ids[..7].to_vec()); - assert_eq!(g.node_count(), 4); // room + 3 newest beliefs - for kept in &ids[7..] { - assert!(g.node(*kept).is_some()); - } - // The room (structural node) is never eligible for eviction. - assert!(g.node(room).is_some()); - // Below the cap, pruning is a no-op. - assert!(g.prune_semantic_states(3).is_empty()); - } - - #[test] - fn prune_is_deterministic_for_equal_timestamps() { - let prov = SemanticProvenance { - evidence: vec![], - model_version: "m".into(), - calibration_version: "c".into(), - privacy_decision: "p".into(), - }; - let build = || { - let mut g = WorldGraph::new(GeoRegistration::default()); - let room = g.upsert_node(living_room()); - for _ in 0..6 { - // Identical timestamps: tie-break must fall back to id order. - g.add_semantic_state("s".into(), 0.5, 100, prov.clone(), &[room]); - } - g - }; - let mut g1 = build(); - let mut g2 = build(); - assert_eq!(g1.prune_semantic_states(2), g2.prune_semantic_states(2)); - } - - #[test] - fn privacy_rollup_suppresses_person_tracks() { - let mut g = WorldGraph::new(GeoRegistration::default()); - let room = g.upsert_node(living_room()); - let person = g.upsert_node(WorldNode::PersonTrack { - id: WorldId::UNASSIGNED, - track_id: 1, - last_position: enu(1.0, 1.0), - reid_embedding_ref: None, - }); - let sensor = g.upsert_node(WorldNode::Sensor { - id: WorldId::UNASSIGNED, - device_id: "s".into(), - position: enu(0.0, 0.0), - modality: SensorModality::WifiCsi, - }); - g.add_edge(sensor, room, WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 }).unwrap(); - g.add_edge(sensor, person, WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 }).unwrap(); - - // StrictNoIdentity: rooms observable, person_tracks suppressed. - let rollup = g.apply_privacy_mode("StrictNoIdentity", "SuppressIdentity", |_modality, node_kind| { - node_kind != "person_track" - }); - assert_eq!(rollup.allowed_pairs, 1); - assert_eq!(rollup.denied_pairs, vec![(sensor, person)]); - assert_eq!(rollup.suppressed_nodes, vec![person]); - } - - #[test] - fn json_roundtrip_preserves_nodes_and_edges() { - let mut g = WorldGraph::new(GeoRegistration::default()); - let room = g.upsert_node(living_room()); - let sensor = g.upsert_node(WorldNode::Sensor { - id: WorldId::UNASSIGNED, - device_id: "s".into(), - position: enu(0.0, 0.0), - modality: SensorModality::WifiCsi, - }); - g.add_edge(sensor, room, WorldEdge::Observes { quality: 0.8, last_seen_unix_ms: 5 }).unwrap(); - - let bytes = g.to_json().unwrap(); - let g2 = WorldGraph::from_json(&bytes).unwrap(); - assert_eq!(g2.node_count(), 2); - assert_eq!(g2.room_for_area("living_room"), Some(room)); - assert_eq!(g2.observed_by(sensor), vec![room]); - // Deterministic: re-serialising the reconstructed graph matches. - assert_eq!(g2.to_json().unwrap(), bytes); - } -} diff --git a/v2/crates/wifi-densepose-worldgraph/src/lib.rs b/v2/crates/wifi-densepose-worldgraph/src/lib.rs deleted file mode 100644 index c062d725..00000000 --- a/v2/crates/wifi-densepose-worldgraph/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! # WiFi-DensePose WorldGraph (ADR-139) -//! -//! The environmental digital twin for the RuView streaming engine: a typed -//! [`petgraph`] `StableDiGraph` of rooms, zones, walls, doorways, sensors, RF -//! links, person tracks, object anchors, events, and semantic-state beliefs, -//! connected by typed relations (observes / located_in / adjacent_to / -//! supports / contradicts / derived_from / privacy_limited_by). -//! -//! It sits downstream of fusion (ADR-137) — storing fused *beliefs*, not raw -//! frames — and upstream of the semantic/agent layer (ADR-140) and evaluation -//! harness (ADR-145). Every [`model::WorldNode::SemanticState`] carries -//! mandatory [`model::SemanticProvenance`] (signal evidence + model + -//! calibration + privacy decision), honouring the house rule structurally. -//! -//! Persistence is via [`graph::WorldGraph::to_json`] / -//! [`graph::WorldGraph::from_json`] (the RVF payload); the serde-enum node/edge -//! model guarantees a deterministic, schema-versioned wire layout. - -#![forbid(unsafe_code)] - -pub mod error; -pub mod graph; -pub mod model; - -pub use error::WorldGraphError; -pub use graph::{PrivacyRollup, WorldGraph, WorldGraphSnapshot, SCHEMA_VERSION}; -pub use model::{ - AnchorKind, EnuPoint, SemanticProvenance, SensorModality, WorldEdge, WorldId, WorldNode, - ZoneBoundsEnu, -}; diff --git a/v2/crates/wifi-densepose-worldgraph/src/model.rs b/v2/crates/wifi-densepose-worldgraph/src/model.rs deleted file mode 100644 index f4e18ea6..00000000 --- a/v2/crates/wifi-densepose-worldgraph/src/model.rs +++ /dev/null @@ -1,385 +0,0 @@ -//! ADR-139 §2.1 — typed node/edge model. -//! -//! Nodes and edges are `serde` enums (NOT boxed trait objects) for -//! deterministic, schema-versioned, RVF-friendly persistence. Cross-ADR -//! references (ADR-137 evidence, ADR-141 privacy decision) are carried as -//! opaque content-address `String` handles so the WorldGraph compiles and -//! persists independently of those crates (§2.1, §2.3). - -use serde::{Deserialize, Serialize}; - -/// Stable, monotonic identity for a world entity. Distinct from petgraph's -/// `NodeIndex` (graph-internal handle); `WorldId` survives RVF round-trips and -/// node removal. `WorldId(0)` is the "assign me one" sentinel for `upsert_node`. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct WorldId(pub u64); - -impl WorldId { - /// The "allocate a fresh id" sentinel. - pub const UNASSIGNED: WorldId = WorldId(0); - - /// Whether this id is the unassigned sentinel. - #[must_use] - pub fn is_unassigned(&self) -> bool { - self.0 == 0 - } -} - -/// Local ENU coordinate in metres relative to the installation origin (ADR-044). -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct EnuPoint { - /// East offset (m). - pub east_m: f64, - /// North offset (m). - pub north_m: f64, - /// Up offset (m). - pub up_m: f64, -} - -/// MAT `ZoneBounds` reprojected into the installation ENU frame. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "shape", rename_all = "snake_case")] -pub enum ZoneBoundsEnu { - /// Axis-aligned rectangle. - Rectangle { - /// Minimum east (m). - min_e: f64, - /// Minimum north (m). - min_n: f64, - /// Maximum east (m). - max_e: f64, - /// Maximum north (m). - max_n: f64, - }, - /// Circle. - Circle { - /// Centre east (m). - center_e: f64, - /// Centre north (m). - center_n: f64, - /// Radius (m). - radius_m: f64, - }, - /// Polygon (east, north) vertices. - Polygon { - /// (east, north) vertices. - vertices: Vec<(f64, f64)>, - }, -} - -impl ZoneBoundsEnu { - /// Whether an ENU point lies within these bounds (up ignored). - #[must_use] - pub fn contains(&self, p: &EnuPoint) -> bool { - match self { - Self::Rectangle { min_e, min_n, max_e, max_n } => { - p.east_m >= *min_e && p.east_m <= *max_e && p.north_m >= *min_n && p.north_m <= *max_n - } - Self::Circle { center_e, center_n, radius_m } => { - let de = p.east_m - center_e; - let dn = p.north_m - center_n; - (de * de + dn * dn).sqrt() <= *radius_m - } - Self::Polygon { vertices } => point_in_polygon(p.east_m, p.north_m, vertices), - } - } -} - -fn point_in_polygon(px: f64, py: f64, verts: &[(f64, f64)]) -> bool { - if verts.len() < 3 { - return false; - } - // Ray-casting parity test. - let mut inside = false; - let mut j = verts.len() - 1; - for i in 0..verts.len() { - let (xi, yi) = verts[i]; - let (xj, yj) = verts[j]; - let intersect = ((yi > py) != (yj > py)) - && (px < (xj - xi) * (py - yi) / (yj - yi) + xi); - if intersect { - inside = !inside; - } - j = i; - } - inside -} - -/// Sensing modality of a physical device placement. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SensorModality { - /// WiFi CSI sensing node (ESP32-S3/C6). - WifiCsi, - /// 60 GHz mmWave FMCW radar. - MmWave, - /// Ultra-wideband ranging beacon (ADR-144). - Uwb, - /// Coarse presence sensor. - Presence, -} - -/// Kind of persistent static anchor. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AnchorKind { - /// A persistent RF reflector (ADR-143 RF SLAM). - Reflector, - /// A piece of furniture inferred from reflector clustering. - Furniture, - /// A surveyed UWB beacon (ADR-144). - UwbBeacon, -} - -/// Mandatory provenance for every [`WorldNode::SemanticState`] (house rule): -/// every semantic belief traces to signal evidence + model + calibration + -/// privacy decision. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct SemanticProvenance { - /// ADR-137 `EvidenceRef` content-address handle(s). - pub evidence: Vec, - /// Model version (ADR-136 `model_id`/`model_version`) that produced this. - pub model_version: String, - /// Calibration version (ADR-135 baseline id) in effect. - pub calibration_version: String, - /// Privacy decision (ADR-141 mode + action) it was derived under. - pub privacy_decision: String, -} - -/// A typed world node (ADR-139 §2.1). Persistence-deterministic serde enum. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum WorldNode { - /// A bounded interior space, linked to a HomeCore `area_id` (ADR-127). - Room { - /// Stable id (or `UNASSIGNED` to allocate). - id: WorldId, - /// HomeCore registry area_id — the entity-linkage join key. - area_id: Option, - /// Human name. - name: String, - /// Room footprint in local ENU. - bounds_enu: ZoneBoundsEnu, - /// Floor index. - floor: i16, - }, - /// A sub-region of a room targeted for sensing (MAT ScanZone analogue). - Zone { - /// Stable id. - id: WorldId, - /// Containing room. - parent_room: WorldId, - /// Human name. - name: String, - /// Zone footprint. - bounds_enu: ZoneBoundsEnu, - }, - /// A wall segment (coarse 2D topological element in ENU). - Wall { - /// Stable id. - id: WorldId, - /// Segment start. - a: EnuPoint, - /// Segment end. - b: EnuPoint, - /// Coarse RF attenuation (dB): drywall ≈ 3, brick ≈ 12. - rf_attenuation_db: f32, - }, - /// A passable opening between two rooms. - Doorway { - /// Stable id. - id: WorldId, - /// Centre point. - center: EnuPoint, - /// Opening width (m). - width_m: f32, - }, - /// A physical sensing device placement (ADR-113 placement target). - Sensor { - /// Stable id. - id: WorldId, - /// Matches HomeCore `EntityEntry.device_id`. - device_id: String, - /// Placement in local ENU. - position: EnuPoint, - /// Sensing modality. - modality: SensorModality, - }, - /// A directed RF propagation channel between two sensors (ADR-138 LinkGroup member). - RfLink { - /// Stable id. - id: WorldId, - /// Transmit sensor node. - tx: WorldId, - /// Receive sensor node. - rx: WorldId, - /// ADR-138 MLO LinkGroup id. - link_group_id: Option, - /// Centre frequency (MHz). - center_freq_mhz: u32, - }, - /// A tracked person (Kalman track id from ruvsense `pose_tracker`). - PersonTrack { - /// Stable id. - id: WorldId, - /// Tracker track id. - track_id: u64, - /// Last known ENU position. - last_position: EnuPoint, - /// AETHER re-ID embedding handle. - reid_embedding_ref: Option, - }, - /// A persistent static reflector / object (ADR-143 / ADR-144 anchor). - ObjectAnchor { - /// Stable id. - id: WorldId, - /// ENU position. - position: EnuPoint, - /// Anchor classification. - anchor_kind: AnchorKind, - /// Confidence in [0, 1]. - confidence: f32, - }, - /// A discrete detected event (fall, entry, gesture) at a point in time. - Event { - /// Stable id. - id: WorldId, - /// Event type tag. - event_type: String, - /// Wall-clock time (Unix ms). - at_unix_ms: i64, - /// Containing room/zone. - located_in: Option, - }, - /// A fused semantic belief about the world (the ADR-140 record's graph anchor). - SemanticState { - /// Stable id. - id: WorldId, - /// Human-readable belief statement. - statement: String, - /// Confidence in [0, 1]. - confidence: f32, - /// Mandatory provenance (house rule). - provenance: SemanticProvenance, - /// Belief validity start (Unix ms). - valid_from_unix_ms: i64, - }, -} - -impl WorldNode { - /// The embedded stable id of this node. - #[must_use] - pub fn id(&self) -> WorldId { - match self { - Self::Room { id, .. } - | Self::Zone { id, .. } - | Self::Wall { id, .. } - | Self::Doorway { id, .. } - | Self::Sensor { id, .. } - | Self::RfLink { id, .. } - | Self::PersonTrack { id, .. } - | Self::ObjectAnchor { id, .. } - | Self::Event { id, .. } - | Self::SemanticState { id, .. } => *id, - } - } - - /// Overwrite the embedded id (used by `upsert_node` when allocating one). - pub(crate) fn set_id(&mut self, new: WorldId) { - match self { - Self::Room { id, .. } - | Self::Zone { id, .. } - | Self::Wall { id, .. } - | Self::Doorway { id, .. } - | Self::Sensor { id, .. } - | Self::RfLink { id, .. } - | Self::PersonTrack { id, .. } - | Self::ObjectAnchor { id, .. } - | Self::Event { id, .. } - | Self::SemanticState { id, .. } => *id = new, - } - } - - /// Static kind tag for diagnostics/queries. - #[must_use] - pub fn kind(&self) -> &'static str { - match self { - Self::Room { .. } => "room", - Self::Zone { .. } => "zone", - Self::Wall { .. } => "wall", - Self::Doorway { .. } => "doorway", - Self::Sensor { .. } => "sensor", - Self::RfLink { .. } => "rf_link", - Self::PersonTrack { .. } => "person_track", - Self::ObjectAnchor { .. } => "object_anchor", - Self::Event { .. } => "event", - Self::SemanticState { .. } => "semantic_state", - } - } -} - -/// A typed edge between two [`WorldNode`]s (ADR-139 §2.1). Stored as the -/// petgraph edge weight; metadata is structurally per-relation. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "rel", rename_all = "snake_case")] -pub enum WorldEdge { - /// sensor/rf_link → observable node. Weight is field-of-regard quality. - Observes { - /// Field-of-regard quality in [0, 1]. - quality: f32, - /// Last observation time (Unix ms). - last_seen_unix_ms: i64, - }, - /// person/object/event → room/zone containment. - LocatedIn { - /// Containment start (Unix ms). - since_unix_ms: i64, - }, - /// room ↔ room through a doorway (undirected pair stored as two edges). - AdjacentTo { - /// The connecting doorway node. - via_doorway: WorldId, - }, - /// sensor/rf_link → sensor/rf_link physical/clock support (ADR-138). - Supports { - /// Support strength in [0, 1]. - strength: f32, - }, - /// evidence/state → evidence/state: sources disagree (ADR-137). - Contradicts { - /// Disagreement magnitude. - magnitude: f32, - /// ADR-137 contradiction-flag content-address handle. - flag: String, - }, - /// semantic_state → prior state/evidence provenance chain (ADR-137). - DerivedFrom { - /// ADR-137 evidence content-address handle. - evidence: String, - }, - /// sensor → node: observation constrained by a privacy mode (ADR-141). - PrivacyLimitedBy { - /// Limiting privacy mode name. - mode: String, - /// Action evaluated. - action: String, - /// Whether observation is allowed under the current mode. - allowed: bool, - }, -} - -impl WorldEdge { - /// Static relation tag. - #[must_use] - pub fn rel(&self) -> &'static str { - match self { - Self::Observes { .. } => "observes", - Self::LocatedIn { .. } => "located_in", - Self::AdjacentTo { .. } => "adjacent_to", - Self::Supports { .. } => "supports", - Self::Contradicts { .. } => "contradicts", - Self::DerivedFrom { .. } => "derived_from", - Self::PrivacyLimitedBy { .. } => "privacy_limited_by", - } - } -} diff --git a/v2/crates/wifi-densepose-worldmodel/Cargo.toml b/v2/crates/wifi-densepose-worldmodel/Cargo.toml deleted file mode 100644 index 2d1a1b35..00000000 --- a/v2/crates/wifi-densepose-worldmodel/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "wifi-densepose-worldmodel" -description = "ADR-147 — OccWorld thin-client bridge: WorldGraph PersonTrack history → OccWorld Python subprocess → TrajectoryPrior" -readme = "README.md" -version = "0.3.1" -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -tokio = { version = "1", features = ["net", "io-util", "macros", "time"] } -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true -thiserror.workspace = true -wifi-densepose-worldgraph = { version = "0.3.1", path = "../wifi-densepose-worldgraph" } - -[lints.rust] -unsafe_code = "forbid" -missing_docs = "warn" diff --git a/v2/crates/wifi-densepose-worldmodel/README.md b/v2/crates/wifi-densepose-worldmodel/README.md deleted file mode 100644 index d2588a69..00000000 --- a/v2/crates/wifi-densepose-worldmodel/README.md +++ /dev/null @@ -1,127 +0,0 @@ -# wifi-densepose-worldmodel - -**Forward prediction for RF sensing — turn where people *were* into where they'll *be*, as occupancy + trajectory priors.** - -[![crates.io](https://img.shields.io/crates/v/wifi-densepose-worldmodel.svg)](https://crates.io/crates/wifi-densepose-worldmodel) -[![docs.rs](https://docs.rs/wifi-densepose-worldmodel/badge.svg)](https://docs.rs/wifi-densepose-worldmodel) - -Part of the [RuView / WiFi-DensePose](https://github.com/ruvnet/RuView) project. Implements **ADR-147**. - ---- - -## What it is (plain language) - -[`wifi-densepose-worldgraph`](https://crates.io/crates/wifi-densepose-worldgraph) tells you **what the room is -*now*** (who's where, the walls, the doorways). This crate answers the next question: **what happens *next*?** - -It's a **thin, async client** to an *occupancy world model* (OccWorld). You give it a short history of where -people have been (their `PersonTrack` positions); it rasterizes that into 3-D occupancy grids, ships them to -an OccWorld inference process, and gets back: - -- **predicted future occupancy** (the model rolls the scene forward N steps), and -- **`TrajectoryPrior`s** — per-person predicted waypoints you can feed straight into a Kalman pose tracker to - stabilize and *anticipate* movement (e.g. someone heading for a doorway). - -It is **camera-free and privacy-first**: the world model reasons over **occupancy voxels**, not video — so it -predicts *where*, never *who-looks-like-what*. (This is the deliberate contrast with pixel-space robot world -models like ByteDance's IRASim: same "predict-the-future-conditioned-on-state" idea, kept in occupancy space -for privacy and edge deployment.) - -## Where it sits - -``` -RF frames → fusion → WorldGraph (what is) ──PersonTrack history──► wifi-densepose-worldmodel - ▲ │ - │ OccWorld inference (Python subprocess) - └────────── TrajectoryPriors (what's next) ◄──────┘ - (injected back into the Kalman tracker) -``` - -## Symbolic vs predictive — the two halves of the world model - -| | `wifi-densepose-worldgraph` | `wifi-densepose-worldmodel` (this crate) | -|---|---|---| -| **Question** | "What is the room *now*?" | "What happens *next*?" | -| **Representation** | typed symbolic graph (rooms, tracks, beliefs) | dense 3-D occupancy voxels + trajectory priors | -| **Nature** | interpretable, evidential, provenance-tracked | predictive, learned (OccWorld) | -| **Compute** | pure Rust, microseconds, edge | Rust client + GPU inference subprocess | -| **Output** | relations & beliefs | future occupancy + per-person waypoints | - -Use them together: the graph supplies tracks + privacy decisions; this crate predicts forward and feeds the -priors back. - -## Features - -- 🔌 **Thin async bridge** — `OccWorldBridge` talks to the OccWorld inference process over a Unix socket (newline-delimited JSON request/response). -- 🧊 **Occupancy rasterization** — `worldgraph_to_occupancy()` turns person positions + scene bounds into a 3-D voxel grid (`200 × 200 × 16` by default; `CLASS_PERSON` / `CLASS_FREE` semantics). -- 🧭 **ENU ↔ voxel mapping** — `SceneBounds::to_voxel_xy()` / `to_voxel_z()` with a configurable resolution (e.g. 0.1 m). -- 🛰️ **Trajectory priors** — predicted per-`track_id` waypoints, ready for Kalman injection. -- 🔁 **Backend-swappable** — the request/response contract (`OccupancyWorldModelRequest` → response with `confidence` + `trajectory_priors`) is model-agnostic (OccWorld today, RoboOccWorld / others later). -- 🔒 **Privacy-gated by design** — meant to be called only when the WorldGraph's privacy mode permits it (ADR-141); reasons over occupancy, never pixels. -- 🚫 **`#![forbid(unsafe_code)]`**, `missing_docs = warn`. - -## Install - -```toml -[dependencies] -wifi-densepose-worldmodel = "0.3" -``` - -> The bridge uses Unix domain sockets (`tokio`), so the client targets Unix-like hosts (Linux/macOS — e.g. a Raspberry Pi appliance). The data types (occupancy, bounds, priors) are platform-agnostic. - -## Usage - -```rust -use wifi_densepose_worldmodel::{ - OccWorldBridge, OccupancyWorldModelRequest, SceneBoundsJson, worldgraph_to_occupancy, -}; -use wifi_densepose_worldmodel::occupancy::{PersonPosition, SceneBounds}; - -# async fn example() -> Result<(), wifi_densepose_worldmodel::WorldModelError> { -let bridge = OccWorldBridge::new("/tmp/occworld.sock"); - -let bounds = SceneBounds { min_e: -10.0, min_n: -10.0, max_e: 10.0, max_n: 10.0 }; -let persons = vec![PersonPosition { track_id: 1, east_m: 2.0, north_m: 3.0, up_m: 1.0 }]; - -// Rasterize current positions → an occupancy frame (0.1 m voxels). -let frame = worldgraph_to_occupancy(&persons, &bounds, 0.1); - -// Ask OccWorld to roll the scene forward 15 steps. -let response = bridge.predict(OccupancyWorldModelRequest { - past_frames: vec![frame], - voxel_resolution_m: 0.1, - scene_bounds: SceneBoundsJson { min_e: bounds.min_e, min_n: bounds.min_n, - max_e: bounds.max_e, max_n: bounds.max_n }, - prediction_steps: 15, -}).await?; - -println!("confidence={:.2}", response.confidence); -for prior in &response.trajectory_priors { - println!("track {} → {} predicted waypoints", prior.track_id, prior.waypoints.len()); -} -# Ok(()) -# } -``` - -## Technical details - -- **Wire protocol:** newline-delimited JSON over a Unix socket; one request → one response. The Python side - (OccWorld) loads `PersonTrack` history as a `(B, F, H, W, D)` occupancy tensor and returns predicted voxels - decoded into `TrajectoryPrior`s. -- **Grid:** `GRID_WIDTH=200 × GRID_HEIGHT=200 × GRID_DEPTH=16` voxels by default; `CLASS_PERSON=10`, - `CLASS_FREE=17` (RuView indoor class remap from the nuScenes outdoor set). -- **Resolution:** configurable meters-per-voxel; `to_voxel_xy`/`to_voxel_z` handle ENU→index. -- **Backend:** OccWorld (1.65 GB VRAM, ~375 ms/inference on an RTX-class GPU; runs on the Pi+Hailo appliance - tier). Cosmos is the deferred heavier alternative (ADR-148). -- **Provenance:** predictions carry the originating `calibration_id` + privacy decision so downstream - consumers can gate on quality and consent (ADR-141). - -## Related crates - -| Crate | Role | -|---|---| -| [`wifi-densepose-worldgraph`](https://crates.io/crates/wifi-densepose-worldgraph) | The symbolic twin ("what is") that supplies the tracks this crate predicts from | - -## License - -Licensed as the parent project. See the [repository](https://github.com/ruvnet/RuView). diff --git a/v2/crates/wifi-densepose-worldmodel/src/bridge.rs b/v2/crates/wifi-densepose-worldmodel/src/bridge.rs deleted file mode 100644 index d9e84e40..00000000 --- a/v2/crates/wifi-densepose-worldmodel/src/bridge.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! Async Unix-socket client that sends an [`OccupancyWorldModelRequest`] to -//! the OccWorld Python inference server and receives an -//! [`OccupancyWorldModelResponse`] (ADR-147). -//! -//! ## Protocol -//! Communication uses newline-delimited JSON over a Unix-domain stream socket: -//! 1. Connect to the socket path. -//! 2. Write the JSON-serialised request followed by a single `\n` byte. -//! 3. Read bytes until the first `\n`; decode as JSON response. -//! -//! A hard 30-second wall-clock timeout wraps the entire operation. - -use std::path::PathBuf; -use std::time::Duration; - -#[cfg(unix)] -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -#[cfg(unix)] -use tokio::net::UnixStream; -use tokio::time::timeout; - -use crate::error::WorldModelError; -use crate::{OccupancyWorldModelRequest, OccupancyWorldModelResponse}; - -/// Hard deadline applied to each inference round-trip. -const TIMEOUT_S: u64 = 30; - -/// Maximum number of bytes accepted for a single response line. -/// -/// 200×200×16 future frames × 15 steps × ~1 byte/voxel = ~9.6 MB in the -/// worst case; set a generous 64 MB ceiling to stay safe without allocating -/// it up front. (Only used by the unix socket reader.) -#[cfg(unix)] -const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024; - -/// Thin async client for the OccWorld Unix-socket inference server. -/// -/// Instances are cheap to clone (they only hold a [`PathBuf`]) and are safe -/// to share across threads. A fresh TCP-free connection is established for -/// every [`OccWorldBridge::predict`] call so the server can restart between -/// requests without invalidating a long-lived connection handle. -#[derive(Debug, Clone)] -pub struct OccWorldBridge { - /// Path to the Unix-domain socket served by the OccWorld Python process. - pub socket_path: PathBuf, -} - -impl OccWorldBridge { - /// Creates a new bridge pointing at the given Unix-domain socket path. - pub fn new(socket_path: impl Into) -> Self { - Self { - socket_path: socket_path.into(), - } - } - - /// Sends `request` to the OccWorld server and returns the decoded - /// response, or an error if the connection fails, times out, or the - /// response is malformed. - pub async fn predict( - &self, - request: OccupancyWorldModelRequest, - ) -> Result { - timeout( - Duration::from_secs(TIMEOUT_S), - self.send_recv(request), - ) - .await - .map_err(|_| WorldModelError::Timeout { timeout_s: TIMEOUT_S })? - } - - /// Non-unix platforms have no Unix-domain sockets. The OccWorld bridge is a - /// Linux-appliance feature (the Python inference server runs on the GPU host), - /// so on Windows/other targets the crate still compiles but `predict` fails - /// fast with a clear error instead of silently degrading. - #[cfg(not(unix))] - async fn send_recv( - &self, - _request: OccupancyWorldModelRequest, - ) -> Result { - Err(WorldModelError::Protocol( - "OccWorld Unix-socket bridge is only supported on unix targets".into(), - )) - } - - /// Internal: connect, write request, read response — no timeout here; - /// the outer [`timeout`] in [`predict`] handles that. - #[cfg(unix)] - async fn send_recv( - &self, - request: OccupancyWorldModelRequest, - ) -> Result { - let stream = self.connect().await?; - - // Split into reader/writer halves so we can write and then read - // without fully consuming the stream. - let (reader_half, mut writer_half) = stream.into_split(); - - // Encode request as a single newline-terminated JSON line. - let mut payload = serde_json::to_vec(&request)?; - payload.push(b'\n'); - - writer_half - .write_all(&payload) - .await - .map_err(|e| WorldModelError::Protocol(format!("write error: {e}")))?; - - // Flush the write half so the server sees the complete line. - writer_half - .flush() - .await - .map_err(|e| WorldModelError::Protocol(format!("flush error: {e}")))?; - - // Read exactly one newline-delimited JSON line from the server. - let mut line = String::new(); - let mut buf_reader = BufReader::new(reader_half); - - buf_reader - .read_line(&mut line) - .await - .map_err(|e| WorldModelError::Protocol(format!("read error: {e}")))?; - - if line.is_empty() { - return Err(WorldModelError::Protocol( - "server closed connection before sending a response".into(), - )); - } - - if line.len() > MAX_RESPONSE_BYTES { - return Err(WorldModelError::Protocol(format!( - "response line too large ({} bytes > {} byte limit)", - line.len(), - MAX_RESPONSE_BYTES - ))); - } - - let response: OccupancyWorldModelResponse = serde_json::from_str(line.trim())?; - - // Propagate any VRAM error signalled by the server via a dedicated - // sentinel in the model_id field (convention agreed in ADR-147). - if response.model_id.starts_with("error:vram:") { - return Err(WorldModelError::VramUnavailable( - response.model_id["error:vram:".len()..].to_owned(), - )); - } - - Ok(response) - } - - /// Establishes a [`UnixStream`] connection to `self.socket_path`. - #[cfg(unix)] - async fn connect(&self) -> Result { - UnixStream::connect(&self.socket_path) - .await - .map_err(|e| WorldModelError::SocketConnect { - path: self.socket_path.display().to_string(), - source: e, - }) - } -} - -/// Returns the default Unix socket path used by the OccWorld Python server -/// as specified in ADR-147. -pub fn default_socket_path() -> PathBuf { - PathBuf::from("/tmp/occworld.sock") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bridge_new_stores_path() { - let b = OccWorldBridge::new("/tmp/test.sock"); - assert_eq!(b.socket_path, PathBuf::from("/tmp/test.sock")); - } - - #[test] - fn default_socket_path_is_deterministic() { - assert_eq!(default_socket_path(), PathBuf::from("/tmp/occworld.sock")); - } - - /// Verify that a missing socket returns `SocketConnect` and not a panic. - /// Unix-only: non-unix targets return a `Protocol` "unsupported" error instead. - #[cfg(unix)] - #[tokio::test] - async fn connect_to_missing_socket_returns_error() { - let bridge = OccWorldBridge::new("/tmp/__occworld_nonexistent_test__.sock"); - use crate::{OccupancyGrid3D, OccupancyWorldModelRequest, SceneBoundsJson}; - let req = OccupancyWorldModelRequest { - past_frames: vec![OccupancyGrid3D { - width: 200, - height: 200, - depth: 16, - voxels: vec![17u8; 200 * 200 * 16], - }], - voxel_resolution_m: 0.1, - scene_bounds: SceneBoundsJson { - min_e: -10.0, - min_n: -10.0, - max_e: 10.0, - max_n: 10.0, - }, - prediction_steps: 1, - }; - let err = bridge.predict(req).await.unwrap_err(); - assert!( - matches!(err, WorldModelError::SocketConnect { .. }), - "expected SocketConnect, got {err:?}" - ); - } -} diff --git a/v2/crates/wifi-densepose-worldmodel/src/error.rs b/v2/crates/wifi-densepose-worldmodel/src/error.rs deleted file mode 100644 index 9a5d6090..00000000 --- a/v2/crates/wifi-densepose-worldmodel/src/error.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Error types for the OccWorld world-model bridge (ADR-147). - -use thiserror::Error; - -/// All errors that can be returned by the OccWorld bridge. -#[derive(Debug, Error)] -pub enum WorldModelError { - /// Could not connect to the Unix-domain socket served by the Python - /// OccWorld inference process. - #[error("could not connect to OccWorld socket at `{path}`: {source}")] - SocketConnect { - /// The socket path that was attempted. - path: String, - /// The underlying I/O error. - source: std::io::Error, - }, - - /// A request or response exceeded the 30-second wall-clock deadline. - #[error("OccWorld inference timed out after {timeout_s}s")] - Timeout { - /// The configured timeout in seconds. - timeout_s: u64, - }, - - /// The JSON payload received from the server could not be decoded, or the - /// payload we tried to send could not be encoded. - #[error("JSON (de)serialisation error: {0}")] - SerdeJson(#[from] serde_json::Error), - - /// The server sent a response that violates the newline-delimited JSON - /// protocol (e.g. an unexpected EOF before the newline delimiter, or an - /// oversized frame that exceeded the read buffer limit). - #[error("protocol error: {0}")] - Protocol(String), - - /// The OccWorld inference server reported that GPU VRAM is unavailable - /// (out-of-memory condition on the device side). - #[error("OccWorld server reports VRAM unavailable: {0}")] - VramUnavailable(String), -} diff --git a/v2/crates/wifi-densepose-worldmodel/src/lib.rs b/v2/crates/wifi-densepose-worldmodel/src/lib.rs deleted file mode 100644 index b739404d..00000000 --- a/v2/crates/wifi-densepose-worldmodel/src/lib.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! `wifi-densepose-worldmodel` — OccWorld thin-client bridge (ADR-147). -//! -//! Bridges [`wifi_densepose_worldgraph`] `PersonTrack` history to the OccWorld -//! Python inference subprocess and returns [`TrajectoryPrior`]s that can be -//! injected into the Kalman pose tracker. -//! -//! ## Quick start -//! ```rust,no_run -//! use wifi_densepose_worldmodel::{ -//! OccWorldBridge, OccupancyWorldModelRequest, OccupancyGrid3D, -//! SceneBoundsJson, worldgraph_to_occupancy, -//! }; -//! use wifi_densepose_worldmodel::occupancy::{PersonPosition, SceneBounds}; -//! -//! # async fn example() -> Result<(), wifi_densepose_worldmodel::WorldModelError> { -//! let bridge = OccWorldBridge::new("/tmp/occworld.sock"); -//! -//! let bounds = SceneBounds { min_e: -10.0, min_n: -10.0, max_e: 10.0, max_n: 10.0 }; -//! let persons = vec![ -//! PersonPosition { track_id: 1, east_m: 2.0, north_m: 3.0, up_m: 1.0 }, -//! ]; -//! let frame = worldgraph_to_occupancy(&persons, &bounds, 0.1); -//! -//! let request = OccupancyWorldModelRequest { -//! past_frames: vec![frame], -//! voxel_resolution_m: 0.1, -//! scene_bounds: SceneBoundsJson { -//! min_e: bounds.min_e, min_n: bounds.min_n, -//! max_e: bounds.max_e, max_n: bounds.max_n, -//! }, -//! prediction_steps: 15, -//! }; -//! -//! let response = bridge.predict(request).await?; -//! println!("confidence={:.2}", response.confidence); -//! for prior in &response.trajectory_priors { -//! println!("track {} has {} waypoints", prior.track_id, prior.waypoints.len()); -//! } -//! # Ok(()) -//! # } -//! ``` - -pub mod bridge; -pub mod error; -pub mod occupancy; - -// Re-export the bridge type at the crate root for convenience. -pub use bridge::{default_socket_path, OccWorldBridge}; -pub use error::WorldModelError; -pub use occupancy::worldgraph_to_occupancy; - -use serde::{Deserialize, Serialize}; - -// --------------------------------------------------------------------------- -// Voxel grid -// --------------------------------------------------------------------------- - -/// A 3-D occupancy grid whose voxel values are class indices (u8). -/// -/// Layout: `voxels[z * height * width + y * width + x]` (row-major, depth last). -/// The grid is always `200 × 200 × 16` when produced by -/// [`worldgraph_to_occupancy`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OccupancyGrid3D { - /// Number of voxels along the east/x axis. - pub width: u32, - /// Number of voxels along the north/y axis. - pub height: u32, - /// Number of voxels along the up/z axis. - pub depth: u32, - /// Flat class-index array, length `width * height * depth`. - pub voxels: Vec, -} - -// --------------------------------------------------------------------------- -// Trajectory types -// --------------------------------------------------------------------------- - -/// A single point on a predicted trajectory, with a relative timestamp. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrajectoryWaypoint { - /// East offset from installation origin, in metres. - pub e: f64, - /// North offset from installation origin, in metres. - pub n: f64, - /// Up offset (height), in metres. - pub u: f64, - /// Time offset from "now", in seconds (positive = future). - pub t_s: f32, -} - -/// Predicted future trajectory for one tracked person. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrajectoryPrior { - /// Stable track identifier (mirrors `WorldNode::PersonTrack::track_id`). - pub track_id: u64, - /// Ordered sequence of predicted future waypoints. - pub waypoints: Vec, -} - -// --------------------------------------------------------------------------- -// Scene bounds (JSON wire shape) -// --------------------------------------------------------------------------- - -/// Axis-aligned scene footprint sent to the OccWorld server in the IPC -/// request. Mirrors [`occupancy::SceneBounds`] but derives `Serialize` / -/// `Deserialize` for direct inclusion in the JSON payload. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SceneBoundsJson { - /// Western (minimum east) edge of the scene, in metres. - pub min_e: f64, - /// Southern (minimum north) edge of the scene, in metres. - pub min_n: f64, - /// Eastern (maximum east) edge of the scene, in metres. - pub max_e: f64, - /// Northern (maximum north) edge of the scene, in metres. - pub max_n: f64, -} - -// --------------------------------------------------------------------------- -// IPC request / response -// --------------------------------------------------------------------------- - -/// JSON request sent from the Rust bridge to the OccWorld Python server. -/// -/// Serialised as a single newline-terminated JSON object over the Unix socket. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OccupancyWorldModelRequest { - /// History of occupancy grids (chronological, oldest first). - /// OccWorld expects at least one frame; the reference implementation uses - /// the most recent 4 frames for temporal context. - pub past_frames: Vec, - - /// Physical size of one voxel cell on the ground plane, in metres. - pub voxel_resolution_m: f32, - - /// Scene footprint used to build the occupancy grid. - pub scene_bounds: SceneBoundsJson, - - /// Number of future time steps to predict (reference: 15 × 0.1 s = 1.5 s). - pub prediction_steps: u32, -} - -/// JSON response returned by the OccWorld Python server. -/// -/// Decoded from a single newline-terminated JSON object on the Unix socket. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OccupancyWorldModelResponse { - /// Predicted future occupancy grids (chronological, `prediction_steps` - /// frames in total). - pub future_frames: Vec, - - /// Per-person predicted trajectories extracted from `future_frames`. - pub trajectory_priors: Vec, - - /// Aggregate confidence score in `[0, 1]` for the entire prediction. - pub confidence: f32, - - /// Identifier of the model that produced this response. - /// The sentinel prefix `"error:vram:"` signals a VRAM error (see ADR-147). - pub model_id: String, - - /// Wall-clock time the Python server spent on inference, in milliseconds. - pub inference_ms: u64, -} - -// --------------------------------------------------------------------------- -// WorldGraph helper — extract PersonPosition list from a WorldGraph snapshot -// --------------------------------------------------------------------------- - -use wifi_densepose_worldgraph::WorldGraph; - -use crate::occupancy::PersonPosition; - -/// Extracts all [`PersonPosition`]s from a [`WorldGraph`] by serialising the -/// graph to its canonical JSON form (via [`WorldGraph::to_json`]) and scanning -/// the `nodes` array for `PersonTrack` entries. -/// -/// This avoids coupling to the private fields of `WorldGraphSnapshot`. -/// The returned positions are unsorted; callers may sort by `track_id` if -/// deterministic ordering is required. -/// -/// # Panics -/// Does not panic — if serialisation fails the function returns an empty -/// `Vec` and logs a warning via `eprintln!`. In practice, serialisation of a -/// valid `WorldGraph` should never fail. -pub fn persons_from_worldgraph(graph: &WorldGraph) -> Vec { - let bytes = match graph.to_json() { - Ok(b) => b, - Err(e) => { - eprintln!("[worldmodel] WorldGraph::to_json failed: {e}"); - return Vec::new(); - } - }; - - // Parse as a raw JSON value to avoid depending on the exact shape of the - // private `WorldGraphSnapshot` struct fields. - let value: serde_json::Value = match serde_json::from_slice(&bytes) { - Ok(v) => v, - Err(e) => { - eprintln!("[worldmodel] failed to parse WorldGraph JSON: {e}"); - return Vec::new(); - } - }; - - let nodes = match value.get("nodes").and_then(|n| n.as_array()) { - Some(arr) => arr, - None => return Vec::new(), - }; - - nodes - .iter() - .filter_map(|node| { - // Nodes use a serde-tagged enum; the PersonTrack variant carries a - // `kind` discriminator equal to `"person_track"`. - if node.get("kind")?.as_str()? != "person_track" { - return None; - } - let track_id = node.get("track_id")?.as_u64()?; - let pos = node.get("last_position")?; - let east_m = pos.get("east_m")?.as_f64()?; - let north_m = pos.get("north_m")?.as_f64()?; - let up_m = pos.get("up_m")?.as_f64()?; - Some(PersonPosition { track_id, east_m, north_m, up_m }) - }) - .collect() -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn occupancy_grid_serde_roundtrip() { - let grid = OccupancyGrid3D { - width: 4, - height: 4, - depth: 2, - voxels: vec![17u8; 32], - }; - let json = serde_json::to_string(&grid).expect("serialize"); - let decoded: OccupancyGrid3D = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(decoded.width, grid.width); - assert_eq!(decoded.voxels.len(), grid.voxels.len()); - } - - #[test] - fn trajectory_prior_serde_roundtrip() { - let prior = TrajectoryPrior { - track_id: 42, - waypoints: vec![ - TrajectoryWaypoint { e: 1.0, n: 2.0, u: 0.0, t_s: 0.1 }, - TrajectoryWaypoint { e: 1.1, n: 2.1, u: 0.0, t_s: 0.2 }, - ], - }; - let json = serde_json::to_string(&prior).expect("serialize"); - let decoded: TrajectoryPrior = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(decoded.track_id, 42); - assert_eq!(decoded.waypoints.len(), 2); - } - - #[test] - fn request_serde_roundtrip() { - let req = OccupancyWorldModelRequest { - past_frames: vec![OccupancyGrid3D { - width: 200, - height: 200, - depth: 16, - voxels: vec![17u8; 200 * 200 * 16], - }], - voxel_resolution_m: 0.1, - scene_bounds: SceneBoundsJson { - min_e: -10.0, - min_n: -10.0, - max_e: 10.0, - max_n: 10.0, - }, - prediction_steps: 15, - }; - let json = serde_json::to_string(&req).expect("serialize"); - let decoded: OccupancyWorldModelRequest = - serde_json::from_str(&json).expect("deserialize"); - assert_eq!(decoded.prediction_steps, 15); - assert_eq!(decoded.past_frames.len(), 1); - } - - #[test] - fn response_serde_roundtrip() { - let resp = OccupancyWorldModelResponse { - future_frames: vec![], - trajectory_priors: vec![TrajectoryPrior { - track_id: 1, - waypoints: vec![TrajectoryWaypoint { e: 0.0, n: 0.0, u: 0.0, t_s: 0.0 }], - }], - confidence: 0.82, - model_id: "occworld-dummy-v0".into(), - inference_ms: 375, - }; - let json = serde_json::to_string(&resp).expect("serialize"); - let decoded: OccupancyWorldModelResponse = - serde_json::from_str(&json).expect("deserialize"); - assert_eq!(decoded.inference_ms, 375); - assert!((decoded.confidence - 0.82).abs() < 1e-5); - } - - #[test] - fn vram_error_sentinel_roundtrip() { - let resp = OccupancyWorldModelResponse { - future_frames: vec![], - trajectory_priors: vec![], - confidence: 0.0, - model_id: "error:vram:out of memory (CUDA)".into(), - inference_ms: 0, - }; - assert!(resp.model_id.starts_with("error:vram:")); - } -} diff --git a/v2/crates/wifi-densepose-worldmodel/src/occupancy.rs b/v2/crates/wifi-densepose-worldmodel/src/occupancy.rs deleted file mode 100644 index cad165d7..00000000 --- a/v2/crates/wifi-densepose-worldmodel/src/occupancy.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Converts WorldGraph PersonTrack ENU positions into an [`OccupancyGrid3D`] -//! tensor suitable for submission to the OccWorld inference server (ADR-147). -//! -//! ## Voxel encoding -//! | Class index | Meaning | -//! |-------------|---------| -//! | 17 | Free space (default) | -//! | 10 | Person occupancy | -//! -//! The grid footprint is defined by axis-aligned [`SceneBounds`] in the local -//! ENU coordinate frame. The *z* / *up* dimension is always 16 voxels; the -//! floor voxel column for a given person is derived from their `up_m` value -//! clamped to `[0, depth-1]`. - -use crate::OccupancyGrid3D; - -/// Class index written into voxels that contain a detected person. -pub const CLASS_PERSON: u8 = 10; -/// Class index written into voxels that are free (unoccupied). -pub const CLASS_FREE: u8 = 17; - -/// Number of voxels along the east/x axis (fixed at 200). -pub const GRID_WIDTH: usize = 200; -/// Number of voxels along the north/y axis (fixed at 200). -pub const GRID_HEIGHT: usize = 200; -/// Number of voxels along the up/z axis (fixed at 16). -pub const GRID_DEPTH: usize = 16; - -/// Maximum height (metres) mapped onto the depth axis. Points above this -/// value are clamped to the topmost voxel. -const MAX_HEIGHT_M: f32 = 3.2; // 3.2 m / 16 voxels = 0.2 m per z-voxel - -/// A single person position expressed in local ENU metres. -#[derive(Debug, Clone)] -pub struct PersonPosition { - /// Stable track identifier (mirrors `WorldNode::PersonTrack::track_id`). - pub track_id: u64, - /// East offset from installation origin, in metres. - pub east_m: f64, - /// North offset from installation origin, in metres. - pub north_m: f64, - /// Up offset (height above floor), in metres. - pub up_m: f64, -} - -/// Axis-aligned bounding box of the scene in the ENU plane. -/// -/// Maps the *east* axis to the voxel *x* dimension and the *north* axis to -/// the voxel *y* dimension. -#[derive(Debug, Clone)] -pub struct SceneBounds { - /// Western (minimum east) edge of the scene, in metres. - pub min_e: f64, - /// Southern (minimum north) edge of the scene, in metres. - pub min_n: f64, - /// Eastern (maximum east) edge of the scene, in metres. - pub max_e: f64, - /// Northern (maximum north) edge of the scene, in metres. - pub max_n: f64, -} - -impl SceneBounds { - /// Returns `(east_extent_m, north_extent_m)`. If either dimension - /// is zero or negative a default of `1.0` is used to avoid division by - /// zero. - fn extents(&self) -> (f64, f64) { - let e = (self.max_e - self.min_e).max(1.0); - let n = (self.max_n - self.min_n).max(1.0); - (e, n) - } - - /// Maps a continuous ENU coordinate to `(vx, vy)` grid indices. - /// Out-of-bounds positions are clamped to the grid extent. - pub fn to_voxel_xy(&self, east_m: f64, north_m: f64) -> (usize, usize) { - let (e_ext, n_ext) = self.extents(); - let fx = (east_m - self.min_e) / e_ext; // [0, 1] - let fy = (north_m - self.min_n) / n_ext; // [0, 1] - let vx = (fx * GRID_WIDTH as f64) - .floor() - .clamp(0.0, (GRID_WIDTH - 1) as f64) as usize; - let vy = (fy * GRID_HEIGHT as f64) - .floor() - .clamp(0.0, (GRID_HEIGHT - 1) as f64) as usize; - (vx, vy) - } - - /// Maps a height value (metres) to a voxel *z* index in `[0, depth-1]`. - pub fn to_voxel_z(up_m: f64) -> usize { - let fz = (up_m as f32).clamp(0.0, MAX_HEIGHT_M) / MAX_HEIGHT_M; - let vz = (fz * GRID_DEPTH as f32) - .floor() - .clamp(0.0, (GRID_DEPTH - 1) as f32) as usize; - vz - } -} - -/// Converts a list of person positions from the WorldGraph into a flat -/// [`OccupancyGrid3D`] tensor. -/// -/// The voxel buffer is laid out as `[x, y, z]` with stride order -/// `voxels[z * height * width + y * width + x]` (row-major, depth last). -/// -/// # Arguments -/// * `persons` – Slice of person ENU positions (may be empty). -/// * `bounds` – Axis-aligned scene footprint used to define the grid. -/// * `resolution_m` – Informational only; the grid is always 200×200×16 — -/// this value is echoed back in the IPC request for the Python server. -/// -/// # Returns -/// An [`OccupancyGrid3D`] with `width = 200`, `height = 200`, `depth = 16`. -pub fn worldgraph_to_occupancy( - persons: &[PersonPosition], - bounds: &SceneBounds, - _resolution_m: f32, -) -> OccupancyGrid3D { - let total = GRID_WIDTH * GRID_HEIGHT * GRID_DEPTH; - let mut voxels = vec![CLASS_FREE; total]; - - for p in persons { - let (vx, vy) = bounds.to_voxel_xy(p.east_m, p.north_m); - let vz = SceneBounds::to_voxel_z(p.up_m); - - let idx = vz * GRID_HEIGHT * GRID_WIDTH + vy * GRID_WIDTH + vx; - // `idx` is always in-bounds given the clamping above. - voxels[idx] = CLASS_PERSON; - } - - OccupancyGrid3D { - width: GRID_WIDTH as u32, - height: GRID_HEIGHT as u32, - depth: GRID_DEPTH as u32, - voxels, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn default_bounds() -> SceneBounds { - SceneBounds { - min_e: -10.0, - min_n: -10.0, - max_e: 10.0, - max_n: 10.0, - } - } - - #[test] - fn empty_persons_all_free() { - let g = worldgraph_to_occupancy(&[], &default_bounds(), 0.1); - assert!(g.voxels.iter().all(|&v| v == CLASS_FREE)); - assert_eq!(g.voxels.len(), GRID_WIDTH * GRID_HEIGHT * GRID_DEPTH); - } - - #[test] - fn person_at_origin_maps_to_centre_voxel() { - let bounds = default_bounds(); // ±10 m; centre = (100, 100) in 200×200 - let persons = vec![PersonPosition { - track_id: 1, - east_m: 0.0, - north_m: 0.0, - up_m: 0.0, - }]; - let g = worldgraph_to_occupancy(&persons, &bounds, 0.1); - - // At ENU (0,0,0): vx=100, vy=100, vz=0 - let expected_idx = 0 * GRID_HEIGHT * GRID_WIDTH + 100 * GRID_WIDTH + 100; - assert_eq!(g.voxels[expected_idx], CLASS_PERSON); - // All other voxels must still be free - let person_count = g.voxels.iter().filter(|&&v| v == CLASS_PERSON).count(); - assert_eq!(person_count, 1); - } - - #[test] - fn out_of_bounds_position_is_clamped() { - let bounds = default_bounds(); - let persons = vec![PersonPosition { - track_id: 2, - east_m: 99.0, // well outside max_e=10 - north_m: 99.0, - up_m: 100.0, - }]; - let g = worldgraph_to_occupancy(&persons, &bounds, 0.1); - // Should not panic; exactly one person voxel set - let person_count = g.voxels.iter().filter(|&&v| v == CLASS_PERSON).count(); - assert_eq!(person_count, 1); - } - - #[test] - fn multiple_persons_independent_voxels() { - let bounds = default_bounds(); - let persons = vec![ - PersonPosition { track_id: 1, east_m: -5.0, north_m: -5.0, up_m: 0.5 }, - PersonPosition { track_id: 2, east_m: 5.0, north_m: 5.0, up_m: 1.5 }, - ]; - let g = worldgraph_to_occupancy(&persons, &bounds, 0.1); - let person_count = g.voxels.iter().filter(|&&v| v == CLASS_PERSON).count(); - assert_eq!(person_count, 2); - } - - #[test] - fn grid_dimensions_correct() { - let g = worldgraph_to_occupancy(&[], &default_bounds(), 0.4); - assert_eq!(g.width, 200); - assert_eq!(g.height, 200); - assert_eq!(g.depth, 16); - assert_eq!(g.voxels.len(), 200 * 200 * 16); - } -} diff --git a/v2/crates/worldgraph b/v2/crates/worldgraph new file mode 160000 index 00000000..5bb16586 --- /dev/null +++ b/v2/crates/worldgraph @@ -0,0 +1 @@ +Subproject commit 5bb1658684881db9fbb223e99ef434a150330dec