Skip to main content

ras_commander_fast_map/
session.rs

1//! Prepared multi-profile / multi-AOI session.
2//!
3//! Execution model:
4//! 1. Profiles are processed in batches of `max_inflight_profiles`. Each batch is loaded and
5//!    prepared (interpolator from supplied global simplices) in the single session pool.
6//! 2. AOIs resolve to pixel windows on each profile's own grid. Work items are grid tiles of
7//!    `output_tile` pixels anchored at grid pixel (0, 0); a tile is evaluated once for the union
8//!    of AOI windows and every AOI receives its sub-rectangle. AOI output therefore equals the
9//!    crop of the full-grid output by construction.
10//! 3. Native terrain blocks are shared through one byte-bounded cache across all profiles,
11//!    batches and AOIs of the session. Items are ordered by terrain-block locality.
12//! 4. At most `max_tiles_in_flight` evaluated tiles are alive between the pool and the sink
13//!    (`max_tiles_in_flight == 1` runs a serial evaluate-then-write branch).
14//! 5. Admission: per-profile load+prepare estimates come from request content and file lengths
15//!    before anything is read; plan sizes are bounded before plans are allocated; the report's
16//!    `memory_estimate` separates enforced bounds, estimates and unaccounted allocations.
17//! 6. Failure safety: errors and caught panics after the output directory exists fail every
18//!    open unit and still write an `aborted` report (best effort; see `run`).
19use crate::interp::PreparedInterpolator;
20use crate::model::{
21    AoiSpec, ErrorPolicy, GridSpec, MaskData, Product, ProfileEstimate, ProfileSpec,
22    QueryConvention, SessionRequest, TerrainSpec, resolve,
23};
24use crate::sink::{DirSink, Rect, TileOut, write_json};
25use crate::terrain::{Hit, NativeTerrain};
26use crate::{MapError, method_record};
27use rayon::prelude::*;
28use serde::Serialize;
29use std::collections::BTreeSet;
30use std::panic::{AssertUnwindSafe, catch_unwind};
31use std::path::Path;
32use std::sync::Arc;
33use std::time::Instant;
34
35#[derive(Clone, Debug, PartialEq)]
36pub enum Grid {
37    Explicit {
38        width: usize,
39        height: usize,
40        x0: f64,
41        y0: f64,
42        dx: f64,
43        dy: f64,
44    },
45    Aligned {
46        width: usize,
47        height: usize,
48        col0: usize,
49        row0: usize,
50        gt: [f64; 6],
51    },
52}
53
54impl Grid {
55    pub fn dims(&self) -> (usize, usize) {
56        match *self {
57            Grid::Explicit { width, height, .. } | Grid::Aligned { width, height, .. } => {
58                (width, height)
59            }
60        }
61    }
62
63    /// Interpolation query point of pixel (row, col).
64    #[inline]
65    pub fn query(&self, row: usize, col: usize) -> [f64; 2] {
66        match *self {
67            Grid::Explicit { x0, y0, dx, dy, .. } => [x0 + col as f64 * dx, y0 + row as f64 * dy],
68            Grid::Aligned { col0, row0, gt, .. } => [
69                gt[0] + ((col0 + col) as f64 + 0.5) * gt[1],
70                gt[3] + ((row0 + row) as f64 + 0.5) * gt[5],
71            ],
72        }
73    }
74
75    /// Pixel-center coordinate along x (col) or y (row); used for bounds selection.
76    #[inline]
77    fn center_x(&self, col: usize) -> f64 {
78        match *self {
79            Grid::Explicit { x0, dx, .. } => x0 + (col as f64 + 0.5) * dx,
80            Grid::Aligned { col0, gt, .. } => gt[0] + ((col0 + col) as f64 + 0.5) * gt[1],
81        }
82    }
83    #[inline]
84    fn center_y(&self, row: usize) -> f64 {
85        match *self {
86            Grid::Explicit { y0, dy, .. } => y0 + (row as f64 + 0.5) * dy,
87            Grid::Aligned { row0, gt, .. } => gt[3] + ((row0 + row) as f64 + 0.5) * gt[5],
88        }
89    }
90
91    /// Terrain sample point for an explicit grid under the session convention.
92    #[inline]
93    fn terrain_point(&self, row: usize, col: usize, q: QueryConvention) -> [f64; 2] {
94        match q {
95            QueryConvention::QueryPoint => self.query(row, col),
96            QueryConvention::PixelCenter => [self.center_x(col), self.center_y(row)],
97        }
98    }
99
100    pub fn spec_json(&self) -> serde_json::Value {
101        match *self {
102            Grid::Explicit {
103                width,
104                height,
105                x0,
106                y0,
107                dx,
108                dy,
109            } => serde_json::json!({
110                "kind": "explicit", "width": width, "height": height, "x0": x0, "y0": y0, "dx": dx, "dy": dy}),
111            Grid::Aligned {
112                width,
113                height,
114                col0,
115                row0,
116                gt,
117            } => serde_json::json!({
118                "kind": "terrain_aligned", "width": width, "height": height, "col": col0, "row": row0,
119                "terrain_geo_transform": gt}),
120        }
121    }
122}
123
124/// Half-open selection of indices whose monotone center lies in [lo, hi).
125fn select_axis(n: usize, center: impl Fn(usize) -> f64, lo: f64, hi: f64) -> (usize, usize) {
126    if n == 0 {
127        return (0, 0);
128    }
129    let increasing = center(n - 1) >= center(0);
130    let pp = |pred: &dyn Fn(usize) -> bool| -> usize {
131        let (mut a, mut b) = (0usize, n);
132        while a < b {
133            let m = a + (b - a) / 2;
134            if pred(m) { a = m + 1 } else { b = m }
135        }
136        a
137    };
138    if increasing {
139        (pp(&|i| center(i) < lo), pp(&|i| center(i) < hi))
140    } else {
141        (pp(&|i| center(i) >= hi), pp(&|i| center(i) >= lo))
142    }
143}
144
145pub fn aoi_window(aoi: &AoiSpec, grid: &Grid) -> Option<Rect> {
146    let (w, h) = grid.dims();
147    let full = Rect {
148        col: 0,
149        row: 0,
150        width: w,
151        height: h,
152    };
153    if let Some(win) = aoi.window {
154        if win[0] >= w as u64 || win[1] >= h as u64 {
155            return None;
156        }
157        let cw = win[2].min(w as u64 - win[0]) as usize;
158        let ch = win[3].min(h as u64 - win[1]) as usize;
159        return Some(Rect {
160            col: win[0] as usize,
161            row: win[1] as usize,
162            width: cw,
163            height: ch,
164        });
165    }
166    if let Some(b) = aoi.bounds {
167        let (c0, c1) = select_axis(w, |c| grid.center_x(c), b[0], b[2]);
168        let (r0, r1) = select_axis(h, |r| grid.center_y(r), b[1], b[3]);
169        return (c0 < c1 && r0 < r1).then(|| Rect {
170            col: c0,
171            row: r0,
172            width: c1 - c0,
173            height: r1 - r0,
174        });
175    }
176    Some(full)
177}
178
179pub struct Prepared {
180    pub slot: usize,
181    pub id: String,
182    pub grid: Grid,
183    pub interp: PreparedInterpolator,
184    pub mask: MaskData,
185    pub terrain: Option<Vec<f64>>,
186    pub bytes: u64,
187    pub report: ProfileReport,
188}
189
190#[derive(Serialize, Clone, Debug, Default)]
191pub struct ProfileReport {
192    pub id: String,
193    pub status: String,
194    pub error: Option<serde_json::Value>,
195    pub batch: usize,
196    pub load_seconds: f64,
197    pub prepare_seconds: f64,
198    pub samples: usize,
199    pub simplices_supplied: usize,
200    pub triangles_valid: usize,
201    pub degenerate_simplices_skipped: usize,
202    pub prepared_bytes: u64,
203    pub source_bytes: u64,
204    pub grid_pixels: u64,
205    pub legacy_terrain_ignored: bool,
206    pub tiles_evaluated: u64,
207    pub pixels_evaluated: u64,
208    pub masked_pixels: u64,
209    pub raw_valid_pixels: u64,
210    pub wet_pixels: u64,
211    pub terrain_dry_pixels: u64,
212    pub terrain_raw_sentinel_pixels: u64,
213    pub terrain_near_boundary_samples: u64,
214    pub eval_seconds: f64,
215    pub max_terrain_blocks_per_tile: usize,
216    /// Pre-read load + prepare estimate (bytes) checked against `max_profile_bytes`.
217    pub load_peak_estimate_bytes: u64,
218    pub estimate_basis: String,
219}
220
221fn mask_at(m: &MaskData, i: usize) -> bool {
222    match m {
223        MaskData::AllWet => true,
224        MaskData::Bits(b) => b[i],
225    }
226}
227
228fn prepare(
229    slot: usize,
230    spec: &ProfileSpec,
231    base: &Path,
232    terrain: Option<&NativeTerrain>,
233    max_bytes: u64,
234) -> Result<Prepared, MapError> {
235    let t0 = Instant::now();
236    let data = spec.source.load(base, terrain.is_some())?;
237    let load_seconds = t0.elapsed().as_secs_f64();
238    let t1 = Instant::now();
239    let n = data.grid.pixels()? as usize;
240    let grid = match data.grid {
241        GridSpec::Explicit {
242            width,
243            height,
244            x0,
245            y0,
246            dx,
247            dy,
248        } => {
249            if ![x0, y0, dx, dy].iter().all(|v| v.is_finite()) || dx == 0.0 || dy == 0.0 {
250                return Err(MapError::Invalid(
251                    "grid origin/spacing must be finite, spacing nonzero".into(),
252                ));
253            }
254            let ends = [
255                x0 + (width as f64 + 0.5) * dx,
256                y0 + (height as f64 + 0.5) * dy,
257            ];
258            if !ends.iter().all(|v| v.is_finite()) {
259                return Err(MapError::Invalid("grid extent overflows".into()));
260            }
261            Grid::Explicit {
262                width,
263                height,
264                x0,
265                y0,
266                dx,
267                dy,
268            }
269        }
270        GridSpec::TerrainAligned {
271            col,
272            row,
273            width,
274            height,
275        } => {
276            let t = terrain.ok_or_else(|| {
277                MapError::Unsupported("terrain_aligned grid requires native_geotiff terrain".into())
278            })?;
279            if col.checked_add(width).is_none_or(|e| e > t.width)
280                || row.checked_add(height).is_none_or(|e| e > t.height)
281            {
282                return Err(MapError::Invalid(
283                    "terrain_aligned window exceeds terrain extent".into(),
284                ));
285            }
286            Grid::Aligned {
287                width,
288                height,
289                col0: col,
290                row0: row,
291                gt: t.gt,
292            }
293        }
294    };
295    if let MaskData::Bits(b) = &data.mask
296        && b.len() != n
297    {
298        return Err(MapError::Invalid(format!(
299            "mask has {} values, grid has {n}",
300            b.len()
301        )));
302    }
303    match (&data.terrain, terrain.is_some()) {
304        (Some(t), false) => {
305            if t.len() != n {
306                return Err(MapError::Invalid(format!(
307                    "terrain buffer has {} values, grid has {n}",
308                    t.len()
309                )));
310            }
311            if t.iter().any(|v| v.is_infinite()) {
312                return Err(MapError::Invalid(
313                    "terrain buffer contains infinite values".into(),
314                ));
315            }
316        }
317        (None, false) => {
318            return Err(MapError::Invalid(
319                "caller_buffer terrain mode requires a per-profile terrain buffer".into(),
320            ));
321        }
322        (Some(_), true) => {
323            return Err(MapError::Invalid(
324                "native terrain mode forbids a caller terrain buffer (ambiguous source)".into(),
325            ));
326        }
327        (None, true) => {}
328    }
329    let interp = PreparedInterpolator::new(&data.samples, &data.simplices)?;
330    let any_wet = match &data.mask {
331        MaskData::AllWet => true,
332        MaskData::Bits(b) => b.iter().any(|v| *v),
333    };
334    if any_wet && interp.triangles_valid == 0 {
335        return Err(MapError::Invalid(
336            "wet mask but no numerically valid supplied simplices".into(),
337        ));
338    }
339    let mask_bytes = match &data.mask {
340        MaskData::AllWet => 0,
341        MaskData::Bits(b) => b.len() as u64,
342    };
343    let bytes = interp.approx_bytes()
344        + mask_bytes
345        + data.terrain.as_ref().map_or(0, |t| t.len() as u64 * 8);
346    if bytes > max_bytes {
347        return Err(MapError::Budget(format!(
348            "prepared profile needs {bytes} bytes > max_profile_bytes {max_bytes}"
349        )));
350    }
351    let report = ProfileReport {
352        id: spec.id.clone(),
353        status: "prepared".into(),
354        load_seconds,
355        prepare_seconds: t1.elapsed().as_secs_f64(),
356        samples: interp.samples,
357        simplices_supplied: interp.simplices_supplied,
358        triangles_valid: interp.triangles_valid,
359        degenerate_simplices_skipped: interp.degenerate_skipped,
360        prepared_bytes: bytes,
361        source_bytes: data.source_bytes,
362        grid_pixels: n as u64,
363        legacy_terrain_ignored: data.legacy_terrain_ignored,
364        ..Default::default()
365    };
366    Ok(Prepared {
367        slot,
368        id: spec.id.clone(),
369        grid,
370        interp,
371        mask: data.mask,
372        terrain: data.terrain,
373        bytes,
374        report,
375    })
376}
377
378struct Item {
379    slot: usize,
380    tile: (usize, usize),
381    rect: Rect,
382    needs_terrain: bool,
383    /// Terrain block ranges [bx0, bx1) x [by0, by1).
384    blocks: Option<(usize, usize, usize, usize)>,
385}
386
387type Evaluated = Vec<(usize, Result<(TileOut, ItemStats), MapError>)>;
388
389#[derive(Default)]
390struct ItemStats {
391    masked: u64,
392    raw_valid: u64,
393    wet: u64,
394    terrain_dry: u64,
395    sentinel: u64,
396    near_boundary: u64,
397    seconds: f64,
398}
399
400impl ItemStats {
401    fn add(&mut self, o: &ItemStats) {
402        self.masked += o.masked;
403        self.raw_valid += o.raw_valid;
404        self.wet += o.wet;
405        self.terrain_dry += o.terrain_dry;
406        self.sentinel += o.sentinel;
407        self.near_boundary += o.near_boundary;
408        self.seconds += o.seconds;
409    }
410}
411
412fn plan_blocks(
413    p: &Prepared,
414    rect: &Rect,
415    t: &NativeTerrain,
416) -> Option<(usize, usize, usize, usize)> {
417    let (c_lo, c_hi, r_lo, r_hi) = match p.grid {
418        Grid::Aligned { col0, row0, .. } => (
419            (col0 + rect.col) as f64,
420            (col0 + rect.col + rect.width - 1) as f64,
421            (row0 + rect.row) as f64,
422            (row0 + rect.row + rect.height - 1) as f64,
423        ),
424        Grid::Explicit { .. } => {
425            // Sample coordinates are monotone in row/col, so corner floors bound every pixel.
426            let mut cs = Vec::with_capacity(4);
427            let mut rs = Vec::with_capacity(4);
428            for (r, c) in [
429                (rect.row, rect.col),
430                (rect.row, rect.col + rect.width - 1),
431                (rect.row + rect.height - 1, rect.col),
432                (rect.row + rect.height - 1, rect.col + rect.width - 1),
433            ] {
434                let [x, y] = p.grid.terrain_point(r, c, t.query);
435                let (fc, fr) = t.floor_index(x, y)?;
436                cs.push(fc);
437                rs.push(fr);
438            }
439            let f = |v: &[f64]| {
440                (
441                    v.iter().cloned().fold(f64::INFINITY, f64::min),
442                    v.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
443                )
444            };
445            let (c_lo, c_hi) = f(&cs);
446            let (r_lo, r_hi) = f(&rs);
447            (c_lo, c_hi, r_lo, r_hi)
448        }
449    };
450    let (w, h) = (t.width as f64, t.height as f64);
451    if c_hi < 0.0 || r_hi < 0.0 || c_lo >= w || r_lo >= h {
452        return None;
453    }
454    let c0 = c_lo.max(0.0) as usize;
455    let c1 = c_hi.min(w - 1.0) as usize;
456    let r0 = r_lo.max(0.0) as usize;
457    let r1 = r_hi.min(h - 1.0) as usize;
458    Some((
459        c0 / t.block.0,
460        c1 / t.block.0 + 1,
461        r0 / t.block.1,
462        r1 / t.block.1 + 1,
463    ))
464}
465
466fn evaluate(
467    item: &Item,
468    p: &Prepared,
469    terrain: Option<&NativeTerrain>,
470    want_ground: bool,
471) -> Result<(TileOut, ItemStats), MapError> {
472    let start = Instant::now();
473    let rect = item.rect;
474    let n = rect.pixels();
475    let mut out = TileOut {
476        profile: item.slot,
477        tile: item.tile,
478        rect,
479        raw_wse: vec![f64::NAN; n],
480        wse: vec![f64::NAN; n],
481        depth: vec![f64::NAN; n],
482        ground: vec![f64::NAN; n],
483        simplex_id: vec![-1; n],
484    };
485    let mut st = ItemStats::default();
486    // Acquire (pin) the tile's terrain blocks once; the cache shares them across items.
487    let (blocks, bx0, by0, nbx) = match (terrain, item.blocks, item.needs_terrain) {
488        (Some(t), Some((bx0, bx1, by0, by1)), true) => {
489            let mut v = Vec::with_capacity((bx1 - bx0) * (by1 - by0));
490            for by in by0..by1 {
491                for bx in bx0..bx1 {
492                    v.push(t.get_block(bx, by)?);
493                }
494            }
495            (v, bx0, by0, bx1 - bx0)
496        }
497        _ => (Vec::new(), 0, 0, 0),
498    };
499    let (gw, _) = p.grid.dims();
500    for r in 0..rect.height {
501        let gr = rect.row + r;
502        for c in 0..rect.width {
503            let gc = rect.col + c;
504            let gi = gr * gw + gc;
505            let o = r * rect.width + c;
506            let masked = mask_at(&p.mask, gi);
507            if !masked && !want_ground {
508                continue;
509            }
510            let ground = match terrain {
511                None => p.terrain.as_ref().map(|b| b[gi]).filter(|v| !v.is_nan()),
512                Some(t) => {
513                    let hit = match p.grid {
514                        Grid::Aligned { col0, row0, .. } => Hit::Pixel {
515                            col: col0 + gc,
516                            row: row0 + gr,
517                            near_boundary: false,
518                        },
519                        Grid::Explicit { .. } => {
520                            let [x, y] = p.grid.terrain_point(gr, gc, t.query);
521                            t.hit(x, y)
522                        }
523                    };
524                    match hit {
525                        Hit::Outside => None,
526                        Hit::Pixel {
527                            col,
528                            row,
529                            near_boundary,
530                        } => {
531                            st.near_boundary += (masked && near_boundary) as u64;
532                            let (bx, by) = (col / t.block.0, row / t.block.1);
533                            let k = (by - by0) * nbx + (bx - bx0);
534                            let block = &blocks[k];
535                            let bw = t.block.0.min(t.width - bx * t.block.0);
536                            let v = block[(row - by * t.block.1) * bw + (col - bx * t.block.0)];
537                            st.sentinel += (masked && t.is_sentinel(v)) as u64;
538                            let g = t.classify(v);
539                            if g.is_some_and(f64::is_infinite) {
540                                // Same policy as caller buffers: infinite terrain is rejected.
541                                return Err(MapError::Terrain(format!(
542                                    "{}: infinite terrain value {v} at pixel (col {col}, row {row})",
543                                    t.path
544                                )));
545                            }
546                            g
547                        }
548                    }
549                }
550            };
551            if want_ground {
552                out.ground[o] = ground.unwrap_or(f64::NAN);
553            }
554            if !masked {
555                continue;
556            }
557            st.masked += 1;
558            // Interpolation and terrain subtraction: baseline kernel semantics.
559            if let Some((id, z)) = p.interp.locate(p.grid.query(gr, gc)) {
560                out.raw_wse[o] = z;
561                out.simplex_id[o] = id as i64;
562                st.raw_valid += 1;
563                match ground {
564                    Some(g) => {
565                        let d = z - g;
566                        if d >= 0.0 {
567                            out.wse[o] = z;
568                            out.depth[o] = d;
569                            st.wet += 1;
570                        }
571                    }
572                    None => st.terrain_dry += 1,
573                }
574            }
575        }
576    }
577    st.seconds = start.elapsed().as_secs_f64();
578    Ok((out, st))
579}
580
581fn unit_header(
582    req: &SessionRequest,
583    p: &Prepared,
584    aoi: &AoiSpec,
585    window: Option<Rect>,
586    products: &[Product],
587) -> serde_json::Value {
588    let mut prods: Vec<_> = products.to_vec();
589    prods.sort();
590    serde_json::json!({
591        "schema": "ras2fim-batch-unit/1",
592        "method_version": crate::METHOD_VERSION,
593        "profile": p.id,
594        "aoi": aoi.id,
595        "aoi_spec": aoi,
596        "crs_epsg": req.crs_epsg,
597        "profile_grid": p.grid.spec_json(),
598        "window": window,
599        "products": prods.iter().map(|p| serde_json::json!({"name": p.name(), "file": format!("{}.bin", p.name()), "dtype": p.dtype(), "nodata": if *p == Product::SimplexId {serde_json::json!(-1)} else {serde_json::json!("NaN")}})).collect::<Vec<_>>(),
600        "layout": "row-major over window, grid row order (row index increases with y0 + row*dy for explicit grids)",
601        "terrain": match &req.terrain { TerrainSpec::CallerBuffer => serde_json::json!("caller_buffer"), t => serde_json::to_value(t).unwrap_or_default() },
602    })
603}
604
605/// Work items above this count in one profile plan are rejected before the plan is allocated
606/// (raise `output_tile`). Bounds plan metadata: items, tile set and per-unit tile records.
607pub const MAX_PLAN_TILES_PER_PROFILE: u64 = 1 << 22;
608/// Estimated bytes per planned work item (item, tile-set entry and evaluated result slot).
609const PLAN_BYTES_PER_ITEM: u64 = std::mem::size_of::<Item>() as u64 + 48 + 16;
610
611/// Tiles of edge `t` (anchored at grid 0,0) that intersect `w`.
612fn tiles_in(w: &Rect, t: usize) -> u64 {
613    let rows = (w.row + w.height - 1) / t - w.row / t + 1;
614    let cols = (w.col + w.width - 1) / t - w.col / t + 1;
615    rows as u64 * cols as u64
616}
617
618/// Session-wide accounted memory terms (bytes) that do not depend on the batch.
619#[derive(Serialize, Clone, Debug, Default)]
620struct StaticTerms {
621    /// Enforced: decoded resident terrain tiles (`max_cache_bytes`; 0 for caller buffers).
622    cache_resident_bound: u64,
623    /// Estimate, not enforced: `threads x decoder_transient_bytes_per_load` (native terrain).
624    decoder_transient_estimate: u64,
625    /// Enforced by construction: evaluated tile buffers alive at once.
626    output_tile_buffers_bound: u64,
627    /// Exact: inline profile payloads held by the parsed request.
628    request_inline_payload: u64,
629}
630
631impl StaticTerms {
632    fn total(&self) -> u64 {
633        self.cache_resident_bound
634            .saturating_add(self.decoder_transient_estimate)
635            .saturating_add(self.output_tile_buffers_bound)
636            .saturating_add(self.request_inline_payload)
637    }
638}
639
640/// Per-run mutable state. Owned outside the inner run so the abort path can read it after an
641/// error or a caught panic.
642#[derive(Default)]
643struct RunState {
644    sink: Option<DirSink>,
645    reports: Vec<ProfileReport>,
646    terrain: Option<Arc<NativeTerrain>>,
647    statics: StaticTerms,
648    batches: Vec<serde_json::Value>,
649    prepare_wall: f64,
650    eval_wall: f64,
651    sink_seconds: f64,
652    items_total: u64,
653    peak_tiles_in_flight: usize,
654    max_blocks_any: usize,
655}
656
657fn err_json(stage: &str, e: &MapError) -> serde_json::Value {
658    serde_json::json!({"stage": stage, "kind": e.kind(), "message": e.to_string()})
659}
660
661/// Writes a `failed` unit.json for every AOI of a profile that never opened units.
662fn write_failed_units(
663    sink: &DirSink,
664    req: &SessionRequest,
665    s: usize,
666    stage: &str,
667    e: &MapError,
668) -> Result<(), MapError> {
669    for a in &req.aois {
670        let dir = sink.unit_dir(&req.profiles[s].id, &a.id);
671        std::fs::create_dir_all(&dir)?;
672        write_json(
673            &dir.join("unit.json"),
674            &serde_json::json!({
675            "schema": "ras2fim-batch-unit/1", "profile": req.profiles[s].id, "aoi": a.id,
676            "status": "failed", "error": err_json(stage, e)}),
677        )?;
678    }
679    Ok(())
680}
681
682/// Peak accounted bytes of loading a set of profiles together in a pool of `threads` workers:
683/// all residents plus the `threads` largest load transients.
684fn batch_load_peak(est: &[&ProfileEstimate], threads: usize) -> u64 {
685    let mut tr: Vec<u64> = est.iter().map(|e| e.transient_bytes).collect();
686    tr.sort_unstable_by(|a, b| b.cmp(a));
687    let resident: u64 = est.iter().map(|e| e.resident_bytes).sum();
688    resident.saturating_add(tr.iter().take(threads).sum())
689}
690
691/// Profiles per batch: chunks of `max_inflight_profiles` (r1 behaviour) or, with
692/// `max_session_bytes`, greedy packing so each batch's accounted load peak fits the allowance.
693/// Profiles whose estimate failed or cannot fit alone ride along at zero cost and fail at
694/// preflight.
695fn plan_batches(
696    req: &SessionRequest,
697    est: &[Result<ProfileEstimate, MapError>],
698    allowance: Option<u64>,
699) -> Vec<Vec<usize>> {
700    let b = &req.budgets;
701    let Some(allow) = allowance else {
702        let slots: Vec<usize> = (0..req.profiles.len()).collect();
703        return slots
704            .chunks(b.max_inflight_profiles)
705            .map(<[usize]>::to_vec)
706            .collect();
707    };
708    let mut out: Vec<Vec<usize>> = Vec::new();
709    let mut cur: Vec<usize> = Vec::new();
710    for (s, e) in est.iter().enumerate() {
711        let fits_alone = e.as_ref().is_ok_and(|e| {
712            e.peak_bytes() <= b.max_profile_bytes && batch_load_peak(&[e], b.threads) <= allow
713        });
714        if fits_alone {
715            let mut members: Vec<&ProfileEstimate> =
716                cur.iter().filter_map(|&k| est[k].as_ref().ok()).collect();
717            members.push(e.as_ref().expect("fits_alone implies Ok"));
718            if cur.len() >= b.max_inflight_profiles || batch_load_peak(&members, b.threads) > allow
719            {
720                out.push(std::mem::take(&mut cur));
721            }
722        } else if cur.len() >= b.max_inflight_profiles {
723            out.push(std::mem::take(&mut cur));
724        }
725        cur.push(s);
726    }
727    if !cur.is_empty() {
728        out.push(cur);
729    }
730    out
731}
732
733/// Runs a session and writes outputs plus `report.json` into the new directory `out`.
734///
735/// Failure contract: once `out` has been created by this call, every exit — error or caught
736/// panic — fails each open unit (data removed, `unit.json` `failed`) and writes `report.json`
737/// with `status: "aborted"` and the error, best effort. If even that is impossible (e.g. the
738/// disk is full) the reason is printed to stderr and the in-progress markers / `.partial` names
739/// stay as explicit evidence. The error is still returned.
740pub fn run(req: &SessionRequest, base: &Path, out: &Path) -> Result<serde_json::Value, MapError> {
741    let started = Instant::now();
742    req.validate()?;
743    let existed = out.exists();
744    let mut st = RunState {
745        reports: req
746            .profiles
747            .iter()
748            .map(|p| ProfileReport {
749                id: p.id.clone(),
750                status: "not_started".into(),
751                ..Default::default()
752            })
753            .collect(),
754        ..Default::default()
755    };
756    let result = catch_unwind(AssertUnwindSafe(|| {
757        run_inner(req, base, out, &mut st, started)
758    }));
759    let err = match result {
760        Ok(Ok(report)) => return Ok(report),
761        Ok(Err(e)) => e,
762        Err(p) => MapError::Internal(format!(
763            "session panicked: {}",
764            p.downcast_ref::<&str>()
765                .map(|s| s.to_string())
766                .or_else(|| p.downcast_ref::<String>().cloned())
767                .unwrap_or_else(|| "non-string panic payload".into())
768        )),
769    };
770    // Only write into a directory this call created.
771    if !existed && out.is_dir() {
772        let problems = st
773            .sink
774            .as_mut()
775            .map(|s| s.abort_all(&err))
776            .unwrap_or_default();
777        for r in st.reports.iter_mut() {
778            if r.status != "complete" && r.status != "failed" {
779                r.status = "aborted".into();
780                r.error = Some(err_json("session", &err));
781            }
782        }
783        let written = build_report(req, &st, "aborted", started).and_then(|mut rep| {
784            rep["error"] = err_json("session", &err);
785            rep["cleanup_problems"] = problems.clone().into();
786            write_json(&out.join("report.json"), &rep)
787        });
788        if let Err(w) = written {
789            eprintln!(
790                "session error: {err}; report.json could NOT be written ({w}); units still marked in_progress (if any) are not valid outputs"
791            );
792        }
793        if !problems.is_empty() {
794            eprintln!("cleanup problems: {}", problems.join("; "));
795        }
796    }
797    Err(err)
798}
799
800fn build_report(
801    req: &SessionRequest,
802    st: &RunState,
803    status: &str,
804    started: Instant,
805) -> Result<serde_json::Value, MapError> {
806    let b = &req.budgets;
807    let pipelined = b.max_tiles_in_flight > 1;
808    let peak_batch = st
809        .batches
810        .iter()
811        .filter_map(|v| v["accounted_peak_estimate_bytes"].as_u64())
812        .max()
813        .unwrap_or(0);
814    let tstats = st.terrain.as_ref().map(|t| t.stats());
815    Ok(serde_json::json!({
816        "schema": "ras2fim-batch-report/1",
817        "method": method_record(),
818        "status": status,
819        "crs_epsg": req.crs_epsg,
820        "terrain_spec": req.terrain,
821        "terrain": st.terrain.as_ref().map(|t| serde_json::json!({
822            "path": t.path, "geo_transform": t.gt, "width": t.width, "height": t.height,
823            "block": t.block, "blocks": t.blocks(), "nodata": t.nodata, "epsg": t.epsg,
824            "nodata_f32_exact": crate::terrain::nodata_f32_exact(t.nodata),
825            "crs_identity": t.crs_identity, "crs_wkt_fnv1a64": t.crs_wkt_fnv1a64,
826            "decode_policy": "one TIFF block per terrio read_window call (serial decode on the calling worker)",
827            "stats": tstats,
828        })),
829        "budgets": b,
830        "output_buffer_bound_bytes": req.output_buffer_bound()?,
831        "products": req.products,
832        "aois": req.aois,
833        "error_policy": req.error_policy,
834        "profiles": st.reports,
835        "counters": {
836            "profiles": req.profiles.len(),
837            "aois": req.aois.len(),
838            "work_items": st.items_total,
839            "tiles_written": st.sink.as_ref().map_or(0, |s| s.tiles_written),
840            "output_bytes": st.sink.as_ref().map_or(0, |s| s.bytes_written),
841            "unit_files_written": st.sink.as_ref().map_or(0, |s| s.unit_files_written),
842            "peak_tiles_in_flight": st.peak_tiles_in_flight,
843            "effective_tiles_in_flight_limit": req.effective_tiles_in_flight(),
844            "max_terrain_blocks_per_tile": st.max_blocks_any,
845            "dir_fsync_unsupported": st.sink.as_ref().map_or(0, |s| s.dir_sync_unsupported),
846        },
847        "contract_version": crate::CONTRACT_VERSION,
848        "cpu_threads": {
849            "pool_workers": b.threads,
850            "pipelined": pipelined,
851            "cpu_active_max": b.threads + pipelined as usize,
852            "note": "in the pipelined branch the calling thread writes outputs and hashes while the pool evaluates, so up to threads + 1 threads are CPU-active; `threads` is the evaluation pool size, not a total CPU budget",
853        },
854        "memory_estimate": {
855            "is_hard_bound": false,
856            "summary": "admission estimate over accounted terms; the decoded terrain cache and evaluated tile buffers are enforced bounds, the rest are estimates; unaccounted allocations remain",
857            "static_terms_bytes": st.statics,
858            "static_total_bytes": st.statics.total(),
859            "batches": st.batches,
860            "accounted_peak_estimate_bytes": peak_batch,
861            "max_session_bytes": b.max_session_bytes,
862            "measured": {
863                "cache_peak_resident_bytes": tstats.as_ref().map(|s| s.cache.peak_bytes),
864                "decoder_transient_peak_estimate_bytes": tstats.as_ref().map(|s| s.decoder_transient_peak_estimate_bytes),
865                "peak_tile_buffer_bytes": (st.peak_tiles_in_flight as u64) * (b.output_tile as u64).pow(2) * 40,
866            },
867            "unaccounted": [
868                "allocator fragmentation / RSS retained after frees",
869                "thread stacks (threads + 2 OS threads)",
870                "terrio readers, TIFF directory state, file buffers and codec (flate/zstd/lzw) internal state",
871                "R-tree node overhead beyond the 48 B/simplex estimate and bulk-load working memory beyond one triangle-vector copy",
872                "serde/JSON transients for the request, unit headers and this report",
873                "legacy_request JSON parse (heuristic 64 x file length; not a proof)",
874                "OS page cache"
875            ],
876        },
877        "timings": {
878            "total_wall_seconds": started.elapsed().as_secs_f64(),
879            "prepare_wall_seconds": st.prepare_wall,
880            "evaluate_and_sink_wall_seconds": st.eval_wall,
881            "sink_seconds": st.sink_seconds,
882        },
883    }))
884}
885
886/// Writes evaluated tiles of one chunk; returns (first sink error, seconds).
887#[allow(clippy::too_many_arguments)]
888fn write_chunk(
889    to_write: Option<Evaluated>,
890    items: &[Item],
891    failed: &mut [Option<MapError>],
892    acc: &mut [(u64, u64, ItemStats)],
893    sink: &mut DirSink,
894    aoi_count: usize,
895) -> (Result<(), MapError>, f64) {
896    let ts = Instant::now();
897    let mut wr: Result<(), MapError> = Ok(());
898    for (k, res) in to_write.into_iter().flatten() {
899        let it = &items[k];
900        match res {
901            Ok((tile, st)) => {
902                if failed[it.slot].is_some() {
903                    continue;
904                }
905                let a = &mut acc[it.slot];
906                a.0 += 1;
907                a.1 += it.rect.pixels() as u64;
908                a.2.add(&st);
909                if let Err(e) = sink.write_tile(&tile, aoi_count) {
910                    wr = Err(e);
911                    break;
912                }
913            }
914            Err(e) => {
915                if failed[it.slot].is_none() {
916                    failed[it.slot] = Some(e);
917                }
918            }
919        }
920    }
921    (wr, ts.elapsed().as_secs_f64())
922}
923
924fn run_inner(
925    req: &SessionRequest,
926    base: &Path,
927    out: &Path,
928    st: &mut RunState,
929    started: Instant,
930) -> Result<serde_json::Value, MapError> {
931    let b = &req.budgets;
932    let terrain: Option<Arc<NativeTerrain>> = match &req.terrain {
933        TerrainSpec::CallerBuffer => None,
934        TerrainSpec::NativeGeotiff {
935            path,
936            query,
937            nodata_policy,
938            crs_identity,
939        } => {
940            let p = resolve(base, path);
941            let p = p
942                .to_str()
943                .ok_or_else(|| MapError::Invalid("terrain path is not UTF-8".into()))?;
944            Some(Arc::new(NativeTerrain::open(
945                p,
946                req.crs_epsg,
947                crs_identity,
948                *query,
949                *nodata_policy,
950                b.max_cache_bytes,
951            )?))
952        }
953    };
954    st.terrain = terrain.clone();
955    // Pre-read admission: accounted session terms and per-profile estimates from metadata only.
956    st.statics = StaticTerms {
957        cache_resident_bound: terrain.as_ref().map_or(0, |_| b.max_cache_bytes),
958        decoder_transient_estimate: terrain.as_ref().map_or(0, |t| {
959            (b.threads as u64).saturating_mul(t.decoder_transient_bytes_per_load())
960        }),
961        output_tile_buffers_bound: req.output_buffer_bound()?,
962        request_inline_payload: req.inline_payload_bytes(),
963    };
964    let allowance = match b.max_session_bytes {
965        Some(max) => Some(max.checked_sub(st.statics.total()).ok_or_else(|| {
966            MapError::Budget(format!(
967                "max_session_bytes {max} < static accounted terms {} ({:?})",
968                st.statics.total(),
969                st.statics
970            ))
971        })?),
972        None => None,
973    };
974    let estimates: Vec<Result<ProfileEstimate, MapError>> = req
975        .profiles
976        .iter()
977        .map(|p| p.source.estimate(base))
978        .collect();
979    let batches = plan_batches(req, &estimates, allowance);
980    let want_ground = req.products.contains(&Product::Ground);
981    let pool = rayon::ThreadPoolBuilder::new()
982        .num_threads(b.threads)
983        .thread_name(|i| format!("ras2fim-batch-{i}"))
984        .build()
985        .map_err(|e| MapError::Invalid(format!("thread pool: {e}")))?;
986    st.sink = Some(DirSink::create(out, &req.products)?);
987    let mut aborted = false;
988    for (batch_no, batch) in batches.iter().enumerate() {
989        if aborted {
990            break;
991        }
992        // 0. Preflight (no reads): estimate errors and over-budget profiles fail here.
993        let mut admitted: Vec<usize> = Vec::new();
994        for &s in batch {
995            let pre = estimates[s].as_ref().map_err(Clone::clone).and_then(|e| {
996                st.reports[s].load_peak_estimate_bytes = e.peak_bytes();
997                st.reports[s].estimate_basis = e.basis.into();
998                if e.peak_bytes() > b.max_profile_bytes {
999                    Err(MapError::Budget(format!(
1000                        "profile load+prepare estimate {} bytes ({}) > max_profile_bytes {}",
1001                        e.peak_bytes(),
1002                        e.basis,
1003                        b.max_profile_bytes
1004                    )))
1005                } else if let Some(allow) = allowance
1006                    && batch_load_peak(&[e], b.threads) > allow
1007                {
1008                    Err(MapError::Budget(format!(
1009                        "profile load+prepare estimate {} bytes does not fit max_session_bytes after static terms ({allow} bytes left)",
1010                        batch_load_peak(&[e], b.threads)
1011                    )))
1012                } else {
1013                    Ok(())
1014                }
1015            });
1016            match pre {
1017                Ok(()) => admitted.push(s),
1018                Err(e) => {
1019                    st.reports[s].status = "failed".into();
1020                    st.reports[s].batch = batch_no;
1021                    st.reports[s].error = Some(err_json("preflight", &e));
1022                    write_failed_units(st.sink.as_ref().expect("sink"), req, s, "preflight", &e)?;
1023                    if req.error_policy == ErrorPolicy::FailFast {
1024                        aborted = true;
1025                    }
1026                }
1027            }
1028        }
1029        if aborted {
1030            break;
1031        }
1032        let admitted_est: Vec<&ProfileEstimate> = admitted
1033            .iter()
1034            .filter_map(|&s| estimates[s].as_ref().ok())
1035            .collect();
1036        let load_peak = batch_load_peak(&admitted_est, b.threads);
1037        // 1. Load + prepare the batch in the session pool.
1038        let t0 = Instant::now();
1039        let prepared: Vec<Result<Prepared, MapError>> = pool.install(|| {
1040            admitted
1041                .par_iter()
1042                .map(|&s| {
1043                    prepare(
1044                        s,
1045                        &req.profiles[s],
1046                        base,
1047                        terrain.as_deref(),
1048                        b.max_profile_bytes,
1049                    )
1050                })
1051                .collect()
1052        });
1053        st.prepare_wall += t0.elapsed().as_secs_f64();
1054        let mut live: Vec<Prepared> = Vec::new();
1055        for (&s, r) in admitted.iter().zip(prepared) {
1056            match r {
1057                Ok(mut p) => {
1058                    p.report.batch = batch_no;
1059                    p.report.load_peak_estimate_bytes = st.reports[s].load_peak_estimate_bytes;
1060                    p.report.estimate_basis = st.reports[s].estimate_basis.clone();
1061                    st.reports[s].status = "prepared".into();
1062                    live.push(p);
1063                }
1064                Err(e) => {
1065                    st.reports[s].status = "failed".into();
1066                    st.reports[s].batch = batch_no;
1067                    st.reports[s].error = Some(err_json("prepare", &e));
1068                    write_failed_units(st.sink.as_ref().expect("sink"), req, s, "prepare", &e)?;
1069                    if req.error_policy == ErrorPolicy::FailFast {
1070                        aborted = true;
1071                    }
1072                }
1073            }
1074        }
1075        if aborted {
1076            break;
1077        }
1078        let resident: u64 = live.iter().map(|p| p.bytes).sum();
1079        // 2. Units and work items. Plan size is bounded before anything is allocated.
1080        let mut items: Vec<Item> = Vec::new();
1081        let mut failed: Vec<Option<MapError>> = vec![None; live.len()];
1082        let mut opened: Vec<bool> = vec![false; live.len()];
1083        let mut headers: Vec<Vec<serde_json::Value>> = Vec::new();
1084        let mut plan_bytes_batch = 0u64;
1085        for (li, p) in live.iter().enumerate() {
1086            let t = b.output_tile;
1087            let windows_all: Vec<Option<Rect>> =
1088                req.aois.iter().map(|a| aoi_window(a, &p.grid)).collect();
1089            let unit_tiles: Vec<u64> = windows_all
1090                .iter()
1091                .map(|w| w.as_ref().map_or(0, |w| tiles_in(w, t)))
1092                .collect();
1093            let tile_bound: u64 = unit_tiles.iter().sum();
1094            let plan_bytes = tile_bound
1095                .saturating_mul(PLAN_BYTES_PER_ITEM)
1096                .saturating_add(tile_bound.saturating_mul(crate::sink::TILE_RECORD_BYTES));
1097            let plan_err = if tile_bound > MAX_PLAN_TILES_PER_PROFILE {
1098                Some(MapError::Budget(format!(
1099                    "plan has up to {tile_bound} tiles > {MAX_PLAN_TILES_PER_PROFILE}; raise output_tile"
1100                )))
1101            } else if let Some(allow) = allowance
1102                && resident
1103                    .saturating_add(plan_bytes_batch)
1104                    .saturating_add(plan_bytes)
1105                    > allow
1106            {
1107                Some(MapError::Budget(format!(
1108                    "plan metadata {plan_bytes} bytes does not fit max_session_bytes with {resident} resident profile bytes"
1109                )))
1110            } else {
1111                None
1112            };
1113            if let Some(e) = plan_err {
1114                write_failed_units(st.sink.as_ref().expect("sink"), req, p.slot, "plan", &e)?;
1115                failed[li] = Some(e);
1116                headers.push(Vec::new());
1117                continue;
1118            }
1119            plan_bytes_batch += plan_bytes;
1120            opened[li] = true;
1121            let mut windows = Vec::new();
1122            let mut hs = Vec::new();
1123            for (ai, a) in req.aois.iter().enumerate() {
1124                let w = windows_all[ai];
1125                let h = unit_header(req, p, a, w, &req.products);
1126                st.sink.as_mut().expect("sink").begin_unit(
1127                    (li, ai),
1128                    &p.id,
1129                    &a.id,
1130                    w,
1131                    &h,
1132                    unit_tiles[ai] as usize,
1133                )?;
1134                hs.push(h);
1135                if let Some(w) = w {
1136                    windows.push(w);
1137                }
1138            }
1139            headers.push(hs);
1140            let mut tiles = BTreeSet::new();
1141            for w in &windows {
1142                for tr in w.row / t..=(w.row + w.height - 1) / t {
1143                    for tc in w.col / t..=(w.col + w.width - 1) / t {
1144                        tiles.insert((tr, tc));
1145                    }
1146                }
1147            }
1148            let (gw, gh) = p.grid.dims();
1149            let mut kmax = 0;
1150            for (tr, tc) in tiles {
1151                let tile_rect = Rect {
1152                    col: tc * t,
1153                    row: tr * t,
1154                    width: t.min(gw - tc * t),
1155                    height: t.min(gh - tr * t),
1156                };
1157                let parts: Vec<Rect> = windows
1158                    .iter()
1159                    .filter_map(|w| w.intersect(&tile_rect))
1160                    .collect();
1161                let c0 = parts.iter().map(|r| r.col).min().unwrap_or(0);
1162                let r0 = parts.iter().map(|r| r.row).min().unwrap_or(0);
1163                let c1 = parts.iter().map(|r| r.col + r.width).max().unwrap_or(0);
1164                let r1 = parts.iter().map(|r| r.row + r.height).max().unwrap_or(0);
1165                let rect = Rect {
1166                    col: c0,
1167                    row: r0,
1168                    width: c1 - c0,
1169                    height: r1 - r0,
1170                };
1171                let any_mask = match &p.mask {
1172                    MaskData::AllWet => true,
1173                    MaskData::Bits(bits) => (rect.row..rect.row + rect.height).any(|r| {
1174                        bits[r * gw + rect.col..r * gw + rect.col + rect.width]
1175                            .iter()
1176                            .any(|v| *v)
1177                    }),
1178                };
1179                let needs_terrain = terrain.is_some() && (any_mask || want_ground);
1180                let blocks = match (&terrain, needs_terrain) {
1181                    (Some(tt), true) => plan_blocks(p, &rect, tt),
1182                    _ => None,
1183                };
1184                if let Some((bx0, bx1, by0, by1)) = blocks {
1185                    kmax = kmax.max((bx1 - bx0) * (by1 - by0));
1186                }
1187                items.push(Item {
1188                    slot: li,
1189                    tile: (tr, tc),
1190                    rect,
1191                    needs_terrain,
1192                    blocks,
1193                });
1194            }
1195            st.max_blocks_any = st.max_blocks_any.max(kmax);
1196            if let Some(tt) = &terrain {
1197                let need = (b.threads as u64)
1198                    .saturating_mul(kmax as u64)
1199                    .saturating_mul(tt.max_block_bytes());
1200                if need > b.max_cache_bytes {
1201                    failed[li] = Some(MapError::Budget(format!(
1202                        "terrain cache budget {} < threads {} x {} blocks/tile x {} bytes = {need}; raise max_cache_bytes or lower output_tile/threads",
1203                        b.max_cache_bytes,
1204                        b.threads,
1205                        kmax,
1206                        tt.max_block_bytes()
1207                    )));
1208                }
1209            }
1210        }
1211        st.batches.push(serde_json::json!({
1212            "batch": batch_no,
1213            "profiles": admitted.iter().map(|&s| req.profiles[s].id.as_str()).collect::<Vec<_>>(),
1214            "load_peak_estimate_bytes": load_peak,
1215            "prepared_resident_bytes": resident,
1216            "plan_metadata_estimate_bytes": plan_bytes_batch,
1217            "accounted_peak_estimate_bytes": st.statics.total().saturating_add(load_peak.max(resident.saturating_add(plan_bytes_batch))),
1218        }));
1219        for (li, p) in live.iter_mut().enumerate() {
1220            p.report.max_terrain_blocks_per_tile = items
1221                .iter()
1222                .filter(|it| it.slot == li)
1223                .filter_map(|it| it.blocks.map(|(a, b2, c, d)| (b2 - a) * (d - c)))
1224                .max()
1225                .unwrap_or(0);
1226        }
1227        // 3. Locality order: shared terrain blocks consecutively across profiles.
1228        items.retain(|it| failed[it.slot].is_none());
1229        items.sort_by_key(|it| {
1230            (
1231                it.blocks
1232                    .map_or((usize::MAX, usize::MAX), |(bx0, _, by0, _)| (by0, bx0)),
1233                it.slot,
1234                it.tile,
1235            )
1236        });
1237        st.items_total += items.len() as u64;
1238        // 4. Bounded evaluation. Pipelined (max_tiles_in_flight >= 2): the pool evaluates chunk
1239        //    k+1 while this thread writes chunk k; two chunks of max_tiles_in_flight/2 keep at
1240        //    most 2*(max_tiles_in_flight/2) tiles alive. Serial (== 1): evaluate one tile, write
1241        //    it, repeat; never more than one alive.
1242        let t1 = Instant::now();
1243        let serial = b.max_tiles_in_flight == 1;
1244        let chunk_len = if serial { 1 } else { b.max_tiles_in_flight / 2 };
1245        let order: Vec<usize> = (0..items.len()).collect();
1246        let mut chunks = order.chunks(chunk_len);
1247        let mut acc: Vec<(u64, u64, ItemStats)> = (0..live.len())
1248            .map(|_| (0, 0, ItemStats::default()))
1249            .collect();
1250        let mut pending: Option<Evaluated> = None;
1251        let sink = st.sink.as_mut().expect("sink");
1252        loop {
1253            let next: Option<Vec<usize>> = chunks.next().map(|c| {
1254                c.iter()
1255                    .copied()
1256                    .filter(|&k| failed[items[k].slot].is_none())
1257                    .collect()
1258            });
1259            if next.is_none() && pending.is_none() {
1260                break;
1261            }
1262            let (items_ref, live_ref, terrain_ref) = (&items, &live, terrain.as_deref());
1263            let eval = |idx: Vec<usize>| -> Evaluated {
1264                idx.par_iter()
1265                    .map(|&k| {
1266                        let it = &items_ref[k];
1267                        let p = &live_ref[it.slot];
1268                        (k, evaluate(it, p, terrain_ref, want_ground))
1269                    })
1270                    .collect()
1271            };
1272            if serial {
1273                let evaluated = next.map(|idx| pool.install(|| eval(idx)));
1274                st.peak_tiles_in_flight = st
1275                    .peak_tiles_in_flight
1276                    .max(evaluated.as_ref().map_or(0, Vec::len));
1277                let (write_result, write_secs) = write_chunk(
1278                    evaluated,
1279                    items_ref,
1280                    &mut failed,
1281                    &mut acc,
1282                    sink,
1283                    req.aois.len(),
1284                );
1285                st.sink_seconds += write_secs;
1286                write_result?;
1287            } else {
1288                let in_flight =
1289                    next.as_ref().map_or(0, Vec::len) + pending.as_ref().map_or(0, Vec::len);
1290                st.peak_tiles_in_flight = st.peak_tiles_in_flight.max(in_flight);
1291                let to_write = pending.take();
1292                let mut evaluated: Option<Evaluated> = None;
1293                // `in_place_scope` keeps this thread (the writer) outside the pool; the chunk job
1294                // runs on pool workers. No additional OS threads are created.
1295                let (write_result, write_secs) = pool.in_place_scope(|s| {
1296                    if let Some(idx) = next {
1297                        let slot = &mut evaluated;
1298                        s.spawn(move |_| *slot = Some(eval(idx)));
1299                    }
1300                    write_chunk(
1301                        to_write,
1302                        items_ref,
1303                        &mut failed,
1304                        &mut acc,
1305                        sink,
1306                        req.aois.len(),
1307                    )
1308                });
1309                st.sink_seconds += write_secs;
1310                write_result?;
1311                pending = evaluated;
1312            }
1313            if req.error_policy == ErrorPolicy::FailFast && failed.iter().any(Option::is_some) {
1314                aborted = true;
1315                break;
1316            }
1317        }
1318        for (p, (tiles, pixels, s)) in live.iter_mut().zip(acc) {
1319            let rep = &mut p.report;
1320            rep.tiles_evaluated = tiles;
1321            rep.pixels_evaluated = pixels;
1322            rep.masked_pixels = s.masked;
1323            rep.raw_valid_pixels = s.raw_valid;
1324            rep.wet_pixels = s.wet;
1325            rep.terrain_dry_pixels = s.terrain_dry;
1326            rep.terrain_raw_sentinel_pixels = s.sentinel;
1327            rep.terrain_near_boundary_samples = s.near_boundary;
1328            rep.eval_seconds = s.seconds;
1329        }
1330        st.eval_wall += t1.elapsed().as_secs_f64();
1331        // 5. Close units; drop prepared profiles of this batch.
1332        let abort_err =
1333            MapError::Invalid("session aborted by fail_fast before this profile completed".into());
1334        for (li, p) in live.into_iter().enumerate() {
1335            let err = failed[li]
1336                .as_ref()
1337                .or(if aborted { Some(&abort_err) } else { None });
1338            if opened[li] {
1339                for (ai, header) in headers[li].iter().enumerate() {
1340                    sink.end_unit((li, ai), header, err)?;
1341                }
1342            }
1343            let mut rep = p.report;
1344            match err {
1345                Some(e) => {
1346                    rep.status = if failed[li].is_some() {
1347                        "failed"
1348                    } else {
1349                        "aborted"
1350                    }
1351                    .into();
1352                    let stage = if opened[li] { "evaluate" } else { "plan" };
1353                    rep.error = Some(err_json(stage, e));
1354                }
1355                None => rep.status = "complete".into(),
1356            }
1357            st.reports[p.slot] = rep;
1358        }
1359        if sink.open_units() != 0 {
1360            return Err(MapError::Internal(format!(
1361                "{} units left open after batch {batch_no}",
1362                sink.open_units()
1363            )));
1364        }
1365    }
1366    let any_failed = st.reports.iter().any(|r| r.status != "complete");
1367    let report = build_report(
1368        req,
1369        st,
1370        if any_failed { "incomplete" } else { "complete" },
1371        started,
1372    )?;
1373    write_json(&out.join("report.json"), &report)?;
1374    Ok(report)
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379    use super::*;
1380
1381    #[test]
1382    fn bounds_select_by_pixel_center_both_orientations() {
1383        let g = Grid::Explicit {
1384            width: 10,
1385            height: 10,
1386            x0: 0.,
1387            y0: 0.,
1388            dx: 1.,
1389            dy: 1.,
1390        };
1391        let a = AoiSpec {
1392            id: "a".into(),
1393            window: None,
1394            bounds: Some([2.0, 3.0, 5.5, 3.6]),
1395            crs_epsg: None,
1396        };
1397        assert_eq!(
1398            aoi_window(&a, &g),
1399            Some(Rect {
1400                col: 2,
1401                row: 3,
1402                width: 3,
1403                height: 1
1404            })
1405        );
1406        let g = Grid::Explicit {
1407            width: 10,
1408            height: 10,
1409            x0: 0.,
1410            y0: 10.,
1411            dx: 1.,
1412            dy: -1.,
1413        };
1414        // Row r center y = 10 - (r + 0.5); [3, 6) contains centers 5.5, 4.5, 3.5 -> rows 4, 5, 6.
1415        let a = AoiSpec {
1416            id: "a".into(),
1417            window: None,
1418            bounds: Some([0.0, 3.0, 1.0, 6.0]),
1419            crs_epsg: None,
1420        };
1421        assert_eq!(
1422            aoi_window(&a, &g),
1423            Some(Rect {
1424                col: 0,
1425                row: 4,
1426                width: 1,
1427                height: 3
1428            })
1429        );
1430        let a = AoiSpec {
1431            id: "a".into(),
1432            window: None,
1433            bounds: Some([20.0, 3.0, 21.0, 6.0]),
1434            crs_epsg: None,
1435        };
1436        assert_eq!(aoi_window(&a, &g), None);
1437    }
1438
1439    #[test]
1440    fn windows_clip() {
1441        let g = Grid::Explicit {
1442            width: 10,
1443            height: 8,
1444            x0: 0.,
1445            y0: 0.,
1446            dx: 1.,
1447            dy: 1.,
1448        };
1449        let a = AoiSpec {
1450            id: "a".into(),
1451            window: Some([8, 6, 5, 5]),
1452            bounds: None,
1453            crs_epsg: None,
1454        };
1455        assert_eq!(
1456            aoi_window(&a, &g),
1457            Some(Rect {
1458                col: 8,
1459                row: 6,
1460                width: 2,
1461                height: 2
1462            })
1463        );
1464        let a = AoiSpec {
1465            id: "a".into(),
1466            window: Some([10, 0, 1, 1]),
1467            bounds: None,
1468            crs_epsg: None,
1469        };
1470        assert_eq!(aoi_window(&a, &g), None);
1471    }
1472}