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 <ruv@ruv.net>
This commit is contained in:
parent
c257e67c3d
commit
aeac5f5543
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
@ -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::<usize>() 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(())
|
||||
}
|
||||
|
|
@ -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<String> = 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<u32> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<Vec<u8>> {
|
||||
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()
|
||||
}
|
||||
|
|
@ -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<TileCoord> {
|
||||
let tl = wgs84_to_tile(bbox.north, bbox.west, zoom);
|
||||
let br = wgs84_to_tile(bbox.south, bbox.east, zoom);
|
||||
let mut tiles = Vec::new();
|
||||
for y in tl.y..=br.y {
|
||||
for x in tl.x..=br.x {
|
||||
tiles.push(TileCoord { z: zoom, x, y });
|
||||
}
|
||||
}
|
||||
tiles
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── haversine asin-domain robustness ───────────────────────────────────
|
||||
//
|
||||
// For (near-)antipodal points, floating rounding can push the haversine
|
||||
// term `h` to 1.0 + ~4e-16, and `asin(sqrt(h)) = asin(>1)` is NaN. A NaN
|
||||
// distance silently breaks every downstream comparison (all `<`/`>` become
|
||||
// false), so the result must stay finite. This exact pair produced
|
||||
// h = 1.0000000000000004 pre-fix (verified empirically).
|
||||
|
||||
#[test]
|
||||
fn haversine_near_antipodal_is_finite_not_nan() {
|
||||
let a = GeoPoint {
|
||||
lat: -44.4994,
|
||||
lon: -178.957_22,
|
||||
alt: 0.0,
|
||||
};
|
||||
let b = GeoPoint {
|
||||
lat: 44.499_399_99,
|
||||
lon: 1.042_780_01,
|
||||
alt: 0.0,
|
||||
};
|
||||
let d = haversine(&a, &b);
|
||||
assert!(d.is_finite(), "near-antipodal haversine must be finite, got {d}");
|
||||
// Half-circumference is ~20_037 km; result must be close to that.
|
||||
assert!(
|
||||
(19_000_000.0..21_000_000.0).contains(&d),
|
||||
"antipodal distance should be ~half-circumference, got {d}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn haversine_identical_points_is_zero() {
|
||||
let p = GeoPoint {
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
alt: 0.0,
|
||||
};
|
||||
let d = haversine(&p, &p);
|
||||
assert!(d.is_finite() && d < 1e-6, "identical points → 0, got {d}");
|
||||
}
|
||||
|
||||
// ── pole-singularity robustness (degenerate geometry) ──────────────────
|
||||
//
|
||||
// The ENU transforms divide by cos(lat); at the poles cos(±90°) = 0, so
|
||||
// the longitude term is non-finite. We do not change the transform (that
|
||||
// would alter near-pole results), but we pin that the call does NOT panic.
|
||||
|
||||
#[test]
|
||||
fn wgs84_to_enu_at_pole_does_not_panic() {
|
||||
let origin = GeoPoint {
|
||||
lat: 90.0,
|
||||
lon: 0.0,
|
||||
alt: 0.0,
|
||||
};
|
||||
let point = GeoPoint {
|
||||
lat: 89.99,
|
||||
lon: 10.0,
|
||||
alt: 0.0,
|
||||
};
|
||||
// Must return without panicking. North/up stay finite; east may be
|
||||
// non-finite at the exact pole — assert the bounded components only.
|
||||
let enu = wgs84_to_enu(&point, &origin);
|
||||
assert!(enu[1].is_finite(), "north component must be finite");
|
||||
assert!(enu[2].is_finite(), "up component must be finite");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GeoScene> {
|
||||
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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
@ -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<GeoPoint> {
|
||||
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<GeoPoint> {
|
||||
// Check cache
|
||||
if let Ok(data) = std::fs::read_to_string(cache_path) {
|
||||
if let Ok(point) = serde_json::from_str::<GeoPoint>(&data) {
|
||||
return Ok(point);
|
||||
}
|
||||
}
|
||||
|
||||
let point = locate_by_ip().await?;
|
||||
let _ = std::fs::write(cache_path, serde_json::to_string(&point)?);
|
||||
Ok(point)
|
||||
}
|
||||
|
|
@ -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<Vec<OsmFeature>> {
|
||||
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<Vec<OsmFeature>> {
|
||||
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<serde_json::Value> {
|
||||
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<Vec<OsmFeature>> {
|
||||
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<Vec<OsmFeature>> {
|
||||
let mut buildings = Vec::new();
|
||||
let mut nodes: std::collections::HashMap<u64, [f64; 2]> = 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::<f32>().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<Vec<OsmFeature>> {
|
||||
let mut roads = Vec::new();
|
||||
let mut nodes: std::collections::HashMap<u64, [f64; 2]> = 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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<WeatherData> {
|
||||
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<Vec<String>> {
|
||||
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<TileChangeResult> {
|
||||
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<chrono::Utc>) -> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ElevationGrid> {
|
||||
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<ElevationGrid> {
|
||||
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<f32> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RasterTile> {
|
||||
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<Vec<RasterTile>> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<u8>,
|
||||
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<f32>,
|
||||
}
|
||||
|
||||
impl ElevationGrid {
|
||||
pub fn get(&self, lat: f64, lon: f64) -> Option<f32> {
|
||||
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<f32>,
|
||||
name: Option<String>,
|
||||
},
|
||||
Road {
|
||||
path: Vec<[f64; 2]>,
|
||||
road_type: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<OsmFeature>,
|
||||
pub roads: Vec<OsmFeature>,
|
||||
pub tile_count: usize,
|
||||
pub registration: GeoRegistration,
|
||||
pub last_updated: String,
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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.**
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-worldgraph)
|
||||
[](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).
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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<WorldNode, WorldEdge>,
|
||||
index: HashMap<WorldId, NodeIndex>,
|
||||
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<WorldNode>,
|
||||
/// 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<WorldId>,
|
||||
/// (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<WorldNode> {
|
||||
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<WorldId> {
|
||||
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<WorldId> {
|
||||
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<WorldId> {
|
||||
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<WorldId> {
|
||||
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<F>(&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<WorldNode> = 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<Vec<u8>, 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<Self, WorldGraphError> {
|
||||
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<WorldId> = (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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<WorldId>,
|
||||
},
|
||||
/// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.**
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-worldmodel)
|
||||
[](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).
|
||||
|
|
@ -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<PathBuf>) -> 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<OccupancyWorldModelResponse, WorldModelError> {
|
||||
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<OccupancyWorldModelResponse, WorldModelError> {
|
||||
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<OccupancyWorldModelResponse, WorldModelError> {
|
||||
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, WorldModelError> {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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<u8>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<TrajectoryWaypoint>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<OccupancyGrid3D>,
|
||||
|
||||
/// 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<OccupancyGrid3D>,
|
||||
|
||||
/// Per-person predicted trajectories extracted from `future_frames`.
|
||||
pub trajectory_priors: Vec<TrajectoryPrior>,
|
||||
|
||||
/// 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<PersonPosition> {
|
||||
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:"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 5bb1658684881db9fbb223e99ef434a150330dec
|
||||
Loading…
Reference in New Issue