Fix OSM/SRTM queries, add change detection + night mode
- OSM: use inclusive building filter with relation query and 25s timeout - SRTM: switch to NASA public mirror with viewfinderpanoramas fallback - Add detect_tile_changes() for pixel-diff satellite change detection - Add is_night() solar-declination model for CSI-only night mode - 6 new unit tests (night mode + tile change detection) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
f49ecb163f
commit
e39a35edee
|
|
@ -6,11 +6,16 @@ use anyhow::Result;
|
||||||
const OVERPASS_URL: &str = "https://overpass-api.de/api/interpreter";
|
const OVERPASS_URL: &str = "https://overpass-api.de/api/interpreter";
|
||||||
|
|
||||||
/// Fetch buildings within radius of a point.
|
/// 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.
|
||||||
pub async fn fetch_buildings(center: &GeoPoint, radius_m: f64) -> Result<Vec<OsmFeature>> {
|
pub async fn fetch_buildings(center: &GeoPoint, radius_m: f64) -> Result<Vec<OsmFeature>> {
|
||||||
let bbox = GeoBBox::from_center(center, radius_m);
|
let bbox = GeoBBox::from_center(center, radius_m);
|
||||||
let query = format!(
|
let query = format!(
|
||||||
r#"[out:json][timeout:10];way["building"]({},{},{},{});out body;>;out skel qt;"#,
|
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,
|
||||||
|
bbox.south, bbox.west, bbox.north, bbox.east,
|
||||||
);
|
);
|
||||||
let resp = overpass_query(&query).await?;
|
let resp = overpass_query(&query).await?;
|
||||||
parse_buildings(&resp)
|
parse_buildings(&resp)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
//! Temporal change tracking — detect changes in satellite/OSM/weather over time.
|
//! Temporal change tracking — detect changes in satellite/OSM/weather over time.
|
||||||
|
|
||||||
use crate::cache::TileCache;
|
use crate::cache::TileCache;
|
||||||
use crate::types::{GeoPoint, GeoScene};
|
use crate::types::GeoPoint;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use crate::types::GeoScene;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
/// Fetch current weather (Open Meteo, free, no key).
|
/// Fetch current weather (Open Meteo, free, no key).
|
||||||
|
|
@ -79,3 +81,230 @@ pub struct WeatherData {
|
||||||
pub wind_speed_ms: f32,
|
pub wind_speed_ms: f32,
|
||||||
pub weather_code: u16,
|
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.
|
||||||
|
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("http://127.0.0.1:9876/memories")
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,33 +17,46 @@ pub async fn fetch_elevation(point: &GeoPoint, cache: &TileCache) -> Result<Elev
|
||||||
return parse_hgt(&data, lat_int as f64, lon_int as f64);
|
return parse_hgt(&data, lat_int as f64, lon_int as f64);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try OpenTopography SRTM (free, no auth)
|
|
||||||
let url = format!(
|
|
||||||
"https://portal.opentopography.org/API/globaldem?demtype=SRTMGL1&south={}&north={}&west={}&east={}&outputFormat=HGT",
|
|
||||||
lat_int, lat_int + 1, lon_int, lon_int + 1
|
|
||||||
);
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
match client.get(&url).send().await {
|
// Primary: NASA SRTM public mirror (no auth required for .hgt)
|
||||||
Ok(resp) if resp.status().is_success() => {
|
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();
|
let data = resp.bytes().await?.to_vec();
|
||||||
cache.put(&cache_key, &data)?;
|
cache.put(&cache_key, &data)?;
|
||||||
parse_hgt(&data, lat_int as f64, lon_int as f64)
|
return parse_hgt(&data, lat_int as f64, lon_int as f64);
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Return flat terrain as fallback
|
|
||||||
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],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
/// Parse SRTM HGT binary (3601x3601 big-endian i16).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue