1use crate::MapError;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::fs;
6use std::io::BufReader;
7use std::path::{Path, PathBuf};
8
9pub const SESSION_SCHEMA: &str = "ras2fim-batch-session/1";
10pub const MAX_JSON_BYTES: u64 = 512 * 1024 * 1024;
11pub const MAX_BINARY_BYTES: u64 = 8 * 1024 * 1024 * 1024;
12pub const MAX_PROFILE_PIXELS: u64 = 1 << 31;
13pub const MAX_THREADS: usize = 64;
14
15#[derive(Deserialize, Debug)]
16#[serde(deny_unknown_fields)]
17pub struct SessionRequest {
18 pub schema: String,
19 pub crs_epsg: u32,
21 pub terrain: TerrainSpec,
22 pub budgets: Budgets,
23 pub products: Vec<Product>,
24 pub profiles: Vec<ProfileSpec>,
25 pub aois: Vec<AoiSpec>,
26 pub error_policy: ErrorPolicy,
27}
28
29#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
30#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
31pub enum TerrainSpec {
32 CallerBuffer,
34 NativeGeotiff {
36 path: String,
37 query: QueryConvention,
38 nodata_policy: NodataPolicy,
39 crs_identity: CrsIdentity,
40 },
41}
42
43#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
45#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
46pub enum CrsIdentity {
47 Epsg,
49 CallerVerifiedWkt { fnv1a64: String },
53}
54
55#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum QueryConvention {
58 PixelCenter,
59 QueryPoint,
60}
61
62#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum NodataPolicy {
65 Dry,
66 RawValue,
67}
68
69#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
70#[serde(rename_all = "snake_case")]
71pub enum ErrorPolicy {
72 IsolateProfile,
74 FailFast,
76}
77
78#[derive(Deserialize, Serialize, Debug, Clone)]
79#[serde(deny_unknown_fields)]
80pub struct Budgets {
81 pub threads: usize,
82 pub max_cache_bytes: u64,
84 pub max_inflight_profiles: usize,
86 pub max_profile_bytes: u64,
88 pub output_tile: usize,
90 pub max_tiles_in_flight: usize,
93 #[serde(default)]
98 pub max_session_bytes: Option<u64>,
99}
100
101#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
102#[serde(rename_all = "snake_case")]
103pub enum Product {
104 RawWse,
105 Wse,
106 Depth,
107 SimplexId,
108 Ground,
109}
110
111impl Product {
112 pub fn name(self) -> &'static str {
113 match self {
114 Product::RawWse => "raw_wse",
115 Product::Wse => "wse",
116 Product::Depth => "depth",
117 Product::SimplexId => "simplex_id",
118 Product::Ground => "ground",
119 }
120 }
121 pub fn dtype(self) -> &'static str {
122 match self {
123 Product::SimplexId => "<i8",
124 _ => "<f8",
125 }
126 }
127}
128
129#[derive(Deserialize, Debug, Clone)]
130#[serde(deny_unknown_fields)]
131pub struct ProfileSpec {
132 pub id: String,
133 pub source: ProfileSource,
134}
135
136#[derive(Deserialize, Debug, Clone)]
137#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
138pub enum ProfileSource {
139 LegacyRequest { path: String },
143 Inline {
144 samples: Vec<[f64; 3]>,
145 simplices: Vec<[usize; 3]>,
146 grid: GridSpec,
147 #[serde(default)]
148 mask: Option<Vec<bool>>,
149 #[serde(default)]
150 mask_all_wet: bool,
151 #[serde(default)]
152 terrain: Option<Vec<Option<f64>>>,
153 },
154 Binary {
157 samples_f64: String,
158 simplices_u32: String,
159 grid: GridSpec,
160 #[serde(default)]
161 mask_u8: Option<String>,
162 #[serde(default)]
163 mask_all_wet: bool,
164 #[serde(default)]
165 terrain_f64: Option<String>,
166 },
167}
168
169#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
170#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
171pub enum GridSpec {
172 Explicit {
174 width: usize,
175 height: usize,
176 x0: f64,
177 y0: f64,
178 dx: f64,
179 dy: f64,
180 },
181 TerrainAligned {
184 col: usize,
185 row: usize,
186 width: usize,
187 height: usize,
188 },
189}
190
191#[derive(Deserialize, Debug, Clone)]
192#[serde(deny_unknown_fields)]
193pub struct LegacyGrid {
194 pub width: usize,
195 pub height: usize,
196 pub x0: f64,
197 pub y0: f64,
198 pub dx: f64,
199 pub dy: f64,
200}
201
202#[derive(Deserialize, Debug)]
203#[serde(deny_unknown_fields)]
204struct LegacyRequest {
205 samples: Vec<[f64; 3]>,
206 grid: LegacyGrid,
207 mask: Vec<bool>,
208 terrain: Vec<Option<f64>>,
209 simplices: Option<Vec<[usize; 3]>>,
210 #[serde(default)]
211 allow_native_triangulation: bool,
212 #[allow(dead_code)]
213 threads: usize,
214 #[allow(dead_code)]
215 repeats: usize,
216}
217
218#[derive(Deserialize, Serialize, Debug, Clone)]
219#[serde(deny_unknown_fields)]
220pub struct AoiSpec {
221 pub id: String,
222 #[serde(default)]
224 pub window: Option<[u64; 4]>,
225 #[serde(default)]
227 pub bounds: Option<[f64; 4]>,
228 #[serde(default)]
229 pub crs_epsg: Option<u32>,
230}
231
232pub struct ProfileData {
234 pub samples: Vec<[f64; 3]>,
235 pub simplices: Vec<[usize; 3]>,
236 pub grid: GridSpec,
237 pub mask: MaskData,
238 pub terrain: Option<Vec<f64>>,
239 pub legacy_terrain_ignored: bool,
240 pub source_bytes: u64,
241}
242
243pub enum MaskData {
244 AllWet,
245 Bits(Vec<bool>),
246}
247
248const WINDOWS_RESERVED: [&str; 4] = ["CON", "PRN", "AUX", "NUL"];
250
251fn windows_reserved(id: &str) -> bool {
252 let stem = id.split('.').next().unwrap_or(id).to_ascii_uppercase();
253 WINDOWS_RESERVED.contains(&stem.as_str())
254 || (stem.len() == 4
255 && (stem.starts_with("COM") || stem.starts_with("LPT"))
256 && stem.as_bytes()[3].is_ascii_digit())
257}
258
259pub fn validate_id(id: &str) -> Result<(), MapError> {
263 let charset = !id.is_empty()
264 && id.len() <= 64
265 && id
266 .chars()
267 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.');
268 if !charset {
269 return Err(MapError::Invalid(format!(
270 "id {id:?} must match [A-Za-z0-9_.-]{{1,64}}"
271 )));
272 }
273 if id.ends_with('.') {
274 return Err(MapError::Invalid(format!(
275 "id {id:?} must not end with '.' (Windows strips it; also rejects . and ..)"
276 )));
277 }
278 if windows_reserved(id) {
279 return Err(MapError::Invalid(format!(
280 "id {id:?} is a reserved Windows device name"
281 )));
282 }
283 Ok(())
284}
285
286pub fn id_key(id: &str) -> String {
288 id.to_ascii_lowercase()
289}
290
291impl SessionRequest {
292 pub fn load(path: &Path) -> Result<Self, MapError> {
293 let r: SessionRequest = read_json(path)?;
294 r.validate()?;
295 Ok(r)
296 }
297
298 pub fn validate(&self) -> Result<(), MapError> {
299 if self.schema != SESSION_SCHEMA {
300 return Err(MapError::Invalid(format!(
301 "schema must be {SESSION_SCHEMA:?}"
302 )));
303 }
304 let b = &self.budgets;
305 if b.threads == 0 || b.threads > MAX_THREADS {
306 return Err(MapError::Invalid(format!(
307 "threads must be 1..={MAX_THREADS}"
308 )));
309 }
310 if !(16..=4096).contains(&b.output_tile) {
311 return Err(MapError::Invalid("output_tile must be 16..=4096".into()));
312 }
313 if b.max_tiles_in_flight == 0 || b.max_tiles_in_flight > 65_536 {
314 return Err(MapError::Invalid(
315 "max_tiles_in_flight must be 1..=65536".into(),
316 ));
317 }
318 if b.max_inflight_profiles == 0 || b.max_inflight_profiles > 4096 {
319 return Err(MapError::Invalid(
320 "max_inflight_profiles must be 1..=4096".into(),
321 ));
322 }
323 if b.max_profile_bytes == 0 {
324 return Err(MapError::Invalid(
325 "max_profile_bytes must be positive".into(),
326 ));
327 }
328 if b.max_session_bytes == Some(0) {
329 return Err(MapError::Invalid(
330 "max_session_bytes must be positive when given".into(),
331 ));
332 }
333 let out_bytes = self.output_buffer_bound()?;
334 if out_bytes > 16 * 1024 * 1024 * 1024 {
335 return Err(MapError::Budget(format!(
336 "tile queue bound {out_bytes} bytes exceeds 16 GiB"
337 )));
338 }
339 if self.products.is_empty() {
340 return Err(MapError::Invalid("at least one product required".into()));
341 }
342 let unique: HashSet<_> = self.products.iter().collect();
343 if unique.len() != self.products.len() {
344 return Err(MapError::Invalid("duplicate product".into()));
345 }
346 if self.profiles.is_empty() || self.aois.is_empty() {
347 return Err(MapError::Invalid(
348 "at least one profile and one AOI required".into(),
349 ));
350 }
351 let mut ids = HashSet::new();
352 for p in &self.profiles {
353 validate_id(&p.id)?;
354 if !ids.insert(id_key(&p.id)) {
355 return Err(MapError::Invalid(format!(
356 "duplicate profile id {} (ids are compared case-insensitively)",
357 p.id
358 )));
359 }
360 }
361 let mut ids = HashSet::new();
362 for a in &self.aois {
363 validate_id(&a.id)?;
364 if !ids.insert(id_key(&a.id)) {
365 return Err(MapError::Invalid(format!(
366 "duplicate AOI id {} (ids are compared case-insensitively)",
367 a.id
368 )));
369 }
370 if a.window.is_some() && a.bounds.is_some() {
371 return Err(MapError::Invalid(format!(
372 "AOI {}: window and bounds are exclusive",
373 a.id
374 )));
375 }
376 if let Some(w) = a.window
377 && (w[2] == 0
378 || w[3] == 0
379 || w[0].checked_add(w[2]).is_none()
380 || w[1].checked_add(w[3]).is_none())
381 {
382 return Err(MapError::Invalid(format!("AOI {}: invalid window", a.id)));
383 }
384 if let Some(bd) = a.bounds
385 && (!bd.iter().all(|v| v.is_finite()) || bd[0] >= bd[2] || bd[1] >= bd[3])
386 {
387 return Err(MapError::Invalid(format!(
388 "AOI {}: bounds must be finite, min < max",
389 a.id
390 )));
391 }
392 if a.crs_epsg.is_some_and(|e| e != self.crs_epsg) {
393 return Err(MapError::Unsupported(format!(
394 "AOI {} CRS EPSG:{} differs from session EPSG:{}; no reprojection",
395 a.id,
396 a.crs_epsg.unwrap_or_default(),
397 self.crs_epsg
398 )));
399 }
400 }
401 Ok(())
402 }
403
404 pub fn effective_tiles_in_flight(&self) -> usize {
407 match self.budgets.max_tiles_in_flight {
408 1 => 1,
409 m => 2 * (m / 2),
410 }
411 }
412
413 pub fn output_buffer_bound(&self) -> Result<u64, MapError> {
416 let px = (self.budgets.output_tile as u64).pow(2);
417 (self.effective_tiles_in_flight() as u64)
418 .checked_mul(px)
419 .and_then(|v| v.checked_mul(8 * 5))
420 .ok_or_else(|| MapError::Budget("tile queue bound overflows".into()))
421 }
422
423 pub fn inline_payload_bytes(&self) -> u64 {
426 self.profiles
427 .iter()
428 .map(|p| match &p.source {
429 ProfileSource::Inline {
430 samples,
431 simplices,
432 mask,
433 terrain,
434 ..
435 } => {
436 24 * samples.len() as u64
437 + 8 * 3 * simplices.len() as u64
438 + mask.as_ref().map_or(0, |m| m.len() as u64)
439 + 16 * terrain.as_ref().map_or(0, |t| t.len() as u64)
440 }
441 _ => 0,
442 })
443 .sum()
444 }
445}
446
447#[derive(Serialize, Clone, Debug, Default, PartialEq)]
449pub struct ProfileEstimate {
450 pub basis: &'static str,
452 pub resident_bytes: u64,
456 pub transient_bytes: u64,
459 pub source_bytes: u64,
461}
462
463impl ProfileEstimate {
464 pub fn peak_bytes(&self) -> u64 {
465 self.resident_bytes.saturating_add(self.transient_bytes)
466 }
467}
468
469pub const LEGACY_JSON_PEAK_FACTOR: u64 = 64;
474pub const READ_CHUNK_BYTES: u64 = 1 << 20;
476
477fn file_len(path: &Path) -> Result<u64, MapError> {
478 Ok(fs::metadata(path)
479 .map_err(|e| MapError::Io(format!("{}: {e}", path.display())))?
480 .len())
481}
482
483fn binary_len(path: &Path, item: u64) -> Result<u64, MapError> {
485 let len = file_len(path)?;
486 if len > MAX_BINARY_BYTES {
487 return Err(MapError::Budget(format!(
488 "{} exceeds binary limit",
489 path.display()
490 )));
491 }
492 if len % item != 0 {
493 return Err(MapError::Invalid(format!(
494 "{}: length {len} is not a multiple of {item}",
495 path.display()
496 )));
497 }
498 Ok(len)
499}
500
501fn read_records<const N: usize, T>(
504 path: &Path,
505 len: u64,
506 mut decode: impl FnMut(&[u8; N]) -> Result<T, MapError>,
507) -> Result<Vec<T>, MapError> {
508 use std::io::Read;
509 let io = |e: std::io::Error| MapError::Io(format!("{}: {e}", path.display()));
510 let count = (len / N as u64) as usize;
511 let mut f = fs::File::open(path).map_err(io)?;
512 let mut out = Vec::with_capacity(count);
513 let per_chunk = (READ_CHUNK_BYTES as usize / N).max(1);
514 let mut buf = vec![0u8; per_chunk * N];
515 while out.len() < count {
516 let n = (count - out.len()).min(per_chunk) * N;
517 f.read_exact(&mut buf[..n]).map_err(io)?;
518 for c in buf[..n].as_chunks::<N>().0 {
519 out.push(decode(c)?);
520 }
521 }
522 if f.read(&mut [0u8; 1]).map_err(io)? != 0 {
523 return Err(MapError::Invalid(format!(
524 "{}: file grew while reading",
525 path.display()
526 )));
527 }
528 Ok(out)
529}
530
531pub fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, MapError> {
532 let len = fs::metadata(path)
533 .map_err(|e| MapError::Io(format!("{}: {e}", path.display())))?
534 .len();
535 if len > MAX_JSON_BYTES {
536 return Err(MapError::Budget(format!(
537 "{} exceeds 512 MiB JSON limit",
538 path.display()
539 )));
540 }
541 let f = fs::File::open(path).map_err(|e| MapError::Io(format!("{}: {e}", path.display())))?;
542 serde_json::from_reader(BufReader::new(f))
543 .map_err(|e| MapError::Invalid(format!("{}: {e}", path.display())))
544}
545
546pub fn resolve(base: &Path, p: &str) -> PathBuf {
547 let p = Path::new(p);
548 if p.is_absolute() {
549 p.to_path_buf()
550 } else {
551 base.join(p)
552 }
553}
554
555impl GridSpec {
556 pub fn dims(&self) -> (usize, usize) {
557 match *self {
558 GridSpec::Explicit { width, height, .. }
559 | GridSpec::TerrainAligned { width, height, .. } => (width, height),
560 }
561 }
562 pub fn pixels(&self) -> Result<u64, MapError> {
563 let (w, h) = self.dims();
564 let n = (w as u64)
565 .checked_mul(h as u64)
566 .ok_or_else(|| MapError::Invalid("grid dimensions overflow".into()))?;
567 if n == 0 || n > MAX_PROFILE_PIXELS {
568 return Err(MapError::Budget(format!(
569 "grid must contain 1..={MAX_PROFILE_PIXELS} pixels"
570 )));
571 }
572 Ok(n)
573 }
574}
575
576impl ProfileSource {
577 pub fn load(&self, base: &Path, native: bool) -> Result<ProfileData, MapError> {
579 match self {
580 ProfileSource::LegacyRequest { path } => {
581 let path = resolve(base, path);
582 let bytes = fs::metadata(&path)?.len();
583 let r: LegacyRequest = read_json(&path)?;
584 let simplices = r.simplices.ok_or_else(|| {
585 MapError::Unsupported(
586 "legacy request has no supplied simplices; native triangulation is not available in this prototype".into(),
587 )
588 })?;
589 let _ = r.allow_native_triangulation;
590 let g = r.grid;
591 let terrain = if native {
592 None
593 } else {
594 Some(r.terrain.iter().map(|v| v.unwrap_or(f64::NAN)).collect())
595 };
596 Ok(ProfileData {
597 samples: r.samples,
598 simplices,
599 grid: GridSpec::Explicit {
600 width: g.width,
601 height: g.height,
602 x0: g.x0,
603 y0: g.y0,
604 dx: g.dx,
605 dy: g.dy,
606 },
607 mask: MaskData::Bits(r.mask),
608 terrain,
609 legacy_terrain_ignored: native,
610 source_bytes: bytes,
611 })
612 }
613 ProfileSource::Inline {
614 samples,
615 simplices,
616 grid,
617 mask,
618 mask_all_wet,
619 terrain,
620 } => Ok(ProfileData {
621 samples: samples.clone(),
622 simplices: simplices.clone(),
623 grid: grid.clone(),
624 mask: mask_data(mask.clone(), *mask_all_wet)?,
625 terrain: terrain
626 .as_ref()
627 .map(|t| t.iter().map(|v| v.unwrap_or(f64::NAN)).collect()),
628 legacy_terrain_ignored: false,
629 source_bytes: 0,
630 }),
631 ProfileSource::Binary {
632 samples_f64,
633 simplices_u32,
634 grid,
635 mask_u8,
636 mask_all_wet,
637 terrain_f64,
638 } => {
639 let lens = binary_lens(self, base)?;
641 let f64_at = |c: &[u8], i: usize| {
642 let mut b = [0u8; 8];
643 b.copy_from_slice(&c[i * 8..i * 8 + 8]);
644 f64::from_le_bytes(b)
645 };
646 let samples = read_records::<24, _>(&resolve(base, samples_f64), lens[0], |c| {
647 Ok([f64_at(c, 0), f64_at(c, 1), f64_at(c, 2)])
648 })?;
649 let simplices =
650 read_records::<12, _>(&resolve(base, simplices_u32), lens[1], |c| {
651 let u = |i: usize| {
652 let mut b = [0u8; 4];
653 b.copy_from_slice(&c[i * 4..i * 4 + 4]);
654 u32::from_le_bytes(b) as usize
655 };
656 Ok([u(0), u(1), u(2)])
657 })?;
658 let mask = match mask_u8 {
659 Some(p) => Some(read_records::<1, _>(
660 &resolve(base, p),
661 lens[2],
662 |c| match c[0] {
663 0 => Ok(false),
664 1 => Ok(true),
665 v => Err(MapError::Invalid(format!("mask byte {v} is not 0/1"))),
666 },
667 )?),
668 None => None,
669 };
670 let terrain = match terrain_f64 {
671 Some(p) => Some(read_records::<8, _>(&resolve(base, p), lens[3], |c| {
672 Ok(f64::from_le_bytes(*c))
673 })?),
674 None => None,
675 };
676 Ok(ProfileData {
677 samples,
678 simplices,
679 grid: grid.clone(),
680 mask: mask_data(mask, *mask_all_wet)?,
681 terrain,
682 legacy_terrain_ignored: false,
683 source_bytes: lens.iter().sum(),
684 })
685 }
686 }
687 }
688
689 pub fn estimate(&self, base: &Path) -> Result<ProfileEstimate, MapError> {
693 let per_simplex = crate::interp::prepared_bytes_per_simplex();
694 let tri = crate::interp::triangle_bytes();
695 let shape = |n: u64, m: u64, mask: u64, terrain_px: u64| {
698 let resident = m
699 .saturating_mul(per_simplex)
700 .saturating_add(mask)
701 .saturating_add(terrain_px.saturating_mul(8));
702 let transient = n
703 .saturating_mul(24)
704 .saturating_add(m.saturating_mul(24))
705 .saturating_add(m.saturating_mul(tri).saturating_mul(2));
706 (resident, transient)
707 };
708 match self {
709 ProfileSource::LegacyRequest { path } => {
710 let len = file_len(&resolve(base, path))?;
711 if len > MAX_JSON_BYTES {
712 return Err(MapError::Budget(format!(
713 "{path} exceeds 512 MiB JSON limit"
714 )));
715 }
716 let peak = len.saturating_mul(LEGACY_JSON_PEAK_FACTOR);
717 Ok(ProfileEstimate {
718 basis: "legacy_json_length_x64_heuristic",
719 resident_bytes: peak / 2,
720 transient_bytes: peak - peak / 2,
721 source_bytes: len,
722 })
723 }
724 ProfileSource::Inline {
725 samples,
726 simplices,
727 mask,
728 terrain,
729 ..
730 } => {
731 let (resident, transient) = shape(
732 samples.len() as u64,
733 simplices.len() as u64,
734 mask.as_ref().map_or(0, |m| m.len() as u64),
735 terrain.as_ref().map_or(0, |t| t.len() as u64),
736 );
737 Ok(ProfileEstimate {
738 basis: "inline_exact_lengths",
739 resident_bytes: resident,
740 transient_bytes: transient,
741 source_bytes: 0,
742 })
743 }
744 ProfileSource::Binary { .. } => {
745 let lens = binary_lens(self, base)?;
746 let (resident, transient) = shape(lens[0] / 24, lens[1] / 12, lens[2], lens[3] / 8);
747 Ok(ProfileEstimate {
748 basis: "binary_file_lengths",
749 resident_bytes: resident,
750 transient_bytes: transient.saturating_add(READ_CHUNK_BYTES),
751 source_bytes: lens.iter().sum(),
752 })
753 }
754 }
755 }
756}
757
758fn binary_lens(src: &ProfileSource, base: &Path) -> Result<[u64; 4], MapError> {
761 let ProfileSource::Binary {
762 samples_f64,
763 simplices_u32,
764 grid,
765 mask_u8,
766 terrain_f64,
767 ..
768 } = src
769 else {
770 return Err(MapError::Internal(
771 "binary_lens on a non-binary source".into(),
772 ));
773 };
774 let px = grid.pixels()?;
775 let s = binary_len(&resolve(base, samples_f64), 24)?;
776 let t = binary_len(&resolve(base, simplices_u32), 12)?;
777 let m = match mask_u8 {
778 Some(p) => {
779 let len = binary_len(&resolve(base, p), 1)?;
780 if len != px {
781 return Err(MapError::Invalid(format!(
782 "mask has {len} values, grid has {px}"
783 )));
784 }
785 len
786 }
787 None => 0,
788 };
789 let g = match terrain_f64 {
790 Some(p) => {
791 let len = binary_len(&resolve(base, p), 8)?;
792 if len / 8 != px {
793 return Err(MapError::Invalid(format!(
794 "terrain buffer has {} values, grid has {px}",
795 len / 8
796 )));
797 }
798 len
799 }
800 None => 0,
801 };
802 Ok([s, t, m, g])
803}
804
805fn mask_data(mask: Option<Vec<bool>>, all: bool) -> Result<MaskData, MapError> {
806 match (mask, all) {
807 (Some(m), false) => Ok(MaskData::Bits(m)),
808 (None, true) => Ok(MaskData::AllWet),
809 _ => Err(MapError::Invalid(
810 "exactly one of mask / mask_all_wet must be given".into(),
811 )),
812 }
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818
819 #[test]
820 fn ids_are_path_safe() {
821 assert!(validate_id("wsel_max_1").is_ok());
822 assert!(validate_id("..").is_err());
823 assert!(validate_id("a/b").is_err());
824 assert!(validate_id("").is_err());
825 }
826
827 #[test]
828 fn ids_are_windows_safe() {
829 for bad in [
830 ".", "..", "a.", "reach.", "CON", "con", "Nul", "aux.txt", "PRN.x.y", "COM1", "com9",
831 "LPT1", "lpt3.bin", "COM0",
832 ] {
833 assert!(validate_id(bad).is_err(), "{bad}");
834 }
835 for ok in [
836 "CONX",
837 "console",
838 "COM10",
839 "LPT",
840 "nul_x",
841 "a.b",
842 ".hidden",
843 "wsel_max_10",
844 "COMA",
845 ] {
846 assert!(validate_id(ok).is_ok(), "{ok}");
847 }
848 assert_eq!(id_key("Reach_A"), id_key("reach_a"));
849 }
850
851 fn tiny(id: &str) -> serde_json::Value {
852 serde_json::json!({"id": id, "source": {"kind": "inline", "samples": [[0,0,1],[1,0,1],[0,1,1]],
853 "simplices": [[0,1,2]], "grid": {"kind": "explicit", "width": 2, "height": 2, "x0": 0, "y0": 0, "dx": 0.4, "dy": 0.4},
854 "mask_all_wet": true, "terrain": [0, 0, 0, 0]}})
855 }
856
857 fn session(profiles: Vec<serde_json::Value>, aois: serde_json::Value) -> SessionRequest {
858 serde_json::from_value(serde_json::json!({"schema": SESSION_SCHEMA, "crs_epsg": 2277,
859 "terrain": {"mode": "caller_buffer"},
860 "budgets": {"threads": 1, "max_cache_bytes": 0, "max_inflight_profiles": 1, "max_profile_bytes": 1 << 20,
861 "output_tile": 16, "max_tiles_in_flight": 1},
862 "products": ["depth"], "profiles": profiles, "aois": aois, "error_policy": "isolate_profile"}))
863 .unwrap()
864 }
865
866 #[test]
867 fn case_colliding_ids_are_rejected() {
868 let s = session(
869 vec![tiny("A"), tiny("a")],
870 serde_json::json!([{"id": "full"}]),
871 );
872 assert!(
873 s.validate()
874 .unwrap_err()
875 .to_string()
876 .contains("case-insensitive")
877 );
878 let s = session(
879 vec![tiny("A")],
880 serde_json::json!([{"id": "R"}, {"id": "r"}]),
881 );
882 assert!(s.validate().is_err());
883 let s = session(vec![tiny("A")], serde_json::json!([{"id": "NUL"}]));
884 assert!(s.validate().is_err());
885 let s = session(
886 vec![tiny("A"), tiny("B")],
887 serde_json::json!([{"id": "full"}]),
888 );
889 assert!(s.validate().is_ok());
890 assert_eq!(s.effective_tiles_in_flight(), 1);
891 assert_eq!(s.output_buffer_bound().unwrap(), 16 * 16 * 40);
892 }
893
894 #[test]
895 fn effective_in_flight_matches_pipeline() {
896 let mut s = session(vec![tiny("A")], serde_json::json!([{"id": "full"}]));
897 for (m, e) in [(1, 1), (2, 2), (3, 2), (5, 4), (16, 16)] {
898 s.budgets.max_tiles_in_flight = m;
899 assert_eq!(s.effective_tiles_in_flight(), e, "{m}");
900 }
901 }
902
903 #[test]
904 fn binary_estimate_needs_no_read_and_checks_lengths() {
905 let d = std::env::temp_dir().join(format!("ras2fim-model-est-{}", std::process::id()));
906 let _ = fs::remove_dir_all(&d);
907 fs::create_dir_all(&d).unwrap();
908 fs::write(d.join("s.f64"), vec![0u8; 24_000]).unwrap();
910 fs::write(d.join("t.u32"), vec![0u8; 24_000]).unwrap();
911 fs::write(d.join("m.u8"), vec![1u8; 5]).unwrap();
912 let src = |mask: bool| -> ProfileSource {
913 serde_json::from_value(serde_json::json!({"kind": "binary", "samples_f64": "s.f64",
914 "simplices_u32": "t.u32", "grid": {"kind": "explicit", "width": 2, "height": 2,
915 "x0": 0, "y0": 0, "dx": 1, "dy": 1}, "mask_u8": if mask {Some("m.u8")} else {None},
916 "mask_all_wet": !mask}))
917 .unwrap()
918 };
919 let e = src(false).estimate(&d).unwrap();
920 let tri = crate::interp::triangle_bytes();
921 assert_eq!(
922 e.resident_bytes,
923 2000 * crate::interp::prepared_bytes_per_simplex()
924 );
925 assert_eq!(
926 e.transient_bytes,
927 1000 * 24 + 2000 * 24 + 2 * 2000 * tri + READ_CHUNK_BYTES
928 );
929 assert_eq!(e.source_bytes, 48_000);
930 let err = src(true).estimate(&d).unwrap_err();
931 assert!(
932 err.to_string().contains("mask has 5 values, grid has 4"),
933 "{err}"
934 );
935 let _ = fs::remove_dir_all(&d);
936 }
937
938 #[test]
939 fn grid_pixel_limits() {
940 let g = GridSpec::Explicit {
941 width: usize::MAX,
942 height: 2,
943 x0: 0.,
944 y0: 0.,
945 dx: 1.,
946 dy: 1.,
947 };
948 assert!(g.pixels().is_err());
949 let g = GridSpec::TerrainAligned {
950 col: 0,
951 row: 0,
952 width: 0,
953 height: 2,
954 };
955 assert!(g.pixels().is_err());
956 }
957
958 #[test]
959 fn mask_exclusive() {
960 assert!(mask_data(None, false).is_err());
961 assert!(mask_data(Some(vec![true]), true).is_err());
962 }
963}