makepad/apps/route/src/nav_data.rs
Admin c460905e2a maps + route: the self-baking Amsterdam test map, reversible mkmap weave, the rtsmap generator, road faces, guarded first-run
Squashed from work:
- rtsmap: one seeded generator for tiled strategy maps
- map: bake a runnable Amsterdam test map from inside the route app
- route: the first-run test map starts itself, and its buttons are guarded
- map: the test map bakes its road faces too, from the same shared pass
- map_tiles: mkmap-extract — the weave is reversible, world-cells reconstructible from world.mkmap
- route: the local dispatcher rides the hub — its copied engine is deleted, limits preserved (aicore P1)
- route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p
- ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
2026-09-01 16:46:33 +02:00

430 lines
17 KiB
Rust

//! Data plane: search indexes, route graphs, charger layer, rain radar.
//!
//! Heavy loads happen on background threads; results cross to the UI thread
//! via `ToUISender` (same pattern as examples/map). Once loaded, tool
//! executors query these synchronously — search and province-scale routing
//! are tens of milliseconds.
use makepad_geodata::knmi_hdf5::{self, KnmiFrame};
use makepad_geodata::query::LayerDb;
use makepad_geodata::radar::{RadarConfig, RadarSync};
use makepad_map_nav::geo::LonLat;
use makepad_map_nav::graph::{Route, RouteGraph, TravelMode};
use makepad_map_nav::search::{SearchIndex, SearchResult};
use makepad_map_nav::searchdb::SearchDb;
use makepad_widgets::*;
use std::path::Path;
/// The production nav pair (see `examples/map`). When it is absent the app
/// falls back to whatever test map was baked on this machine — same
/// formats, one city instead of a province.
const NAV_DATA_BASENAME: &str = "local/maps/noord-holland";
const EUROPE_PLACES_PATH: &str = "local/maps/europe-places.search";
const EUROPE_SEARCHDB_PATH: &str = "local/maps/europe.searchdb";
const EUROPE_MAJOR_GRAPH_PATH: &str = "local/maps/europe-major.graph";
const CHARGERS_MBTILES_PATH: &str = "local/overlays/nl-chargers.mbtiles";
const RADAR_CACHE_DIR: &str = "local/overlays/radar";
pub struct NavData {
pub nh_search: SearchIndex,
pub nh_graph: RouteGraph,
pub searchdb: Option<SearchDb>,
/// In-RAM Europe settlements, used only when the searchdb is absent.
pub places: Option<SearchIndex>,
/// Europe major-roads long-haul graph (971MB) — loaded on the first
/// route that leaves NH coverage, not at startup.
pub major_graph: Option<RouteGraph>,
major_graph_attempted: bool,
pub chargers: Option<LayerDb>,
}
pub enum NavLoad {
Ready { data: Box<NavData>, stats: String },
Failed { error: String },
}
/// The nav artifacts to load, production first, test map second. `None`
/// means this machine has neither and the caller should offer to bake one.
pub fn nav_basename() -> Option<String> {
for basename in [
NAV_DATA_BASENAME.to_string(),
makepad_map_build::testmap::TestMapPaths::amsterdam()
.nav_basename
.to_string_lossy()
.into_owned(),
] {
if Path::new(&format!("{basename}.search")).is_file()
&& Path::new(&format!("{basename}.graph")).is_file()
{
return Some(basename);
}
}
None
}
pub fn start_nav_load(sender: ToUISender<NavLoad>, basename: String) {
std::thread::spawn(move || {
let t0 = std::time::Instant::now();
let nh_search = match std::fs::read(format!("{basename}.search"))
.map_err(|e| e.to_string())
.and_then(|d| SearchIndex::deserialize(&d).map_err(|e| format!("{e:?}")))
{
Ok(s) => s,
Err(e) => {
let _ = sender.send(NavLoad::Failed {
error: format!("search index: {e} (run from repo root; see examples/map)"),
});
return;
}
};
let nh_graph = match std::fs::read(format!("{basename}.graph"))
.map_err(|e| e.to_string())
.and_then(|d| RouteGraph::deserialize(&d).map_err(|e| format!("{e:?}")))
{
Ok(g) => g,
Err(e) => {
let _ = sender.send(NavLoad::Failed {
error: format!("route graph: {e}"),
});
return;
}
};
let searchdb = SearchDb::open(Path::new(EUROPE_SEARCHDB_PATH)).ok();
let places = if searchdb.is_some() {
None
} else {
std::fs::read(EUROPE_PLACES_PATH)
.ok()
.and_then(|d| SearchIndex::deserialize(&d).ok())
};
let chargers = LayerDb::open(Path::new(CHARGERS_MBTILES_PATH)).ok();
let stats = format!(
"nav ready in {:.1}s: {} docs, {} edges{}{}{}",
t0.elapsed().as_secs_f64(),
nh_search.doc_count(),
nh_graph.edges.len(),
if searchdb.is_some() { ", europe searchdb" } else { "" },
", major graph on demand",
if chargers.is_some() { ", chargers" } else { "" },
);
let _ = sender.send(NavLoad::Ready {
data: Box::new(NavData {
nh_search,
nh_graph,
searchdb,
places,
major_graph: None,
major_graph_attempted: false,
chargers,
}),
stats,
});
});
}
impl NavData {
/// Merged search over the NH detail index + Europe-wide index, same
/// policy as examples/map: score-sorted, name+2km deduped, truncated.
pub fn search(&self, text: &str, near: Option<LonLat>, limit: usize) -> Vec<SearchResult> {
let mut results = self.nh_search.query(text, near, limit);
if let Some(db) = &self.searchdb {
if let Ok(more) = db.query(text, near, limit) {
results.extend(more);
}
} else if let Some(places) = &self.places {
results.extend(places.query(text, near, limit));
}
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut kept: Vec<SearchResult> = Vec::new();
for r in results {
if kept.iter().any(|k| {
k.name.eq_ignore_ascii_case(&r.name)
&& crate::trip::haversine_m((k.pos.lon, k.pos.lat), (r.pos.lon, r.pos.lat))
< 2000.0
}) {
continue;
}
kept.push(r);
if kept.len() >= limit {
break;
}
}
kept
}
/// Detail graph first; whole-pair fallback to the Europe major-roads
/// graph when an endpoint is outside NH coverage (no cross-graph
/// stitching — same policy as examples/map).
pub fn route_pair(&mut self, from: LonLat, to: LonLat, mode: TravelMode) -> Option<Route> {
if let Some(route) = self.nh_graph.route(from, to, mode) {
return Some(route);
}
if self.major_graph.is_none() && !self.major_graph_attempted {
self.major_graph_attempted = true;
let t0 = std::time::Instant::now();
self.major_graph = std::fs::read(EUROPE_MAJOR_GRAPH_PATH)
.ok()
.and_then(|d| RouteGraph::deserialize(&d).ok());
if self.major_graph.is_some() {
makepad_widgets::log!(
"nav: europe-major graph loaded on demand in {:.1}s",
t0.elapsed().as_secs_f64()
);
}
}
self.major_graph.as_ref()?.route(from, to, mode)
}
}
// --- Rain radar -------------------------------------------------------------
pub struct RadarData {
/// Decoded forecast frames of the newest file, minutes_offset 0..=120.
pub frames: Vec<KnmiFrame>,
/// Product timestamp digits from the filename (YYYYMMDDHHMM, UTC).
pub stamp: String,
/// Mercator-reprojected BGRA animation frames for `set_rain_frames`.
pub display_frames: Vec<Vec<u32>>,
pub display_width: usize,
pub display_height: usize,
/// Hi-res dual-radar composite for the "now" frame (BGRA, width, height),
/// built from the raw Herwijnen + Den Helder volumes at 250 m.
pub now_hires: Option<(Vec<u32>, usize, usize)>,
}
/// Geographic bbox of the reprojected radar display frames.
pub fn radar_display_bbox() -> (f64, f64, f64, f64) {
use makepad_geodata::radar_raster::{RASTER_EAST, RASTER_NORTH, RASTER_SOUTH, RASTER_WEST};
(RASTER_WEST, RASTER_SOUTH, RASTER_EAST, RASTER_NORTH)
}
/// Timestamp digits from a KNMI radar filename ("..._YYYYMMDDHHMM.h5").
fn filename_stamp(filename: &str) -> String {
filename
.rsplit('_')
.next()
.unwrap_or("")
.trim_end_matches(".h5")
.to_string()
}
/// Newest timestamp for which BOTH radar volumes are on disk, with paths.
fn newest_volume_pair(
herwijnen: &RadarSync,
den_helder: &RadarSync,
) -> Option<(String, std::path::PathBuf, std::path::PathBuf)> {
let h_state = herwijnen.state();
let d_state = den_helder.state();
for h_frame in h_state.frames.iter().rev() {
let stamp = filename_stamp(&h_frame.filename);
if let Some(d_frame) = d_state
.frames
.iter()
.rev()
.find(|d| filename_stamp(&d.filename) == stamp)
{
return Some((stamp, h_frame.path.clone(), d_frame.path.clone()));
}
}
None
}
/// Decode both volume files and composite them at 250 m, reprojected to a
/// mercator BGRA image for `set_rain_now_hires`.
fn build_hires_now(
projection: &makepad_geodata::radar_raster::RadarProjection,
herwijnen_path: &std::path::Path,
den_helder_path: &std::path::Path,
) -> Option<(Vec<u32>, usize, usize)> {
use makepad_geodata::radar_volume::{composite_volumes, RadarVolume};
let mut volumes = Vec::new();
for path in [herwijnen_path, den_helder_path] {
let data = std::fs::read(path).ok()?;
match RadarVolume::decode(&data) {
Ok(volume) => volumes.push(volume),
Err(error) => {
log!("radar volume decode {}: {error}", path.display());
return None;
}
}
}
let frame = composite_volumes(&volumes, 4);
let rgba = projection.composite_to_rgba(&frame);
Some((
makepad_geodata::radar_raster::rgba_to_bgra_texels(&rgba),
projection.width,
projection.height,
))
}
/// Poll KNMI (blocking curl inside RadarSync — never on the UI thread),
/// decode the newest forecast file when it changes, ship both the raw grid
/// (numeric weather_now sampling) and reprojected display frames to the UI.
/// Also syncs the raw volumes of both radars and composites them into the
/// hi-res "now" image.
pub fn start_radar_worker(sender: ToUISender<RadarData>) {
std::thread::spawn(move || {
let sync = RadarSync::new(RadarConfig::new(RADAR_CACHE_DIR));
let (herwijnen_config, den_helder_config) = RadarConfig::volume_pair(RADAR_CACHE_DIR);
let volume_syncs = (
RadarSync::new(herwijnen_config),
RadarSync::new(den_helder_config),
);
let projection = makepad_geodata::radar_raster::RadarProjection::new(1024, 1280);
// Hi-res projection built lazily: the LUT is big and only needed
// once volume data actually exists.
let mut hires_projection: Option<makepad_geodata::radar_raster::RadarProjection> = None;
let mut last_decoded = String::new();
let mut last_volume_stamp = String::new();
// Latest decoded forecast package, re-sent when the hi-res image
// refreshes on its own 5-min cadence.
let mut current: Option<RadarData> = None;
loop {
let state = match sync.sync() {
Ok(state) => state,
Err(_) => sync.state(),
};
let mut changed = false;
if let Some(frame) = state.frames.last() {
if frame.filename != last_decoded {
if let Ok(data) = std::fs::read(&frame.path) {
if let Ok(frames) = knmi_hdf5::decode_frames(&data) {
last_decoded = frame.filename.clone();
let stamp = filename_stamp(&frame.filename);
// Reproject all frames in parallel (same as
// examples/map — serial is a multi-second stall).
let display_frames: Vec<Vec<u32>> = std::thread::scope(|scope| {
let handles: Vec<_> = frames
.iter()
.map(|frame| {
let projection = &projection;
scope.spawn(move || {
makepad_geodata::radar_raster::rgba_to_bgra_texels(
&projection.frame_to_rgba(frame),
)
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
let now_hires = current.as_ref().and_then(|c| c.now_hires.clone());
current = Some(RadarData {
frames,
stamp,
display_frames,
display_width: 1024,
display_height: 1280,
now_hires,
});
changed = true;
}
}
}
}
// Volumes: sync both radars, composite when a new common
// timestamp appears.
let _ = volume_syncs.0.sync();
let _ = volume_syncs.1.sync();
if let Some((stamp, herwijnen_path, den_helder_path)) =
newest_volume_pair(&volume_syncs.0, &volume_syncs.1)
{
if stamp != last_volume_stamp {
let projection = hires_projection.get_or_insert_with(|| {
makepad_geodata::radar_raster::RadarProjection::new(2048, 2560)
});
if let Some(hires) =
build_hires_now(projection, &herwijnen_path, &den_helder_path)
{
last_volume_stamp = stamp;
if let Some(current) = &mut current {
current.now_hires = Some(hires);
changed = true;
}
}
}
}
if changed {
if let Some(current) = &current {
let _ = sender.send(RadarData {
frames: current.frames.clone(),
stamp: current.stamp.clone(),
display_frames: current.display_frames.clone(),
display_width: current.display_width,
display_height: current.display_height,
now_hires: current.now_hires.clone(),
});
}
}
std::thread::sleep(std::time::Duration::from_secs(60));
}
});
}
// --- RAD_NL25 grid sampling -------------------------------------------------
//
// Polar-stereographic forward projection for the 700x765 1km radar grid.
// Constants match libs/geodata/src/radar_raster.rs (private there, verified
// against the file's geo_product_corners to ~20m).
const A_KM: f64 = 6378.14;
const B_KM: f64 = 6356.75;
const ROW_OFFSET: f64 = 3649.9795;
const GRID_COLS: usize = 700;
const GRID_ROWS: usize = 765;
pub struct RadarGrid {
e: f64,
k0m: f64,
}
impl RadarGrid {
pub fn new() -> Self {
let e2 = 1.0 - (B_KM * B_KM) / (A_KM * A_KM);
let e = e2.sqrt();
let lat_ts = 60.0_f64.to_radians();
let t_ts = (std::f64::consts::FRAC_PI_4 - lat_ts / 2.0).tan()
/ ((1.0 - e * lat_ts.sin()) / (1.0 + e * lat_ts.sin())).powf(e / 2.0);
let m_ts = lat_ts.cos() / (1.0 - e2 * lat_ts.sin() * lat_ts.sin()).sqrt();
RadarGrid { e, k0m: A_KM * m_ts / t_ts }
}
/// lon/lat (deg) → radar grid col/row (f64).
fn forward(&self, lon_deg: f64, lat_deg: f64) -> (f64, f64) {
let lam = lon_deg.to_radians();
let phi = lat_deg.to_radians();
let t = (std::f64::consts::FRAC_PI_4 - phi / 2.0).tan()
/ ((1.0 - self.e * phi.sin()) / (1.0 + self.e * phi.sin())).powf(self.e / 2.0);
let rho = self.k0m * t;
let x = rho * lam.sin();
let y = -rho * lam.cos();
(x, -y - ROW_OFFSET)
}
/// Rain rate in mm/h at lon/lat for one frame. `None` = outside the
/// radar image. Raw pixel is reflectivity: dBZ = 0.5*PV - 32; rate via
/// Marshall-Palmer Z = 200 R^1.6.
pub fn sample_mm_h(&self, frame: &KnmiFrame, lon: f64, lat: f64) -> Option<f64> {
let (col, row) = self.forward(lon, lat);
let (c, r) = (col.round() as i64, row.round() as i64);
if c < 0 || r < 0 || c >= GRID_COLS as i64 || r >= GRID_ROWS as i64 {
return None;
}
if frame.cols != GRID_COLS || frame.rows != GRID_ROWS {
return None;
}
let v = frame.values[r as usize * frame.cols + c as usize];
if v == 255 {
return None;
}
if v < 1 {
return Some(0.0);
}
let dbz = 0.5 * v as f64 - 32.0;
Some((10.0_f64.powf(dbz / 10.0) / 200.0).powf(1.0 / 1.6))
}
}