1use crate::MapError;
15use crate::model::{CrsIdentity, NodataPolicy, QueryConvention};
16use crate::scipy_interp::{DelaunayParts, Located, ScipyDelaunay, WalkStats};
17use crate::terrain::{Hit, NativeTerrain};
18use rayon::prelude::*;
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21use std::collections::HashMap;
22use std::fs;
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
25use std::sync::{Arc, Mutex};
26use std::time::Instant;
27
28pub const REQUEST_SCHEMA: &str = "ras2fim-step05-request/1";
29pub const INPUTS_SCHEMA: &str = "ras2fim-step05-inputs/1";
30pub const METHOD: &str = "ras2fim-step05-stages/0.1.0 method-r2";
31const LOOKUP_PASS_BITS: u32 = 24;
33const NONE: u32 = u32::MAX;
34
35#[derive(Deserialize, Serialize, Debug, Clone)]
36#[serde(deny_unknown_fields)]
37pub struct Request {
38 pub schema: String,
39 pub terrain: TerrainSpec,
40 pub source_crs: SourceCrs,
41 pub budgets: Budgets,
42 pub interpolation: Interpolation,
43 pub nudge: Nudge,
44 #[serde(default)]
45 pub stages: Vec<String>,
46 pub profiles: Vec<Profile>,
47}
48
49#[derive(Deserialize, Serialize, Debug, Clone)]
50#[serde(deny_unknown_fields)]
51pub struct TerrainSpec {
52 pub path: String,
53 pub epsg: u32,
54 pub crs_identity: CrsIdentity,
55}
56
57#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
58#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
59pub enum SourceCrs {
60 Terrain,
61 Epsg { code: u32 },
62}
63
64#[derive(Deserialize, Serialize, Debug, Clone)]
65#[serde(deny_unknown_fields)]
66pub struct Budgets {
67 pub threads: usize,
68 pub max_cache_bytes: u64,
69 pub max_profile_bytes: u64,
70 pub max_inflight_profiles: usize,
71 #[serde(default = "default_lookup_cache")]
72 pub max_lookup_cache_bytes: u64,
73}
74
75fn default_lookup_cache() -> u64 {
77 0
78}
79
80#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
81#[serde(rename_all = "snake_case")]
82pub enum Selection {
83 Auto,
84 ScipyWalkSerial,
85 GuardedParallel,
86}
87
88#[derive(Deserialize, Serialize, Debug, Clone)]
89#[serde(deny_unknown_fields)]
90pub struct Interpolation {
91 pub selection: Selection,
92}
93
94#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
95#[serde(rename_all = "snake_case")]
96pub enum NudgeMethod {
97 Lookup,
98 FullWarp,
99}
100
101#[derive(Deserialize, Serialize, Debug, Clone)]
102#[serde(deny_unknown_fields)]
103pub struct Nudge {
104 pub dst_epsg: u32,
105 pub error_threshold: f64,
106 pub memory_limit_bytes: f64,
107 pub method: NudgeMethod,
108}
109
110#[derive(Deserialize, Serialize, Debug, Clone)]
111#[serde(deny_unknown_fields)]
112pub struct Profile {
113 pub id: String,
114 pub inputs_dir: String,
115 pub dest_grid: DestGrid,
116 pub aois: Vec<Aoi>,
117}
118
119#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
120#[serde(deny_unknown_fields)]
121pub struct DestGrid {
122 pub geotransform: [f64; 6],
123 pub width: usize,
124 pub height: usize,
125}
126
127#[derive(Deserialize, Serialize, Debug, Clone)]
128#[serde(deny_unknown_fields)]
129pub struct Aoi {
130 pub id: String,
131 #[serde(default)]
132 pub window: Option<[i64; 4]>,
133 #[serde(default)]
134 pub bounds: Option<[f64; 4]>,
135 #[serde(default)]
136 pub mask_u8: Option<String>,
137}
138
139#[derive(Deserialize, Debug, Clone)]
140pub struct InputsJson {
141 pub schema: String,
142 pub rows: usize,
143 pub cols: usize,
144 pub raster_affine: RasterAffine,
145 pub delaunay: DelaunayScalars,
146 pub files: HashMap<String, FileSpec>,
147}
148
149#[derive(Deserialize, Serialize, Debug, Clone, Copy)]
150pub struct RasterAffine {
151 pub x0: f64,
152 pub y0: f64,
153 pub res_x: f64,
154 pub res_y: f64,
155}
156
157#[derive(Deserialize, Debug, Clone)]
158pub struct DelaunayScalars {
159 pub paraboloid_scale: f64,
160 pub paraboloid_shift: f64,
161 pub min_bound: [f64; 2],
162 pub max_bound: [f64; 2],
163 pub nsimplex: usize,
164}
165
166#[derive(Deserialize, Debug, Clone)]
167pub struct FileSpec {
168 pub file: String,
169 pub dtype: String,
170 pub shape: Vec<usize>,
171}
172
173const STAGES: [&str; 5] = ["03", "05", "06", "07", "08"];
174
175fn is_safe_id(s: &str) -> bool {
176 let reserved = ["con", "prn", "aux", "nul", "com1", "lpt1"];
178 !s.is_empty()
179 && s.len() <= 128
180 && s.chars()
181 .all(|c| c.is_ascii_alphanumeric() || "_-.".contains(c))
182 && !s.starts_with('.')
183 && !reserved.contains(&s.to_ascii_lowercase().as_str())
184}
185
186impl Request {
187 pub fn load(path: &Path) -> Result<Self, MapError> {
188 let text = fs::read_to_string(path)
189 .map_err(|e| MapError::Io(format!("{}: {e}", path.display())))?;
190 let r: Request =
191 serde_json::from_str(&text).map_err(|e| MapError::Invalid(format!("request: {e}")))?;
192 r.validate()?;
193 Ok(r)
194 }
195
196 pub fn validate(&self) -> Result<(), MapError> {
197 let inv = |s: String| Err(MapError::Invalid(s));
198 if self.schema != REQUEST_SCHEMA {
199 return inv(format!(
200 "schema {:?}, expected {REQUEST_SCHEMA}",
201 self.schema
202 ));
203 }
204 let b = &self.budgets;
205 if b.threads == 0 || b.threads > 256 || b.max_inflight_profiles == 0 {
206 return inv("budgets: threads 1..=256 and max_inflight_profiles >= 1".into());
207 }
208 if b.max_cache_bytes == 0 || b.max_profile_bytes == 0 {
209 return inv("budgets: byte limits must be positive".into());
210 }
211 let n = &self.nudge;
212 if !(n.error_threshold.is_finite() && n.error_threshold >= 0.0) {
213 return inv("nudge.error_threshold must be finite and >= 0".into());
214 }
215 if !(n.memory_limit_bytes.is_finite() && n.memory_limit_bytes > 0.0) {
216 return inv("nudge.memory_limit_bytes must be positive".into());
217 }
218 for s in &self.stages {
219 if !STAGES.contains(&s.as_str()) {
220 return inv(format!("unknown stage {s:?}; allowed {STAGES:?}"));
221 }
222 }
223 if self.profiles.is_empty() {
224 return inv("no profiles".into());
225 }
226 let mut ids = std::collections::HashSet::new();
227 for p in &self.profiles {
228 if !is_safe_id(&p.id) || !ids.insert(p.id.to_ascii_lowercase()) {
229 return inv(format!(
230 "profile id {:?} unsafe or duplicate (case-insensitive)",
231 p.id
232 ));
233 }
234 let g = &p.dest_grid;
235 if !g.geotransform.iter().all(|v| v.is_finite())
236 || g.geotransform[2] != 0.0
237 || g.geotransform[4] != 0.0
238 || g.geotransform[1] <= 0.0
239 || g.geotransform[5] >= 0.0
240 {
241 return Err(MapError::Unsupported(format!(
242 "profile {}: dest_grid must be north-up axis-aligned",
243 p.id
244 )));
245 }
246 if g.width == 0
247 || g.height == 0
248 || g.width
249 .checked_mul(g.height)
250 .is_none_or(|v| v >= NONE as usize)
251 {
252 return inv(format!("profile {}: dest_grid size", p.id));
253 }
254 if p.aois.is_empty() {
255 return inv(format!("profile {}: no aois", p.id));
256 }
257 let mut aids = std::collections::HashSet::new();
258 for a in &p.aois {
259 if !is_safe_id(&a.id) || !aids.insert(a.id.to_ascii_lowercase()) {
260 return inv(format!("aoi id {:?} unsafe or duplicate", a.id));
261 }
262 if a.window.is_some() && a.bounds.is_some() {
263 return inv(format!("aoi {}: window and bounds are exclusive", a.id));
264 }
265 if let Some(w) = a.window
266 && (w[2] <= 0 || w[3] <= 0)
267 {
268 return inv(format!("aoi {}: window size must be positive", a.id));
269 }
270 if let Some(bd) = a.bounds
271 && !(bd.iter().all(|v| v.is_finite()) && bd[0] < bd[2] && bd[1] < bd[3])
272 {
273 return inv(format!(
274 "aoi {}: bounds must be finite with min < max",
275 a.id
276 ));
277 }
278 if a.mask_u8.is_some() && a.window.is_none() && a.bounds.is_none() {
279 return inv(format!("aoi {}: mask_u8 needs window or bounds", a.id));
280 }
281 }
282 }
283 Ok(())
284 }
285}
286
287pub fn aoi_window(g: &DestGrid, a: &Aoi) -> Option<[usize; 4]> {
289 let (c0, r0, c1, r1) = if let Some(w) = a.window {
290 (
291 w[0],
292 w[1],
293 w[0].saturating_add(w[2]),
294 w[1].saturating_add(w[3]),
295 )
296 } else if let Some(b) = a.bounds {
297 let gt = g.geotransform;
299 let cols: Vec<i64> = (0..g.width as i64)
300 .filter(|c| {
301 let x = gt[0] + (*c as f64 + 0.5) * gt[1];
302 b[0] <= x && x < b[2]
303 })
304 .collect();
305 let rows: Vec<i64> = (0..g.height as i64)
306 .filter(|r| {
307 let y = gt[3] + (*r as f64 + 0.5) * gt[5];
308 b[1] <= y && y < b[3]
309 })
310 .collect();
311 if cols.is_empty() || rows.is_empty() {
312 return None;
313 }
314 (
315 cols[0],
316 rows[0],
317 cols[cols.len() - 1] + 1,
318 rows[rows.len() - 1] + 1,
319 )
320 } else {
321 (0, 0, g.width as i64, g.height as i64)
322 };
323 let c0 = c0.max(0);
324 let r0 = r0.max(0);
325 let c1 = c1.min(g.width as i64);
326 let r1 = r1.min(g.height as i64);
327 (c1 > c0 && r1 > r0).then(|| {
328 [
329 c0 as usize,
330 r0 as usize,
331 (c1 - c0) as usize,
332 (r1 - r0) as usize,
333 ]
334 })
335}
336
337fn read_bin(
338 dir: &Path,
339 spec: &FileSpec,
340 dtype: &str,
341 elem: usize,
342 shape: &[usize],
343) -> Result<Vec<u8>, MapError> {
344 if spec.dtype.trim_start_matches('|') != dtype {
346 return Err(MapError::Invalid(format!(
347 "{}: dtype {} expected {dtype}",
348 spec.file, spec.dtype
349 )));
350 }
351 if spec.shape != shape {
352 return Err(MapError::Invalid(format!(
353 "{}: shape {:?} expected {shape:?}",
354 spec.file, spec.shape
355 )));
356 }
357 if spec.file.contains('/') || spec.file.contains('\\') || spec.file.contains("..") {
358 return Err(MapError::Invalid(format!(
359 "{}: file must be a plain name",
360 spec.file
361 )));
362 }
363 let n = shape
364 .iter()
365 .try_fold(elem, |a, b| a.checked_mul(*b))
366 .ok_or_else(|| MapError::Budget("shape overflow".into()))?;
367 let path = dir.join(&spec.file);
368 let len = fs::metadata(&path)
369 .map_err(|e| MapError::Io(format!("{}: {e}", path.display())))?
370 .len();
371 if len != n as u64 {
372 return Err(MapError::Invalid(format!(
373 "{}: {len} bytes, expected {n}",
374 path.display()
375 )));
376 }
377 fs::read(&path).map_err(|e| MapError::Io(format!("{}: {e}", path.display())))
378}
379
380fn f64s(b: &[u8]) -> Vec<f64> {
381 b.as_chunks::<8>()
382 .0
383 .iter()
384 .map(|c| f64::from_le_bytes(*c))
385 .collect()
386}
387
388pub struct Inputs {
390 pub rows: usize,
391 pub cols: usize,
392 pub affine: RasterAffine,
393 pub x_axis: Vec<f64>,
394 pub y_axis: Vec<f64>,
395 pub mask: Vec<u8>,
396 pub delaunay: ScipyDelaunay,
397}
398
399impl Inputs {
400 pub fn planned_bytes(meta: &InputsJson) -> u64 {
402 let get = |k: &str| {
403 meta.files
404 .get(k)
405 .map_or(0u64, |f| f.shape.iter().product::<usize>() as u64)
406 };
407 get("samples") * 8
408 + get("simplices") * 4
409 + get("transform") * 8
410 + get("neighbors") * 4
411 + get("equations") * 8
412 + get("x_axis") * 8
413 + get("y_axis") * 8
414 + get("wet_mask")
415 + meta.delaunay.nsimplex as u64 * 72
416 }
417
418 pub fn load_meta(dir: &Path) -> Result<InputsJson, MapError> {
419 let p = dir.join("inputs.json");
420 let text =
421 fs::read_to_string(&p).map_err(|e| MapError::Io(format!("{}: {e}", p.display())))?;
422 let m: InputsJson = serde_json::from_str(&text)
423 .map_err(|e| MapError::Invalid(format!("{}: {e}", p.display())))?;
424 if m.schema != INPUTS_SCHEMA {
425 return Err(MapError::Invalid(format!(
426 "{}: schema {:?}",
427 p.display(),
428 m.schema
429 )));
430 }
431 let a = m.raster_affine;
432 if !([a.x0, a.y0, a.res_x, a.res_y].iter().all(|v| v.is_finite())
433 && a.res_x > 0.0
434 && a.res_y > 0.0)
435 {
436 return Err(MapError::Unsupported(
437 "raster_affine must be finite with positive res (upstream positive-e)".into(),
438 ));
439 }
440 if m.rows == 0
441 || m.cols == 0
442 || m.rows
443 .checked_mul(m.cols)
444 .is_none_or(|v| v >= NONE as usize)
445 {
446 return Err(MapError::Invalid("rows/cols".into()));
447 }
448 Ok(m)
449 }
450
451 pub fn load(dir: &Path, m: &InputsJson) -> Result<Self, MapError> {
452 let f = |k: &str| {
453 m.files
454 .get(k)
455 .ok_or_else(|| MapError::Invalid(format!("inputs.json: missing file {k}")))
456 };
457 let samples_spec = f("samples")?;
458 let n = *samples_spec.shape.first().unwrap_or(&0);
459 let ns = m.delaunay.nsimplex;
460 let (rows, cols) = (m.rows, m.cols);
461 let samples = f64s(&read_bin(dir, samples_spec, "<f8", 8, &[n, 3])?);
462 let simplices: Vec<[u32; 3]> = read_bin(dir, f("simplices")?, "<u4", 4, &[ns, 3])?
463 .as_chunks::<12>()
464 .0
465 .iter()
466 .map(|c| [0, 1, 2].map(|k| u32::from_le_bytes(c[4 * k..4 * k + 4].try_into().unwrap())))
467 .collect();
468 let transform: Vec<[f64; 6]> =
469 f64s(&read_bin(dir, f("transform")?, "<f8", 8, &[ns, 3, 2])?)
470 .as_chunks::<6>()
471 .0
472 .to_vec();
473 let neighbors: Vec<[i32; 3]> = read_bin(dir, f("neighbors")?, "<i4", 4, &[ns, 3])?
474 .as_chunks::<12>()
475 .0
476 .iter()
477 .map(|c| [0, 1, 2].map(|k| i32::from_le_bytes(c[4 * k..4 * k + 4].try_into().unwrap())))
478 .collect();
479 let equations: Vec<[f64; 4]> = f64s(&read_bin(dir, f("equations")?, "<f8", 8, &[ns, 4])?)
480 .as_chunks::<4>()
481 .0
482 .to_vec();
483 let x_axis = f64s(&read_bin(dir, f("x_axis")?, "<f8", 8, &[cols])?);
484 let y_axis = f64s(&read_bin(dir, f("y_axis")?, "<f8", 8, &[rows])?);
485 let mask = read_bin(dir, f("wet_mask")?, "u1", 1, &[rows, cols])?;
486 if mask.iter().any(|v| *v > 1) {
487 return Err(MapError::Invalid("wet_mask values must be 0/1".into()));
488 }
489 for (name, a) in [("x_axis", &x_axis), ("y_axis", &y_axis)] {
490 if !a.iter().all(|v| v.is_finite()) || a.windows(2).any(|w| w[1] <= w[0]) {
491 return Err(MapError::Invalid(format!(
492 "{name} must be finite and strictly increasing"
493 )));
494 }
495 }
496 let samples3: Vec<[f64; 3]> = samples.as_chunks::<3>().0.to_vec();
497 let d = &m.delaunay;
498 let delaunay = ScipyDelaunay::new(DelaunayParts {
499 samples: samples3,
500 simplices,
501 transform,
502 neighbors,
503 equations,
504 paraboloid_scale: d.paraboloid_scale,
505 paraboloid_shift: d.paraboloid_shift,
506 min_bound: d.min_bound,
507 max_bound: d.max_bound,
508 })?;
509 Ok(Self {
510 rows,
511 cols,
512 affine: m.raster_affine,
513 x_axis,
514 y_axis,
515 mask,
516 delaunay,
517 })
518 }
519
520 pub fn gt(&self) -> [f64; 6] {
522 [
523 self.affine.x0,
524 self.affine.res_x,
525 0.0,
526 self.affine.y0,
527 0.0,
528 self.affine.res_y,
529 ]
530 }
531}
532
533#[derive(Clone, Copy, Debug)]
535pub struct PixelStages {
536 pub raw03: f64,
537 pub ground05: f32,
538 pub d06: f32,
539 pub d07: f32,
540 pub w08: f32,
541}
542
543#[inline]
545pub fn float_stages(raw03: f64, ground05: f32) -> Result<PixelStages, MapError> {
546 let wide = raw03 - ground05 as f64;
547 let d06 = wide as f32;
548 if wide.is_finite() && !d06.is_finite() {
549 return Err(MapError::Unsupported(format!(
550 "06 float32 cast overflow ({wide})"
551 )));
552 }
553 let d07 = if d06 < 0.0 { f32::NAN } else { d06 };
554 let w08 = if d07.is_nan() {
555 f32::NAN
556 } else {
557 let s = ground05 as f64 + d07 as f64;
558 let v = s as f32;
559 if s.is_finite() && !v.is_finite() {
560 return Err(MapError::Unsupported(format!(
561 "08 float32 cast overflow ({s})"
562 )));
563 }
564 v
565 };
566 Ok(PixelStages {
567 raw03,
568 ground05,
569 d06,
570 d07,
571 w08,
572 })
573}
574
575#[derive(Default, Debug, Clone, Serialize)]
576pub struct TerrainCounters {
577 pub sampled: u64,
578 pub outside: u64,
579 pub near_boundary: u64,
580 pub raw_sentinel_under_wet: u64,
581}
582
583fn ground_at(
585 t: &NativeTerrain,
586 inp: &Inputs,
587 row: usize,
588 col: usize,
589 tc: &mut TerrainCounters,
590) -> Result<f32, MapError> {
591 let a = inp.affine;
592 let x = a.x0 + (col as f64 + 0.5) * a.res_x;
593 let y = a.y0 + (row as f64 + 0.5) * a.res_y;
594 tc.sampled += 1;
595 match t.hit(x, y) {
596 Hit::Outside => {
597 tc.outside += 1;
598 Ok(t.nodata.map(|v| v as f32).unwrap_or(f32::NAN))
599 }
600 Hit::Pixel {
601 col: c,
602 row: r,
603 near_boundary,
604 } => {
605 tc.near_boundary += near_boundary as u64;
606 let (bw, bh) = t.block;
607 let block = t.get_block(c / bw, r / bh)?;
608 let w = t.block_window(c / bw, r / bh);
609 Ok(block[(r - w.y) * w.width + (c - w.x)])
610 }
611 }
612}
613
614pub struct Lookup {
616 pub key: String,
617 pub index: Vec<u32>,
618 pub passes: u32,
619 pub build_seconds: f64,
620}
621
622fn src_dataset(
623 values: Vec<f32>,
624 rows: usize,
625 cols: usize,
626 gt: [f64; 6],
627) -> Result<terrio::RasterDataset, MapError> {
628 let cfg = terrio::RasterConfig {
629 cols,
630 rows,
631 bands: 1,
632 nodata: f64::NAN,
633 data_type: terrio::DataType::F32,
634 ..Default::default()
635 };
636 let raster = terrio::Raster::from_data_native(cfg, terrio::raster::RasterData::F32(values))
637 .map_err(|e| MapError::Invalid(format!("source raster: {e}")))?;
638 let mut ds = terrio::RasterDataset::new(raster, true);
639 ds.set_geo_transform(gt);
640 Ok(ds)
641}
642
643pub struct WarpEnv {
644 pub src_srs: terrio::crs::SpatialRef,
645 pub dst_srs: terrio::crs::SpatialRef,
646 pub src_srs_wkt: String,
647 pub dst_srs_wkt: String,
648}
649
650fn warp_options(env: &WarpEnv, n: &Nudge, g: &DestGrid) -> terrio::ops::warp::WarpOptions {
651 terrio::ops::warp::WarpOptions {
652 src_srs: Some(env.src_srs.clone()),
653 dst_srs: Some(env.dst_srs.clone()),
654 dst_transform: Some(g.geotransform),
655 size: Some((g.width, g.height)),
656 resampling: terrio::ops::Resampling::Nearest,
657 error_threshold: n.error_threshold,
658 memory_limit: n.memory_limit_bytes,
659 ..Default::default()
660 }
661}
662
663pub fn warp_field(
665 values: Vec<f32>,
666 inp_rows: usize,
667 inp_cols: usize,
668 gt: [f64; 6],
669 env: &WarpEnv,
670 n: &Nudge,
671 g: &DestGrid,
672) -> Result<Vec<f32>, MapError> {
673 let ds = src_dataset(values, inp_rows, inp_cols, gt)?;
674 let out = terrio::ops::warp::warp(&[&ds], &warp_options(env, n, g))
675 .map_err(|e| MapError::Terrain(format!("warp: {e}")))?;
676 if out.geo_transform() != g.geotransform
677 || out.raster.cols != g.width
678 || out.raster.rows != g.height
679 {
680 return Err(MapError::Terrain(
681 "warp output grid differs from dest_grid".into(),
682 ));
683 }
684 match out.raster.data {
685 terrio::raster::RasterData::F32(v) => Ok(v),
686 _ => Err(MapError::Terrain("warp output not float32".into())),
687 }
688}
689
690pub fn lookup_key(
691 rows: usize,
692 cols: usize,
693 gt: [f64; 6],
694 env: &WarpEnv,
695 n: &Nudge,
696 g: &DestGrid,
697) -> String {
698 let bits = |a: &[f64]| {
699 a.iter()
700 .map(|v| format!("{:016x}", v.to_bits()))
701 .collect::<Vec<_>>()
702 .join(",")
703 };
704 let h = |s: &str| format!("{:016x}", crate::sink::fnv1a64(s.as_bytes()));
705 format!(
706 "terrio=0.4.2;nearest;f32;src_nodata=nan;dst_nodata=auto;src={rows}x{cols}@{};src_srs={};dst_srs={};dst={}x{}@{};et={:016x};wm={:016x}",
707 bits(>),
708 h(&env.src_srs_wkt),
709 h(&env.dst_srs_wkt),
710 g.width,
711 g.height,
712 bits(&g.geotransform),
713 n.error_threshold.to_bits(),
714 n.memory_limit_bytes.to_bits()
715 )
716}
717
718pub fn lookup_passes(total: usize, pass_bits: u32) -> Result<u32, MapError> {
722 if !(1..=LOOKUP_PASS_BITS).contains(&pass_bits) {
723 return Err(MapError::Invalid(format!(
724 "lookup pass bits {pass_bits} outside 1..=24"
725 )));
726 }
727 if total == 0 || total as u64 >= NONE as u64 {
728 return Err(MapError::Budget(format!(
729 "source grid of {total} pixels cannot be encoded below the u32 lookup sentinel"
730 )));
731 }
732 let bits = 64 - (total as u64 - 1).max(1).leading_zeros();
733 Ok(bits.div_ceil(pass_bits).max(1))
734}
735
736pub fn build_lookup_with(
741 rows: usize,
742 cols: usize,
743 gt: [f64; 6],
744 env: &WarpEnv,
745 n: &Nudge,
746 g: &DestGrid,
747 pass_bits: u32,
748) -> Result<Lookup, MapError> {
749 let t0 = Instant::now();
750 let total = rows * cols;
751 let passes = lookup_passes(total, pass_bits)?;
752 let digit = 1u64 << pass_bits;
753 let mut index = vec![0u32; g.width * g.height];
754 let mut covered: Option<Vec<bool>> = None;
755 for pass in 0..passes {
756 let shift = pass * pass_bits;
757 let field: Vec<f32> = (0..total as u64)
758 .map(|i| ((i >> shift) & (digit - 1)) as f32)
759 .collect();
760 let out = warp_field(field, rows, cols, gt, env, n, g)?;
761 let cov = covered.get_or_insert_with(|| out.iter().map(|v| !v.is_nan()).collect());
762 for ((slot, v), c) in index.iter_mut().zip(out).zip(cov.iter()) {
763 if v.is_nan() == *c {
764 return Err(MapError::Terrain(
765 "lookup passes disagree on coverage".into(),
766 ));
767 }
768 if v.is_nan() {
769 continue;
770 }
771 if !(v >= 0.0 && (v as f64) < digit as f64 && v.fract() == 0.0) {
772 return Err(MapError::Terrain(format!(
773 "lookup value {v} is not an exact pass digit"
774 )));
775 }
776 *slot |= (v as u32) << shift;
777 }
778 }
779 let cov = covered.expect("at least one pass");
780 for (slot, c) in index.iter_mut().zip(cov) {
781 if !c {
782 *slot = NONE;
783 } else if *slot as usize >= total {
784 return Err(MapError::Terrain("lookup index out of range".into()));
785 }
786 }
787 Ok(Lookup {
788 key: lookup_key(rows, cols, gt, env, n, g),
789 index,
790 passes,
791 build_seconds: t0.elapsed().as_secs_f64(),
792 })
793}
794
795pub fn build_lookup(
796 rows: usize,
797 cols: usize,
798 gt: [f64; 6],
799 env: &WarpEnv,
800 n: &Nudge,
801 g: &DestGrid,
802) -> Result<Lookup, MapError> {
803 build_lookup_with(rows, cols, gt, env, n, g, LOOKUP_PASS_BITS)
804}
805
806#[derive(Default)]
809pub struct LookupCache {
810 entries: Vec<Arc<Lookup>>,
811 pub budget: u64,
812 pub resident_bytes: u64,
813 pub peak_resident_bytes: u64,
814 pub hits: u64,
815 pub misses: u64,
816 pub evictions: u64,
817 pub not_retained: u64,
818}
819
820pub fn lookup_bytes(l: &Lookup) -> u64 {
821 l.index.len() as u64 * 4 + l.key.len() as u64
822}
823
824impl LookupCache {
825 pub fn with_budget(budget: u64) -> Self {
826 Self {
827 budget,
828 ..Default::default()
829 }
830 }
831 pub fn get(&mut self, key: &str) -> Option<Arc<Lookup>> {
832 let hit = self.entries.iter().find(|e| e.key == key).cloned();
833 if hit.is_some() {
834 self.hits += 1;
835 } else {
836 self.misses += 1;
837 }
838 hit
839 }
840 pub fn insert(&mut self, l: Arc<Lookup>) {
841 if self.entries.iter().any(|e| e.key == l.key) {
842 return;
843 }
844 let b = lookup_bytes(&l);
845 if b > self.budget {
846 self.not_retained += 1;
847 return;
848 }
849 while self.resident_bytes + b > self.budget && !self.entries.is_empty() {
850 let old = self.entries.remove(0);
851 self.resident_bytes -= lookup_bytes(&old);
852 self.evictions += 1;
853 }
854 self.resident_bytes += b;
855 self.peak_resident_bytes = self.peak_resident_bytes.max(self.resident_bytes);
856 self.entries.push(l);
857 }
858}
859
860#[derive(Debug, Clone, Serialize)]
865pub struct MemoryPlan {
866 pub inputs: u64,
867 pub guarded_index: u64,
868 pub lookup_index: u64,
869 pub lookup_transient: u64,
870 pub needed_set: u64,
871 pub per_pixel_stages: u64,
872 pub full_grids: u64,
873 pub full_warp_transient: u64,
874 pub aoi_outputs: u64,
875 pub warp_chunk_allowance: u64,
876 pub total: u64,
877}
878
879pub fn plan_memory(
880 meta: &InputsJson,
881 g: &DestGrid,
882 nudge: &Nudge,
883 full: bool,
884 max_aoi_px: u64,
885 aoi_px_sum: u64,
886) -> Result<MemoryPlan, MapError> {
887 let src = (meta.rows * meta.cols) as u64;
888 let dest = (g.width * g.height) as u64;
889 let lookup = nudge.method == NudgeMethod::Lookup;
890 if lookup {
891 lookup_passes(meta.rows * meta.cols, LOOKUP_PASS_BITS)?;
892 }
893 let evaluated = if full { src } else { src.min(aoi_px_sum) };
895 let mut p = MemoryPlan {
896 inputs: Inputs::planned_bytes(meta),
897 guarded_index: meta.delaunay.nsimplex as u64 * 72,
898 lookup_index: if lookup { dest * 4 } else { 0 },
899 lookup_transient: if lookup { src * 4 + dest * 4 + dest } else { 0 },
901 needed_set: if full {
902 src * 4 * 2
903 } else {
904 src.div_ceil(64) * 8 + evaluated * 4 * 2
905 },
906 per_pixel_stages: evaluated * (8 + 24 + 48),
908 full_grids: if full { src * (8 + 4 * 4) } else { 0 },
910 full_warp_transient: if nudge.method == NudgeMethod::FullWarp {
912 dest * 4 * 2 + src * 4
913 } else {
914 0
915 },
916 aoi_outputs: max_aoi_px * (4 * 2 + 4 * 2 + 1),
918 warp_chunk_allowance: nudge.memory_limit_bytes.ceil() as u64,
919 total: 0,
920 };
921 let terms = [
922 p.inputs,
923 p.guarded_index,
924 p.lookup_index,
925 p.lookup_transient,
926 p.needed_set,
927 p.per_pixel_stages,
928 p.full_grids,
929 p.full_warp_transient,
930 p.aoi_outputs,
931 p.warp_chunk_allowance,
932 ];
933 p.total = terms
934 .iter()
935 .try_fold(0u64, |a, b| a.checked_add(*b))
936 .ok_or_else(|| MapError::Budget("memory plan overflow".into()))?;
937 Ok(p)
938}
939
940pub struct Session<'a> {
941 pub req: &'a Request,
942 pub base: PathBuf,
943 pub terrain: NativeTerrain,
945 pub env: WarpEnv,
946 pub lookups: Mutex<LookupCache>,
948 pub inflight: InFlight,
949}
950
951#[derive(Default)]
953pub struct InFlight {
954 pub active: AtomicUsize,
955 pub peak_active: AtomicUsize,
956 pub planned: AtomicU64,
957 pub peak_planned: AtomicU64,
958}
959
960struct ActiveGuard<'a> {
962 f: &'a InFlight,
963 planned: u64,
964}
965
966impl<'a> ActiveGuard<'a> {
967 fn enter(f: &'a InFlight) -> Self {
968 let now = f.active.fetch_add(1, Ordering::SeqCst) + 1;
969 f.peak_active.fetch_max(now, Ordering::SeqCst);
970 Self { f, planned: 0 }
971 }
972 fn plan(&mut self, bytes: u64) {
973 self.planned = bytes;
974 let now = self.f.planned.fetch_add(bytes, Ordering::SeqCst) + bytes;
975 self.f.peak_planned.fetch_max(now, Ordering::SeqCst);
976 }
977}
978
979impl Drop for ActiveGuard<'_> {
980 fn drop(&mut self) {
981 self.f.planned.fetch_sub(self.planned, Ordering::SeqCst);
982 self.f.active.fetch_sub(1, Ordering::SeqCst);
983 }
984}
985
986fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
987 m.lock().unwrap_or_else(|e| e.into_inner())
988}
989
990fn resolve(base: &Path, p: &str) -> PathBuf {
991 let p = PathBuf::from(p);
992 if p.is_absolute() { p } else { base.join(p) }
993}
994
995impl<'a> Session<'a> {
996 pub fn open(req: &'a Request, base: &Path) -> Result<Self, MapError> {
997 let tpath = resolve(base, &req.terrain.path);
998 let tstr = tpath
999 .to_str()
1000 .ok_or_else(|| MapError::Invalid("terrain path not UTF-8".into()))?;
1001 let terrain = NativeTerrain::open(
1002 tstr,
1003 req.terrain.epsg,
1004 &req.terrain.crs_identity,
1005 QueryConvention::PixelCenter,
1006 NodataPolicy::RawValue,
1007 req.budgets.max_cache_bytes,
1008 )?;
1009 if (req.budgets.threads as u64) * terrain.max_block_bytes() > req.budgets.max_cache_bytes {
1010 return Err(MapError::Budget(
1011 "max_cache_bytes below threads x one terrain block".into(),
1012 ));
1013 }
1014 let src_srs = match &req.source_crs {
1015 SourceCrs::Terrain => terrio::LazyRaster::open_tiff(tstr)
1016 .map_err(|e| MapError::Terrain(e.to_string()))?
1017 .ok_or_else(|| MapError::Unsupported("terrain not a TIFF".into()))?
1018 .crs(),
1019 SourceCrs::Epsg { code } => terrio::crs::SpatialRef::parse(&format!("EPSG:{code}"))
1020 .map_err(|e| MapError::Invalid(format!("source_crs: {e}")))?,
1021 };
1022 let dst_srs = terrio::crs::SpatialRef::parse(&format!("EPSG:{}", req.nudge.dst_epsg))
1023 .map_err(|e| MapError::Invalid(format!("dst_epsg: {e}")))?;
1024 let env = WarpEnv {
1025 src_srs_wkt: src_srs.to_wkt().unwrap_or_default(),
1026 dst_srs_wkt: dst_srs.to_wkt().unwrap_or_default(),
1027 src_srs,
1028 dst_srs,
1029 };
1030 Ok(Self {
1031 req,
1032 base: base.to_path_buf(),
1033 terrain,
1034 env,
1035 lookups: Mutex::new(LookupCache::with_budget(req.budgets.max_lookup_cache_bytes)),
1036 inflight: InFlight::default(),
1037 })
1038 }
1039}
1040
1041struct Interpolated {
1043 raw: Vec<f64>,
1044 selection_used: &'static str,
1045 ambiguous: u64,
1046 walk: Option<WalkStats>,
1047}
1048
1049fn interpolate(
1050 inp: &Inputs,
1051 needed: &[u32],
1052 selection: Selection,
1053) -> Result<Interpolated, MapError> {
1054 let d = &inp.delaunay;
1055 let serial = |reason: &'static str, ambiguous: u64| {
1056 let mut raw = vec![f64::NAN; needed.len()];
1057 let mut k = 0usize;
1058 let cols = inp.cols;
1059 let st = d.evaluate_serial(&inp.x_axis, &inp.y_axis, |r, c, v| {
1060 let lin = (r * cols + c) as u32;
1061 if k < needed.len() && needed[k] == lin {
1062 raw[k] = v.map_or(f64::NAN, |(_, z)| z);
1063 k += 1;
1064 }
1065 });
1066 Interpolated {
1067 raw,
1068 selection_used: reason,
1069 ambiguous,
1070 walk: Some(st),
1071 }
1072 };
1073 let guarded_ok = d.guarded_allowed();
1074 match selection {
1075 Selection::ScipyWalkSerial => return Ok(serial("scipy_walk_serial", 0)),
1076 Selection::GuardedParallel if !guarded_ok => {
1077 return Err(MapError::Unsupported(
1078 "guarded_parallel: degenerate simplices present".into(),
1079 ));
1080 }
1081 Selection::Auto if !guarded_ok => {
1082 return Ok(serial("scipy_walk_serial (auto: degenerate simplices)", 0));
1083 }
1084 _ => {}
1085 }
1086 let located: Vec<Located> = needed
1087 .par_iter()
1088 .map(|lin| {
1089 let (r, c) = (*lin as usize / inp.cols, *lin as usize % inp.cols);
1090 d.locate_guarded([inp.x_axis[c], inp.y_axis[r]])
1091 })
1092 .collect();
1093 let ambiguous = located
1094 .iter()
1095 .filter(|l| matches!(l, Located::Ambiguous))
1096 .count() as u64;
1097 if ambiguous > 0 {
1098 if selection == Selection::GuardedParallel {
1099 return Err(MapError::Unsupported(format!(
1100 "guarded_parallel: {ambiguous} order-dependent pixels"
1101 )));
1102 }
1103 return Ok(serial(
1104 "scipy_walk_serial (auto: ambiguous pixels)",
1105 ambiguous,
1106 ));
1107 }
1108 let raw = located
1109 .into_iter()
1110 .map(|l| match l {
1111 Located::Value { value, .. } => value,
1112 _ => f64::NAN,
1113 })
1114 .collect();
1115 Ok(Interpolated {
1116 raw,
1117 selection_used: "guarded_parallel",
1118 ambiguous: 0,
1119 walk: None,
1120 })
1121}
1122
1123fn write_atomic_unit(dir: &Path, files: &[(&str, Vec<u8>)], unit: &Value) -> Result<(), MapError> {
1124 let tmp = dir.with_extension("partial");
1125 fs::create_dir_all(&tmp)?;
1126 for (name, bytes) in files {
1127 fs::write(tmp.join(name), bytes)?;
1128 }
1129 crate::sink::write_json(&tmp.join("unit.json"), unit)?;
1130 fs::rename(&tmp, dir)?;
1131 Ok(())
1132}
1133
1134fn f32_bytes(v: &[f32]) -> Vec<u8> {
1135 v.iter().flat_map(|x| x.to_le_bytes()).collect()
1136}
1137
1138pub fn run_profile(s: &Session, p: &Profile, out: &Path) -> Result<Value, MapError> {
1140 let mut slot = ActiveGuard::enter(&s.inflight);
1141 let t_all = Instant::now();
1142 let req = s.req;
1143 let dir = resolve(&s.base, &p.inputs_dir);
1144 let meta = Inputs::load_meta(&dir)?;
1145 let (rows, cols) = (meta.rows, meta.cols);
1146 let total = rows * cols;
1147 let g = &p.dest_grid;
1148 let full = req.nudge.method == NudgeMethod::FullWarp || !req.stages.is_empty();
1149 let windows: Vec<Option<[usize; 4]>> = p.aois.iter().map(|a| aoi_window(g, a)).collect();
1151 let aoi_px: u64 = windows
1152 .iter()
1153 .flatten()
1154 .map(|w| (w[2] * w[3]) as u64)
1155 .max()
1156 .unwrap_or(0);
1157 let aoi_sum: u64 = windows.iter().flatten().map(|w| (w[2] * w[3]) as u64).sum();
1158 let plan = plan_memory(&meta, g, &req.nudge, full, aoi_px, aoi_sum)?;
1159 let planned = plan.total;
1160 slot.plan(planned);
1161 if planned > req.budgets.max_profile_bytes {
1162 return Err(MapError::Budget(format!(
1163 "profile {}: planned {planned} bytes > max_profile_bytes {} (plan {:?})",
1164 p.id, req.budgets.max_profile_bytes, plan
1165 )));
1166 }
1167 let t0 = Instant::now();
1168 let mut inp = Inputs::load(&dir, &meta)?;
1169 let gt = inp.gt();
1171 let tg = s.terrain.gt;
1172 let (tx0, tx1) = (tg[0], tg[0] + tg[1] * s.terrain.width as f64);
1173 let (ty1, ty0) = (tg[3], tg[3] + tg[5] * s.terrain.height as f64);
1174 let (gx1, gy1) = (gt[0] + gt[1] * cols as f64, gt[3] + gt[5] * rows as f64);
1175 if gt[0] < tx0 || gx1 > tx1 || gt[3] < ty0 || gy1 > ty1 {
1176 return Err(MapError::Unsupported(format!(
1177 "profile {}: 01 grid extends beyond the terrain (upstream 03 crop would change it)",
1178 p.id
1179 )));
1180 }
1181 if inp.delaunay.guarded_allowed() && req.interpolation.selection != Selection::ScipyWalkSerial {
1182 inp.delaunay.build_index();
1183 }
1184 let load_s = t0.elapsed().as_secs_f64();
1185
1186 let t1 = Instant::now();
1188 let mut lookup_info = json!(null);
1189 let lookup: Option<Arc<Lookup>> = if req.nudge.method == NudgeMethod::Lookup {
1190 let key = lookup_key(rows, cols, gt, &s.env, &req.nudge, g);
1191 let found = lock(&s.lookups).get(&key);
1194 let (l, cached) = match found {
1195 Some(l) => (l, true),
1196 None => {
1197 let l = Arc::new(build_lookup(rows, cols, gt, &s.env, &req.nudge, g)?);
1198 lock(&s.lookups).insert(l.clone());
1199 (l, false)
1200 }
1201 };
1202 lookup_info = json!({"key_fnv1a64": format!("{:016x}", crate::sink::fnv1a64(l.key.as_bytes())), "passes": l.passes,
1204 "reused_from_cache": cached, "build_seconds_diagnostic": if cached { 0.0 } else { l.build_seconds },
1205 "full_grid_prep": {"source_pixels_encoded": total, "dest_pixels_warped_per_pass": g.width * g.height,
1206 "passes": l.passes, "lookup_bytes": lookup_bytes(&l),
1207 "note": "geometry-only native warp of the full source/destination grids; required even for a small AOI"},
1208 "covered_dest_pixels": l.index.iter().filter(|i| **i != NONE).count()});
1209 Some(l)
1210 } else {
1211 None
1212 };
1213 let lookup_s = t1.elapsed().as_secs_f64();
1214 let needed: Vec<u32> = if full {
1215 (0..total as u32).collect()
1216 } else {
1217 let l = lookup.as_ref().expect("lookup");
1218 let mut bits = vec![0u64; total.div_ceil(64)];
1219 for w in windows.iter().flatten() {
1220 for r in w[1]..w[1] + w[3] {
1221 for c in w[0]..w[0] + w[2] {
1222 let i = l.index[r * g.width + c];
1223 if i != NONE {
1224 bits[i as usize / 64] |= 1 << (i % 64);
1225 }
1226 }
1227 }
1228 }
1229 (0..total as u32)
1231 .filter(|i| bits[*i as usize / 64] >> (i % 64) & 1 == 1 && inp.mask[*i as usize] == 1)
1232 .collect()
1233 };
1234
1235 let t2 = Instant::now();
1237 let interp_set: Vec<u32> = if full {
1238 needed
1239 .iter()
1240 .copied()
1241 .filter(|i| inp.mask[*i as usize] == 1)
1242 .collect()
1243 } else {
1244 needed.clone()
1245 };
1246 let it = interpolate(&inp, &interp_set, req.interpolation.selection)?;
1247 let interp_s = t2.elapsed().as_secs_f64();
1248
1249 let t3 = Instant::now();
1251 let terrain = &s.terrain;
1252 let staged: Vec<Result<(PixelStages, TerrainCounters), MapError>> = interp_set
1253 .par_iter()
1254 .zip(it.raw.par_iter())
1255 .map(|(lin, raw)| {
1256 let mut tc = TerrainCounters::default();
1257 let (r, c) = (*lin as usize / cols, *lin as usize % cols);
1258 let raw03 = if raw.is_finite() { *raw } else { f64::NAN };
1259 if raw03.is_nan() {
1260 return Ok((
1261 PixelStages {
1262 raw03,
1263 ground05: f32::NAN,
1264 d06: f32::NAN,
1265 d07: f32::NAN,
1266 w08: f32::NAN,
1267 },
1268 tc,
1269 ));
1270 }
1271 let g05 = ground_at(terrain, &inp, r, c, &mut tc)?;
1272 if g05.is_nan() {
1273 return Err(MapError::Unsupported(format!(
1274 "terrain NaN under wet pixel (row {r}, col {c}); no oracle"
1275 )));
1276 }
1277 if terrain.is_sentinel(g05) {
1278 tc.raw_sentinel_under_wet += 1;
1279 }
1280 Ok((float_stages(raw03, g05)?, tc))
1281 })
1282 .collect();
1283 let mut tc = TerrainCounters::default();
1284 let mut px: Vec<PixelStages> = Vec::with_capacity(staged.len());
1285 for r in staged {
1286 let (p5, c) = r?;
1287 tc.sampled += c.sampled;
1288 tc.outside += c.outside;
1289 tc.near_boundary += c.near_boundary;
1290 tc.raw_sentinel_under_wet += c.raw_sentinel_under_wet;
1291 px.push(p5);
1292 }
1293 let stage_s = t3.elapsed().as_secs_f64();
1294
1295 let t4 = Instant::now();
1297 let mut d07_full: Option<Vec<f32>> = None;
1298 let mut w08_full: Option<Vec<f32>> = None;
1299 if full {
1300 let mut raw = vec![f64::NAN; total];
1301 let mut g05 = vec![f32::NAN; total];
1302 let (mut d6, mut d7, mut w8) = (
1303 vec![f32::NAN; total],
1304 vec![f32::NAN; total],
1305 vec![f32::NAN; total],
1306 );
1307 for (lin, v) in interp_set.iter().zip(&px) {
1308 let i = *lin as usize;
1309 raw[i] = v.raw03;
1310 g05[i] = v.ground05;
1311 d6[i] = v.d06;
1312 d7[i] = v.d07;
1313 w8[i] = v.w08;
1314 }
1315 if req.stages.iter().any(|x| x == "05") {
1316 let filled: Vec<Result<f32, MapError>> = (0..total)
1318 .into_par_iter()
1319 .map(|i| {
1320 if g05[i].is_nan() {
1321 ground_at(
1322 terrain,
1323 &inp,
1324 i / cols,
1325 i % cols,
1326 &mut TerrainCounters::default(),
1327 )
1328 } else {
1329 Ok(g05[i])
1330 }
1331 })
1332 .collect();
1333 for (i, v) in filled.into_iter().enumerate() {
1334 g05[i] = v?;
1335 }
1336 }
1338 if !req.stages.is_empty() {
1339 let sd = out.join("stages").join(&p.id);
1340 let tmp = sd.with_extension("partial");
1341 fs::create_dir_all(&tmp)?;
1342 for st in &req.stages {
1343 let bytes = match st.as_str() {
1344 "03" => raw.iter().flat_map(|x| x.to_le_bytes()).collect(),
1345 "05" => f32_bytes(&g05),
1346 "06" => f32_bytes(&d6),
1347 "07" => f32_bytes(&d7),
1348 _ => f32_bytes(&w8),
1349 };
1350 fs::write(tmp.join(format!("{st}.bin")), bytes)?;
1351 }
1352 crate::sink::write_json(
1353 &tmp.join("stages.json"),
1354 &json!({"schema": "ras2fim-step05-stages/1", "profile": p.id,
1355 "rows": rows, "cols": cols, "raster_geotransform": gt, "dtypes": {"03": "<f8", "05": "<f4", "06": "<f4", "07": "<f4", "08": "<f4"},
1356 "written": req.stages}),
1357 )?;
1358 if let Some(parent) = sd.parent() {
1359 fs::create_dir_all(parent)?;
1360 }
1361 fs::rename(&tmp, &sd)?;
1362 }
1363 d07_full = Some(d7);
1364 w08_full = Some(w8);
1365 }
1366 let stages_write_s = t4.elapsed().as_secs_f64();
1367
1368 let t5 = Instant::now();
1370 let (dest_depth, dest_wse): (Option<Vec<f32>>, Option<Vec<f32>>) =
1371 if req.nudge.method == NudgeMethod::FullWarp {
1372 let d7 = d07_full.take().expect("full");
1373 let w8 = w08_full.take().expect("full");
1374 (
1375 Some(warp_field(d7, rows, cols, gt, &s.env, &req.nudge, g)?),
1376 Some(warp_field(w8, rows, cols, gt, &s.env, &req.nudge, g)?),
1377 )
1378 } else {
1379 (None, None)
1380 };
1381 let warp_s = t5.elapsed().as_secs_f64();
1382
1383 let t6 = Instant::now();
1384 let mut units = Vec::new();
1385 let pos = |lin: u32| interp_set.binary_search(&lin).ok();
1386 for (a, win) in p.aois.iter().zip(&windows) {
1387 let udir = out.join("units").join(&p.id).join(&a.id);
1388 fs::create_dir_all(udir.parent().expect("parent"))?;
1389 let Some(w) = *win else {
1390 write_atomic_unit(
1391 &udir,
1392 &[],
1393 &json!({"schema": "ras2fim-step05-unit/1", "profile": p.id, "aoi": a.id, "status": "empty_window"}),
1394 )?;
1395 units.push(json!({"aoi": a.id, "status": "empty_window"}));
1396 continue;
1397 };
1398 let n = w[2] * w[3];
1399 let mask: Option<Vec<u8>> = match &a.mask_u8 {
1400 Some(m) => {
1401 let mp = resolve(&s.base, m);
1402 let b =
1403 fs::read(&mp).map_err(|e| MapError::Io(format!("{}: {e}", mp.display())))?;
1404 if b.len() != n || b.iter().any(|v| *v > 1) {
1405 return Err(MapError::Invalid(format!(
1406 "aoi {}: mask_u8 must be {}x{} bytes of 0/1",
1407 a.id, w[3], w[2]
1408 )));
1409 }
1410 Some(b)
1411 }
1412 None => None,
1413 };
1414 let mut depth = vec![f32::NAN; n];
1415 let mut wse = vec![f32::NAN; n];
1416 for rr in 0..w[3] {
1417 for cc in 0..w[2] {
1418 let k = rr * w[2] + cc;
1419 if mask.as_ref().is_some_and(|m| m[k] == 0) {
1420 continue;
1421 }
1422 let di = (w[1] + rr) * g.width + w[0] + cc;
1423 if let (Some(dd), Some(ww)) = (&dest_depth, &dest_wse) {
1424 depth[k] = dd[di];
1425 wse[k] = ww[di];
1426 } else if let Some(l) = &lookup {
1427 let src = l.index[di];
1428 if src != NONE
1429 && let Some(j) = pos(src)
1430 {
1431 depth[k] = px[j].d07;
1432 wse[k] = px[j].w08;
1433 }
1434 }
1435 }
1436 }
1437 let wgt = [
1438 g.geotransform[0] + w[0] as f64 * g.geotransform[1],
1439 g.geotransform[1],
1440 0.0,
1441 g.geotransform[3] + w[1] as f64 * g.geotransform[5],
1442 0.0,
1443 g.geotransform[5],
1444 ];
1445 let (db, wb) = (f32_bytes(&depth), f32_bytes(&wse));
1446 let unit = json!({"schema": "ras2fim-step05-unit/1", "profile": p.id, "aoi": a.id, "status": "complete",
1447 "window": w, "window_geotransform": wgt, "crs_epsg": req.nudge.dst_epsg, "dtype": "<f4", "nodata": "nan",
1448 "width": w[2], "height": w[3], "polygon_mask_applied": mask.is_some(),
1449 "finite_counts": {"depth": depth.iter().filter(|v| v.is_finite()).count(), "wse": wse.iter().filter(|v| v.is_finite()).count()},
1450 "fnv1a64": {"depth.f32": format!("{:016x}", crate::sink::fnv1a64(&db)), "wse.f32": format!("{:016x}", crate::sink::fnv1a64(&wb))}});
1451 write_atomic_unit(&udir, &[("depth.f32", db), ("wse.f32", wb)], &unit)?;
1452 units.push(json!({"aoi": a.id, "status": "complete", "window": w}));
1453 }
1454 let gather_s = t6.elapsed().as_secs_f64();
1455 Ok(json!({
1456 "id": p.id, "status": "complete", "rows": rows, "cols": cols, "dest_grid": g,
1457 "selection_requested": req.interpolation.selection, "selection_used": it.selection_used,
1458 "ambiguous_pixels": it.ambiguous, "degenerate_simplices": inp.delaunay.degenerate,
1459 "walk": it.walk, "source_pixels_needed": needed.len(), "source_pixels_interpolated": interp_set.len(),
1460 "source_pixels_total": total, "terrain": tc, "lookup": lookup_info, "memory_plan": plan,
1461 "units": units,
1462 "timings_diagnostic_seconds": {"load": load_s, "lookup": lookup_s, "interpolate": interp_s, "terrain_and_float32": stage_s,
1463 "stage_writes": stages_write_s, "warp": warp_s, "gather_and_write": gather_s, "total": t_all.elapsed().as_secs_f64()}
1464 }))
1465}
1466
1467pub fn method_record() -> Value {
1468 json!({
1469 "method_version": METHOD,
1470 "upstream": "andycarter-pe/ras2fim-2d @3146f647 generate_depth_wsel_rasters_mp_05.py",
1471 "axes": "exact caller-supplied np.arange query axes; never x0 + i*res; raster affine separate",
1472 "interpolation": "exact SciPy LinearNDInterpolator port over caller-supplied Delaunay (transform, neighbors, equations, paraboloid, bounds); serial walk or guarded order-independent evaluation with automatic serial fallback",
1473 "mask": "caller-supplied wet mask (rasterio.mask equivalent, Python adapter)",
1474 "terrain_05": "native terrio GeoTIFF; raster-01 pixel centres x0+(c+0.5)*res; floor((x-gt0)/gt1); outside = declared nodata; raw sentinel kept",
1475 "float32": "06=f32(f64(03)-f64(05)); 07=06<0->NaN after cast; 08=f32(f64(05)+f64(07)) where 07 finite",
1476 "nudge": "terrio 0.4.2 native warp, nearest, explicit dest grid, approximate transformer (error_threshold), GDAL-style chunks (memory_limit); lookup = same-config f32 index warp in 24-bit passes",
1477 "aoi": "selects destination pixels only; global inputs, grid and chunks unchanged; output = crop(full) by construction; optional caller polygon mask",
1478 "not_in_engine": ["sample preparation", "Qhull/SciPy Delaunay", "wet union rasterization", "dest grid derivation", "product 11"]
1479 })
1480}
1481
1482fn profile_result(r: Result<Value, MapError>, p: &Profile) -> Value {
1483 r.unwrap_or_else(
1484 |e| json!({"id": p.id, "status": "failed", "error_kind": e.kind(), "error": e.to_string()}),
1485 )
1486}
1487
1488pub fn run(req: &Request, base: &Path, out: &Path) -> Result<Value, MapError> {
1497 let pool = rayon::ThreadPoolBuilder::new()
1498 .num_threads(req.budgets.threads)
1499 .thread_name(|i| format!("ras2fim-step05-{i}"))
1500 .build()
1501 .map_err(|e| MapError::Invalid(e.to_string()))?;
1502 fs::create_dir(out).map_err(|e| MapError::Io(format!("{}: {e}", out.display())))?;
1503 let t0 = Instant::now();
1504 let k = req
1505 .budgets
1506 .max_inflight_profiles
1507 .min(req.profiles.len())
1508 .max(1);
1509 let mut plans: Vec<u64> = req
1511 .profiles
1512 .iter()
1513 .map(|p| {
1514 let dir = resolve(base, &p.inputs_dir);
1515 Inputs::load_meta(&dir)
1516 .and_then(|m| {
1517 let ws: Vec<[usize; 4]> = p
1518 .aois
1519 .iter()
1520 .filter_map(|a| aoi_window(&p.dest_grid, a))
1521 .collect();
1522 let mx = ws.iter().map(|w| (w[2] * w[3]) as u64).max().unwrap_or(0);
1523 let sum = ws.iter().map(|w| (w[2] * w[3]) as u64).sum();
1524 let full = req.nudge.method == NudgeMethod::FullWarp || !req.stages.is_empty();
1525 plan_memory(&m, &p.dest_grid, &req.nudge, full, mx, sum)
1526 })
1527 .map(|pl| pl.total.min(req.budgets.max_profile_bytes))
1528 .unwrap_or(0)
1529 })
1530 .collect();
1531 plans.sort_unstable_by(|a, b| b.cmp(a));
1532 let top_k: u64 = plans.iter().take(k).sum();
1533 pool.install(|| {
1534 let s = Session::open(req, base)?;
1535 let profiles: Vec<Value> = if k == 1 {
1536 req.profiles.iter().map(|p| profile_result(run_profile(&s, p, out), p)).collect()
1537 } else {
1538 let next = AtomicUsize::new(0);
1539 let slots: Vec<Mutex<Option<Value>>> = req.profiles.iter().map(|_| Mutex::new(None)).collect();
1540 rayon::scope(|sc| {
1541 for _ in 0..k {
1542 sc.spawn(|_| {
1543 loop {
1544 let i = next.fetch_add(1, Ordering::SeqCst);
1545 let Some(p) = req.profiles.get(i) else { break };
1546 let r = profile_result(run_profile(&s, p, out), p);
1547 *lock(&slots[i]) = Some(r);
1548 }
1549 });
1550 }
1551 });
1552 slots
1553 .into_iter()
1554 .zip(&req.profiles)
1555 .map(|(m, p)| {
1556 m.into_inner()
1557 .unwrap_or_else(|e| e.into_inner())
1558 .unwrap_or_else(|| json!({"id": p.id, "status": "failed", "error_kind": "internal", "error": "profile not run"}))
1559 })
1560 .collect()
1561 };
1562 let failed = profiles.iter().filter(|v| v["status"] != "complete").count();
1563 let peak_active = s.inflight.peak_active.load(Ordering::SeqCst);
1564 let lc = lock(&s.lookups);
1565 Ok(json!({
1566 "schema": "ras2fim-step05-report/1",
1567 "status": if failed == 0 { "complete" } else { "failed" },
1568 "method": method_record(),
1569 "request": req,
1570 "concurrency": {
1571 "requested_max_inflight_profiles": req.budgets.max_inflight_profiles,
1572 "effective_max_inflight_profiles": k,
1573 "peak_active_profiles": peak_active,
1574 "active_bound_respected": peak_active <= k,
1575 "scheduler": if k == 1 { "sequential, request order" } else { "sliding window of k tasks in the single Rayon pool; profiles claimed in request order; report in request order" },
1576 "threads": req.budgets.threads,
1577 "shared": "one NativeTerrain decoded-block cache; one lookup cache (aggregate budget); one thread pool"
1578 },
1579 "memory_bounds": {
1580 "per_profile_limit_bytes": req.budgets.max_profile_bytes,
1581 "estimated_aggregate_planned_bytes_k_largest": top_k,
1582 "observed_peak_inflight_planned_bytes": s.inflight.peak_planned.load(Ordering::SeqCst),
1583 "terrain_cache_budget_bytes": req.budgets.max_cache_bytes,
1584 "lookup_cache_budget_bytes": req.budgets.max_lookup_cache_bytes,
1585 "estimated_aggregate_bound_bytes": top_k + req.budgets.max_cache_bytes + req.budgets.max_lookup_cache_bytes,
1586 "note": "planned estimates of this implementation's arrays plus configured cache budgets; not a measured or enforced RSS cap"
1587 },
1588 "terrain": {"path": s.terrain.path, "stats": s.terrain.stats(), "crs_identity": s.terrain.crs_identity},
1589 "warp_env": {"src_srs_wkt_fnv1a64": format!("{:016x}", crate::sink::fnv1a64(s.env.src_srs_wkt.as_bytes())),
1590 "dst_srs_wkt_fnv1a64": format!("{:016x}", crate::sink::fnv1a64(s.env.dst_srs_wkt.as_bytes()))},
1591 "lookup_cache": {"budget_bytes": lc.budget, "resident_bytes": lc.resident_bytes,
1592 "peak_resident_bytes": lc.peak_resident_bytes, "hits": lc.hits, "misses": lc.misses,
1593 "evictions": lc.evictions, "not_retained": lc.not_retained},
1594 "profiles": profiles,
1595 "total_wall_seconds_diagnostic": t0.elapsed().as_secs_f64(),
1596 }))
1597 })
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602 use super::*;
1603
1604 #[test]
1605 fn float_stage_policy() {
1606 let p = float_stages(10.0, 9.5).unwrap();
1607 assert_eq!(p.d06, 0.5);
1608 assert_eq!(p.w08, 10.0);
1609 let n = float_stages(9.0, 9.5).unwrap();
1610 assert!(n.d07.is_nan() && n.w08.is_nan() && n.d06 == -0.5);
1611 let z = float_stages(9.5, 9.5).unwrap();
1612 assert_eq!(z.d07.to_bits(), 0.0f32.to_bits());
1613 let t = float_stages(554.5000000000001, 554.5).unwrap();
1615 assert!(t.d07 > 0.0);
1616 let u = float_stages(554.4999999999999, 554.5).unwrap();
1618 assert!(u.d07.is_nan());
1619 let s = float_stages(500.0, -9999.0).unwrap();
1621 assert_eq!(s.d07, 10499.0);
1622 assert!(float_stages(1e300, 0.0).is_err());
1623 }
1624
1625 #[test]
1626 fn aoi_windows() {
1627 let g = DestGrid {
1628 geotransform: [0.0, 1.0, 0.0, 10.0, 0.0, -1.0],
1629 width: 10,
1630 height: 10,
1631 };
1632 let a = |w: Option<[i64; 4]>, b: Option<[f64; 4]>| Aoi {
1633 id: "a".into(),
1634 window: w,
1635 bounds: b,
1636 mask_u8: None,
1637 };
1638 assert_eq!(aoi_window(&g, &a(None, None)), Some([0, 0, 10, 10]));
1639 assert_eq!(
1640 aoi_window(&g, &a(Some([8, 8, 5, 5]), None)),
1641 Some([8, 8, 2, 2])
1642 );
1643 assert_eq!(aoi_window(&g, &a(Some([20, 0, 5, 5]), None)), None);
1644 assert_eq!(
1646 aoi_window(&g, &a(None, Some([2.0, 6.0, 5.0, 8.0]))),
1647 Some([2, 2, 3, 2])
1648 );
1649 }
1650
1651 #[test]
1652 fn lookup_pass_counts_and_limits() {
1653 assert_eq!(lookup_passes(1, 24).unwrap(), 1);
1654 assert_eq!(lookup_passes(1 << 24, 24).unwrap(), 1); assert_eq!(lookup_passes((1 << 24) + 1, 24).unwrap(), 2);
1656 assert_eq!(lookup_passes(17, 4).unwrap(), 2);
1657 assert_eq!(lookup_passes(16, 4).unwrap(), 1);
1658 assert!(lookup_passes(u32::MAX as usize, 24).is_err());
1659 assert!(lookup_passes(0, 24).is_err());
1660 assert!(lookup_passes(10, 25).is_err());
1661 let top = ((1u32 << 24) - 1) as f32;
1663 assert_eq!(top as u32, (1 << 24) - 1);
1664 }
1665
1666 fn test_env() -> WarpEnv {
1667 let src = terrio::crs::SpatialRef::parse("EPSG:2277").unwrap();
1668 let dst = terrio::crs::SpatialRef::parse("EPSG:3857").unwrap();
1669 WarpEnv {
1670 src_srs_wkt: src.to_wkt().unwrap_or_default(),
1671 dst_srs_wkt: dst.to_wkt().unwrap_or_default(),
1672 src_srs: src,
1673 dst_srs: dst,
1674 }
1675 }
1676
1677 #[test]
1680 fn multipass_lookup_equals_single_pass_and_direct_warp() {
1681 let (rows, cols) = (37usize, 53usize);
1682 let gt = [3215352.0, 8.48, 0.0, 10113648.0, 0.0, 8.48];
1683 let env = test_env();
1684 let n = Nudge {
1685 dst_epsg: 3857,
1686 error_threshold: 0.125,
1687 memory_limit_bytes: 64.0 * 1024.0 * 1024.0,
1688 method: NudgeMethod::Lookup,
1689 };
1690 let (sg, w, h) =
1692 terrio::ops::warp::suggested_grid(cols, rows, gt, &env.src_srs, &env.dst_srs).unwrap();
1693 let g = DestGrid {
1694 geotransform: [
1695 (sg[0] / 3.0).round() * 3.0,
1696 3.0,
1697 0.0,
1698 (sg[3] / 3.0).round() * 3.0,
1699 0.0,
1700 -3.0,
1701 ],
1702 width: w,
1703 height: h,
1704 };
1705 let one = build_lookup_with(rows, cols, gt, &env, &n, &g, 24).unwrap();
1706 let many = build_lookup_with(rows, cols, gt, &env, &n, &g, 4).unwrap();
1707 assert_eq!(one.passes, 1);
1708 assert_eq!(many.passes, 3);
1709 assert_eq!(one.index, many.index);
1710 assert!(one.index.iter().any(|i| *i != NONE));
1711 let values: Vec<f32> = (0..rows * cols)
1712 .map(|i| {
1713 if i % 7 == 0 {
1714 f32::NAN
1715 } else {
1716 i as f32 * 0.25 - 3.0
1717 }
1718 })
1719 .collect();
1720 let direct = warp_field(values.clone(), rows, cols, gt, &env, &n, &g).unwrap();
1721 for (d, i) in direct.iter().zip(&one.index) {
1722 let gathered = if *i == NONE {
1723 f32::NAN
1724 } else {
1725 values[*i as usize]
1726 };
1727 assert!(d.to_bits() == gathered.to_bits() || (d.is_nan() && gathered.is_nan()));
1728 }
1729 }
1730
1731 #[test]
1732 fn lookup_cache_budget_and_no_retention_default() {
1733 let l = |k: &str, n: usize| {
1734 Arc::new(Lookup {
1735 key: k.into(),
1736 index: vec![0; n],
1737 passes: 1,
1738 build_seconds: 0.0,
1739 })
1740 };
1741 let mut none = LookupCache::with_budget(default_lookup_cache());
1742 none.insert(l("a", 10));
1743 assert_eq!(none.resident_bytes, 0);
1744 assert_eq!(none.not_retained, 1);
1745 assert!(none.get("a").is_none());
1746 let mut c = LookupCache::with_budget(100);
1747 c.insert(l("a", 10));
1748 c.insert(l("b", 10));
1749 c.insert(l("c", 10)); assert_eq!(c.evictions, 1);
1751 assert!(c.resident_bytes <= 100 && c.peak_resident_bytes <= 100);
1752 assert!(c.get("a").is_none() && c.get("c").is_some());
1753 }
1754
1755 #[test]
1756 fn memory_plan_counts_lookup_and_full_transients() {
1757 let meta: InputsJson = serde_json::from_value(json!({"schema": INPUTS_SCHEMA, "rows": 1000, "cols": 2000,
1758 "raster_affine": {"x0": 0.0, "y0": 0.0, "res_x": 1.0, "res_y": 1.0},
1759 "delaunay": {"paraboloid_scale": 1.0, "paraboloid_shift": 0.0, "min_bound": [0.0, 0.0], "max_bound": [1.0, 1.0], "nsimplex": 10},
1760 "files": {}})).unwrap();
1761 let g = DestGrid {
1762 geotransform: [0.0, 3.0, 0.0, 0.0, 0.0, -3.0],
1763 width: 3000,
1764 height: 1000,
1765 };
1766 let mut n = Nudge {
1767 dst_epsg: 3857,
1768 error_threshold: 0.125,
1769 memory_limit_bytes: 1.0,
1770 method: NudgeMethod::Lookup,
1771 };
1772 let small = plan_memory(&meta, &g, &n, false, 100, 100).unwrap();
1773 assert_eq!(small.lookup_index, 3000 * 1000 * 4);
1774 assert_eq!(small.lookup_transient, 2_000_000 * 4 + 3_000_000 * 5);
1775 n.method = NudgeMethod::FullWarp;
1776 let full = plan_memory(&meta, &g, &n, true, 3_000_000, 3_000_000).unwrap();
1777 assert!(full.total > small.total && full.full_grids == 2_000_000 * 24);
1778 }
1779
1780 #[test]
1781 fn inflight_guard_counts_active_and_planned_and_releases_on_error_paths() {
1782 let f = InFlight::default();
1783 {
1784 let mut a = ActiveGuard::enter(&f);
1785 a.plan(100);
1786 {
1787 let mut b = ActiveGuard::enter(&f);
1788 b.plan(50);
1789 assert_eq!(f.active.load(Ordering::SeqCst), 2);
1790 } let _c = ActiveGuard::enter(&f); assert_eq!(f.planned.load(Ordering::SeqCst), 100);
1793 }
1794 assert_eq!(f.active.load(Ordering::SeqCst), 0);
1795 assert_eq!(f.planned.load(Ordering::SeqCst), 0);
1796 assert_eq!(f.peak_active.load(Ordering::SeqCst), 2);
1797 assert_eq!(f.peak_planned.load(Ordering::SeqCst), 150);
1798 let mut c = LookupCache::with_budget(1000);
1800 let l = Arc::new(Lookup {
1801 key: "k".into(),
1802 index: vec![0; 10],
1803 passes: 1,
1804 build_seconds: 0.0,
1805 });
1806 c.insert(l.clone());
1807 c.insert(l);
1808 assert_eq!(c.resident_bytes, 41);
1809 }
1810
1811 #[test]
1812 fn ids_and_schema_validation() {
1813 assert!(is_safe_id("wsel_max_10"));
1814 assert!(!is_safe_id("CON"));
1815 assert!(!is_safe_id("../x"));
1816 assert!(!is_safe_id(""));
1817 }
1818}