Skip to main content

ras_commander_fast_map/
step05.rs

1//! Upstream ras2fim-2D Step05 raster-compatibility stages over prepared global inputs.
2//!
3//! Interface: `implementation-r2/stages/README.md` (`ras2fim-step05-request/1`).
4//! Stage sequence (upstream `generate_depth_wsel_rasters_mp_05.py` @ 3146f647):
5//! - 01: SciPy `griddata(linear)` at the exact captured `np.arange` axes (`scipy_interp`).
6//! - 02/03: caller-supplied wet mask (rasterio.mask equivalent, Python adapter); f64.
7//! - 05: native terrain at the raster-01 affine pixel centres (no GDAL); outside = nodata.
8//! - 06 = f32(f64(03) - f64(05)); 07 = 06 with `< 0 -> NaN` after the cast (zero and -0 kept);
9//!   08 = f32(f64(05) + f64(07)) where 07 is finite. The raw terrain sentinel is subtracted as a
10//!   number, as upstream does.
11//! - 09/10: terrio 0.4.2 native approximate warp (nearest) onto the explicit destination grid,
12//!   either as full warps of 07/08 (`full_warp`) or through a native full-destination-grid
13//!   source-pixel lookup and an AOI gather (`lookup`).
14use 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";
31/// Largest index exactly representable in one f32 lookup pass.
32const 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
75/// Default 0: no lookup retention across profiles (bounded memory for long catalogs).
76fn 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    // Lowercase-insensitive uniqueness is checked by the caller; reject Windows-reserved names.
177    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
287/// Clipped `[col, row, w, h]` of an AOI on the destination grid; `None` = empty.
288pub 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        // Pixel centres in [min, max): col c selected iff xmin <= gt0 + (c+0.5)*gt1 < xmax.
298        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    // numpy spells byte-order-free dtypes with '|' (e.g. "|u1").
345    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
388/// Prepared per-profile inputs (validated).
389pub 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    /// Bytes the inputs will occupy, computed from inputs.json before reading any array.
401    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    /// Raster-01 affine in GDAL order (positive e).
521    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/// Values of the float32 stages at one 01-grid pixel.
534#[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/// The upstream float32 sequence for one pixel.
544#[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
583/// Terrain sample at raster-01 pixel centre: raw value, or declared nodata outside the extent.
584fn 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
614/// Native lookup: destination pixel -> source (01-grid) linear pixel index, or `NONE`.
615pub 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
663/// Native warp of one f32 01-grid field with NaN nodata onto the destination grid.
664pub 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(&gt),
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
718/// Number of f32 index passes needed for `total` source pixels at `pass_bits` bits per pass.
719/// Every warped value must be an f32 integer below `2**pass_bits <= 2**24` (exact in f32), and
720/// the combined index must stay below the `NONE` sentinel.
721pub 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
736/// Builds the lookup with the same dtype, nodata declaration, grids and options as the product
737/// warps, so chunking and approximation are identical. Indices are split in `pass_bits` passes
738/// (24 in production) so every warped value is an exactly representable f32 integer; every pass
739/// must agree on coverage and every value must be an exact in-range integer.
740pub 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/// Lookup retention across profiles, bounded by an explicit resident byte budget (oldest evicted
807/// first). Budget 0 (the default) retains nothing: each profile builds, uses and drops its lookup.
808#[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/// Planned peak bytes of one profile, computed from metadata before any array is read or
861/// allocated. Terms are explicit upper estimates of this implementation's own arrays. terrio's
862/// internal warp staging is approximated by one chunk memory limit (`warp_chunk_allowance`) plus
863/// its full output buffer (`lookup_transient` / `full_warp_transient`); it is not measured.
864#[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    // Pixels evaluated: the full grid, or at most one source pixel per AOI destination pixel.
894    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        // One pass at a time: source index field f32 + warp output f32 + coverage flags.
900        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        // raw f64 + PixelStages (24 B) + per-pixel result staging (<= 48 B).
907        per_pixel_stages: evaluated * (8 + 24 + 48),
908        // raw f64, ground/06/07/08 f32 on the full 01 grid.
909        full_grids: if full { src * (8 + 4 * 4) } else { 0 },
910        // Two destination f32 warp outputs held together plus one moved source field.
911        full_warp_transient: if nudge.method == NudgeMethod::FullWarp {
912            dest * 4 * 2 + src * 4
913        } else {
914            0
915        },
916        // depth/wse f32 + their byte copies + optional polygon mask, for the largest AOI.
917        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    /// One shared terrain reader and decoded-block cache for every in-flight profile.
944    pub terrain: NativeTerrain,
945    pub env: WarpEnv,
946    /// One lookup cache with one aggregate resident budget, shared by all in-flight profiles.
947    pub lookups: Mutex<LookupCache>,
948    pub inflight: InFlight,
949}
950
951/// Instrumentation of concurrent profiles: active count and planned bytes (not RSS).
952#[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
960/// Holds one profile's slot (and, once known, its planned bytes) while it runs.
961struct 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
1041/// Per-pixel interpolation result for a sorted set of 01-grid linear indices.
1042struct 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
1138/// Runs one profile. Writes its units and stages; returns the profile report.
1139pub 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    // Budget before any array allocation (r1 QA M2), including lookup and warp transients.
1150    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    // Upstream's 03 crop to terrain bounds must be a no-op.
1170    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    // Needed 01-grid pixels.
1187    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        // The lock is held only for lookup/insert; builds run unlocked (a concurrent duplicate
1192        // build of the same key is possible and harmless: `insert` keeps one copy).
1193        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        // Full-grid preparation cost: independent of the AOI size (recorded, not hidden).
1203        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        // Only wet pixels need interpolation: dry ones are NaN in 03 and every later stage.
1230        (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    // Interpolation (01) on needed wet pixels, then 03 mask.
1236    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    // 05 + 06/07/08 at the interpolated pixels (and 05 everywhere when the stage is requested).
1250    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    // Optional full-grid stage products.
1296    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            // 05 is the whole terrain-aligned field, wet or not.
1317            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            // Dry 06 = f32(NaN - g) = NaN, consistent with upstream.
1337        }
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    // 09/10 on the destination grid.
1369    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
1488/// Runs the profiles: one at a time by default, or up to `max_inflight_profiles` concurrently.
1489///
1490/// Concurrency is a sliding window of `k` tasks spawned in the session's single Rayon pool; each
1491/// claims the next profile index in request order. Every profile's inner parallel work runs in the
1492/// same pool (work-stealing), so concurrent profiles never add OS threads beyond
1493/// `budgets.threads`. All profiles share one `NativeTerrain` (one decoded-block cache under
1494/// `max_cache_bytes`) and one lookup cache under `max_lookup_cache_bytes`. Results are reported in
1495/// request order; a failing profile writes no units and does not stop the others.
1496pub 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    // Planned (not measured) per-profile peaks, for the aggregate estimate of k in flight.
1510    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        // Tiny positive f64 difference rounds to a positive f32 and stays wet.
1614        let t = float_stages(554.5000000000001, 554.5).unwrap();
1615        assert!(t.d07 > 0.0);
1616        // Negative tiny difference: cast first, then the sign test.
1617        let u = float_stages(554.4999999999999, 554.5).unwrap();
1618        assert!(u.d07.is_nan());
1619        // Raw sentinel subtracted as a number (upstream).
1620        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        // centres 2.5..4.5 in [2,5) -> cols 2..=4; y centres 9.5-r in [6,8) -> rows 2,3
1645        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); // max index 2**24 - 1
1655        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        // Every 24-bit digit is an exact f32 integer.
1662        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    /// Multi-pass encoding (forced with 4-bit digits) equals the single-pass lookup, and the lookup
1678    /// gather equals a direct native warp of a value field with NaN holes.
1679    #[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        // Destination grid: default suggestion snapped to 3 m (Python computes this in production).
1691        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)); // 3 x 41 > 100: evicts "a"
1750        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            } // dropped as on an early `?` return
1791            let _c = ActiveGuard::enter(&f); // failed before planning
1792            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        // Duplicate concurrent builds of one key keep a single resident copy.
1799        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}