1use 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 #[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 #[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 #[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
124fn 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 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 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 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 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 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 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
605pub const MAX_PLAN_TILES_PER_PROFILE: u64 = 1 << 22;
608const PLAN_BYTES_PER_ITEM: u64 = std::mem::size_of::<Item>() as u64 + 48 + 16;
610
611fn 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#[derive(Serialize, Clone, Debug, Default)]
620struct StaticTerms {
621 cache_resident_bound: u64,
623 decoder_transient_estimate: u64,
625 output_tile_buffers_bound: u64,
627 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#[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
661fn 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
682fn 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
691fn 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
733pub 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 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#[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 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 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 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 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 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 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 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 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 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}