1use crate::{
5 MapError,
6 model::{self, CrsIdentity, NodataPolicy, QueryConvention},
7 sink,
8 terrain::NativeTerrain,
9};
10use rayon::prelude::*;
11use serde::Deserialize;
12use serde_json::{Value, json};
13use std::{
14 collections::HashSet,
15 fs::{self, File},
16 io::{BufWriter, Write},
17 path::Path,
18 time::Instant,
19};
20
21pub const SCHEMA: &str = "ras2fim-horizontal-request/1";
22#[derive(Clone, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Request {
25 pub schema: String,
26 pub terrain: String,
27 pub epsg: u32,
28 pub crs_identity: CrsIdentity,
29 pub terrain_transform: [f64; 6],
30 pub window: [usize; 4], #[serde(default)]
32 pub owner_file: String, #[serde(default)]
34 pub geometry_file: Option<String>,
35 #[serde(default)]
36 pub aoi_file: Option<String>,
37 pub cell_count: usize,
38 pub profiles: Vec<Profile>,
39 pub min_depth: f64,
40 pub threads: usize,
41 pub max_working_bytes: u64,
42 #[serde(default)]
43 pub stream_outputs: bool,
44 #[serde(default)]
46 pub terrain_scope: Option<crate::terrain_scope::TerrainScope>,
47}
48#[derive(Clone, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct Profile {
51 pub id: String,
52 pub values_file: String,
53}
54
55pub fn method_record() -> Value {
56 json!({"method_id":"horizontal-cell-constant/2", "backend":"rust", "stream_outputs":true,
57 "arithmetic":"native float32 WSE >= terrain + plot tolerance; float32 subtraction; optional strict positive depth cutoff",
58 "native_plot_tolerance":0.001_f32,
59 "ownership":"terrio pixel-centre polygons or supplied u32 labels; 0 dry; cell position + 1",
60 "native_ownership":true,"aoi_mask":crate::aoi::METHOD,
61 "terrain":"same-CRS north-up square float32 GeoTIFF; native terrio block reads once per window",
62 "reuse":"terrain and ownership shared across all profiles; pixel parallelism inside bounded Rayon pool",
63 "terrain_scope":crate::terrain_scope::METHOD_ID,
64 "scope":"2D horizontal cells only; no native sloping, 1D or structure renderer"})
65}
66fn bytes(count: usize, width: usize) -> Result<u64, MapError> {
67 count
68 .checked_mul(width)
69 .and_then(|v| u64::try_from(v).ok())
70 .ok_or_else(|| MapError::Budget("horizontal size overflow".into()))
71}
72fn read_bytes(path: &Path, expected: u64) -> Result<Vec<u8>, MapError> {
73 if fs::metadata(path)?.len() != expected {
74 return Err(MapError::Invalid(format!(
75 "{}: expected {expected} bytes",
76 path.display()
77 )));
78 }
79 let data = fs::read(path)?;
80 if data.len() as u64 != expected {
81 return Err(MapError::Invalid("input size changed".into()));
82 }
83 Ok(data)
84}
85pub(crate) fn read_cell_values(path: &Path, count: usize) -> Result<Vec<f32>, MapError> {
87 read_values(path, count)
88}
89fn read_values(path: &Path, count: usize) -> Result<Vec<f32>, MapError> {
90 let raw = read_bytes(path, bytes(count, 4)?)?;
91 let values: Vec<_> = raw
92 .as_chunks::<4>()
93 .0
94 .iter()
95 .map(|v| f32::from_le_bytes(*v))
96 .collect();
97 if values.iter().any(|v| v.is_infinite()) {
98 return Err(MapError::Invalid(
99 "horizontal WSE must be finite or NaN".into(),
100 ));
101 }
102 Ok(values)
103}
104pub(crate) fn write_f32(writer: &mut impl Write, values: &[f32]) -> Result<(), MapError> {
105 let mut buffer = vec![0_u8; 65536];
106 for chunk in values.chunks(16384) {
107 for (value, bytes) in chunk.iter().zip(buffer.as_chunks_mut::<4>().0.iter_mut()) {
108 bytes.copy_from_slice(&value.to_le_bytes());
109 }
110 writer.write_all(&buffer[..chunk.len() * 4])?;
111 }
112 Ok(())
113}
114pub(crate) fn write_values(path: &Path, values: &[f32]) -> Result<(), MapError> {
115 let partial = path.with_extension("bin.partial");
116 let mut writer = BufWriter::new(File::create(&partial)?);
117 write_f32(&mut writer, values)?;
118 writer.flush()?;
119 writer.get_ref().sync_all()?;
120 fs::rename(partial, path)?;
121 Ok(())
122}
123fn evaluate(
125 owner: &[u32],
126 ground: &[f32],
127 values: &[f32],
128 threshold: f32,
129 depth: &mut [f32],
130 wse: &mut [f32],
131) -> Result<(), MapError> {
132 depth
133 .par_iter_mut()
134 .zip(wse.par_iter_mut())
135 .enumerate()
136 .try_for_each(|(i, (d, w))| {
137 let label = owner[i] as usize;
138 if label == 0 || !ground[i].is_finite() {
139 return Ok(());
140 }
141 let value = values[label - 1];
142 if value.is_nan() {
143 return Ok(());
144 }
145 let delta = value - ground[i];
146 if delta.is_infinite() {
147 return Err(MapError::Invalid("float32 depth overflow".into()));
148 }
149 if value >= ground[i] + 0.001_f32 && (threshold == 0.0 || delta > threshold) {
154 *d = delta;
155 *w = value;
156 }
157 Ok(())
158 })
159}
160#[inline]
163pub(crate) fn plot(value: f32, ground: f32) -> Result<Option<f32>, MapError> {
164 if !ground.is_finite() || value.is_nan() {
165 return Ok(None);
166 }
167 let delta = value - ground;
168 if delta.is_infinite() {
169 return Err(MapError::Invalid("float32 depth overflow".into()));
170 }
171 Ok((value >= ground + 0.001_f32).then_some(delta))
172}
173
174pub fn run(req: &Request, base: &Path, out: &Path) -> Result<Value, MapError> {
175 run_with(req, base, out, None)
176}
177
178#[cfg(test)]
179pub(crate) fn evaluate_for_test(
180 owner: &[u32],
181 ground: &[f32],
182 values: &[f32],
183 depth: &mut [f32],
184 wse: &mut [f32],
185) {
186 evaluate(owner, ground, values, 0.0, depth, wse).unwrap();
187}
188
189pub(crate) trait Consumer: Send {
192 fn prepare(&mut self, owner: &[u32], ground: &[f32]) -> Result<(), MapError>;
195 fn profile(&mut self, position: usize, values: &[f32]) -> Result<(), MapError>;
197}
198
199pub(crate) fn run_with(
203 req: &Request,
204 base: &Path,
205 out: &Path,
206 mut consumer: Option<&mut dyn Consumer>,
207) -> Result<Value, MapError> {
208 let started = Instant::now();
209 if req.schema != SCHEMA
210 || !(1..=64).contains(&req.threads)
211 || req.profiles.is_empty()
212 || req.epsg == 0
213 || req.cell_count == 0
214 || req.cell_count >= u32::MAX as usize
215 || !req.min_depth.is_finite()
216 || req.min_depth < 0.0
217 {
218 return Err(MapError::Invalid(
219 "invalid horizontal schema, profiles, cells, threshold or thread count".into(),
220 ));
221 }
222 let [col, row, width, height] = req.window;
223 let count = width
224 .checked_mul(height)
225 .filter(|n| *n > 0 && *n <= model::MAX_PROFILE_PIXELS as usize)
226 .ok_or_else(|| MapError::Budget("horizontal window empty or too large".into()))?;
227 if let Some(scope) = &req.terrain_scope {
228 scope.validate()?;
229 }
230 let mut ids = HashSet::new();
231 for p in &req.profiles {
232 model::validate_id(&p.id)?;
233 if !ids.insert(model::id_key(&p.id)) {
234 return Err(MapError::Invalid("duplicate profile id".into()));
235 }
236 }
237 let cache = 64_u64 << 20;
240 let scope_bytes = if req.terrain_scope.is_some() {
241 bytes(width + 2, height + 2)?
242 } else {
243 0
244 };
245 let planned = bytes(count, 40)?
246 .checked_add(scope_bytes)
247 .ok_or_else(|| MapError::Budget("size overflow".into()))?
248 .checked_add(bytes(req.cell_count, 64)?)
249 .and_then(|v| v.checked_add(cache))
250 .ok_or_else(|| MapError::Budget("size overflow".into()))?;
251 if planned > req.max_working_bytes {
252 return Err(MapError::Budget(
253 "horizontal array plan exceeds budget".into(),
254 ));
255 }
256 let terrain_path = model::resolve(base, &req.terrain);
257 let terrain = NativeTerrain::open(
258 terrain_path
259 .to_str()
260 .ok_or_else(|| MapError::Invalid("terrain path not UTF-8".into()))?,
261 req.epsg,
262 &req.crs_identity,
263 QueryConvention::PixelCenter,
264 NodataPolicy::Dry,
265 cache,
266 )?;
267 if terrain.nodata.is_none()
268 || terrain.gt != req.terrain_transform
269 || (terrain.gt[1] + terrain.gt[5]).abs() > terrain.gt[1].abs() * 1e-12
270 || col.checked_add(width).is_none_or(|v| v > terrain.width)
271 || row.checked_add(height).is_none_or(|v| v > terrain.height)
272 {
273 return Err(MapError::Unsupported(
274 "horizontal terrain identity, nodata, square grid or window mismatch".into(),
275 ));
276 }
277 if terrain.max_block_bytes() > cache {
278 return Err(MapError::Unsupported(
279 "terrain block exceeds 64 MiB; export tiled terrain".into(),
280 ));
281 }
282 let geometry_bytes = [req.geometry_file.as_ref(), req.aoi_file.as_ref()]
283 .into_iter()
284 .flatten()
285 .try_fold(0_u64, |sum, name| -> Result<u64, MapError> {
286 sum.checked_add(fs::metadata(model::resolve(base, name))?.len())
287 .ok_or_else(|| MapError::Budget("geometry size overflow".into()))
288 })?;
289 let geometry_plan = geometry_bytes
290 .checked_mul(12)
291 .ok_or_else(|| MapError::Budget("geometry size overflow".into()))?;
292 let admitted = planned
293 .checked_add(geometry_plan)
294 .ok_or_else(|| MapError::Budget("geometry plan overflow".into()))?
295 .checked_add(crate::aoi::scratch_bytes(
296 req.aoi_file
297 .as_ref()
298 .map(|p| model::resolve(base, p))
299 .as_deref(),
300 req.threads,
301 )?)
302 .ok_or_else(|| MapError::Budget("AOI plan overflow".into()))?
303 .checked_add(terrain.decoder_transient_bytes_per_load())
304 .ok_or_else(|| MapError::Budget("terrain transient size overflow".into()))?;
305 if admitted > req.max_working_bytes {
306 return Err(MapError::Budget(
307 "terrain decode plan exceeds budget".into(),
308 ));
309 }
310 let owner_path = model::resolve(base, &req.owner_file);
312 if req.geometry_file.is_some() == !req.owner_file.is_empty()
313 || (req.aoi_file.is_some() && req.geometry_file.is_none())
314 {
315 return Err(MapError::Invalid(
316 "supply geometry_file or owner_file, exclusively".into(),
317 ));
318 }
319 if req.geometry_file.is_none() && fs::metadata(&owner_path)?.len() != bytes(count, 4)? {
320 return Err(MapError::Invalid("owner length mismatch".into()));
321 }
322 for p in &req.profiles {
323 if fs::metadata(model::resolve(base, &p.values_file))?.len() != bytes(req.cell_count, 4)? {
324 return Err(MapError::Invalid(format!(
325 "{}: values length mismatch",
326 p.id
327 )));
328 }
329 }
330 fs::create_dir(out)?;
331 let mut report = json!({"schema":"ras2fim-horizontal-report/1", "status":"in_progress",
332 "method":method_record(), "window":req.window, "planned_bytes":admitted,
333 "threads":req.threads, "profiles":[]});
334 sink::write_json(&out.join("report.json"), &report)?;
335 let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(
336 || -> Result<(), MapError> {
337 let pool = rayon::ThreadPoolBuilder::new()
338 .num_threads(req.threads)
339 .build()
340 .map_err(|e| MapError::Internal(e.to_string()))?;
341 let owner_started = Instant::now();
342 let owner = if let Some(name) = &req.geometry_file {
343 let grid = terrio::ops::rasterize::Grid {
344 geo_transform: [
345 terrain.gt[0] + col as f64 * terrain.gt[1],
346 terrain.gt[1],
347 0.,
348 terrain.gt[3] + row as f64 * terrain.gt[5],
349 0.,
350 terrain.gt[5],
351 ],
352 cols: width,
353 rows: height,
354 crs: Default::default(),
355 };
356 let cells =
357 crate::ownership::load(&model::resolve(base, name), req.cell_count, false)?;
358 let mut labels = pool.install(|| crate::ownership::burn(cells, grid.clone()))?;
359 if let Some(mask) = &req.aoi_file {
360 let mask = pool.install(|| {
361 crate::aoi::mask(&model::resolve(base, mask), terrain.gt, req.window)
362 })?;
363 pool.install(|| {
364 labels
365 .par_iter_mut()
366 .zip(mask.par_iter())
367 .for_each(|(v, m)| {
368 if *m == 0 {
369 *v = 0
370 }
371 })
372 });
373 }
374 labels
375 } else {
376 let raw = read_bytes(&owner_path, bytes(count, 4)?)?;
377 raw.as_chunks::<4>()
378 .0
379 .iter()
380 .map(|v| u32::from_le_bytes(*v))
381 .collect::<Vec<_>>()
382 };
383 if owner.iter().any(|v| *v as usize > req.cell_count) {
384 return Err(MapError::Invalid("owner index exceeds cell table".into()));
385 }
386 report["ownership_prepare_seconds"] = owner_started.elapsed().as_secs_f64().into();
387 report["ownership_backend"] = if req.geometry_file.is_some() {
388 "terrio"
389 } else {
390 "supplied"
391 }
392 .into();
393 let terrain_started = Instant::now();
394 let mut ground = vec![f32::NAN; count];
395 let (bw, bh) = terrain.block;
396 for by in row / bh..=(row + height - 1) / bh {
397 for bx in col / bw..=(col + width - 1) / bw {
398 let block = terrain.get_block(bx, by)?;
399 let win = terrain.block_window(bx, by);
400 for r in row.max(win.y)..(row + height).min(win.y + win.height) {
401 for c in col.max(win.x)..(col + width).min(win.x + win.width) {
402 let v = block[(r - win.y) * win.width + c - win.x];
403 if let Some(value) = terrain.classify(v) {
404 if !value.is_finite() {
405 return Err(MapError::Terrain(
406 "unmasked infinite terrain".into(),
407 ));
408 }
409 ground[(r - row) * width + c - col] = value as f32;
410 }
411 }
412 }
413 }
414 }
415 report["terrain_prepare_seconds"] = terrain_started.elapsed().as_secs_f64().into();
416 if let Some(scope) = &req.terrain_scope {
417 let scoped =
418 crate::terrain_scope::check(scope, base, &terrain, req.window, &owner)?;
419 report["terrain_scope"] = scoped.record;
420 if !scoped.foreign.is_empty() {
423 return Err(MapError::Unsupported(format!(
424 "{} mapped pixels need terrain outside the top-priority source's interior; lower-priority sources and stitches are not qualified",
425 scoped.foreign.len()
426 )));
427 }
428 }
429 for (position, p) in req.profiles.iter().enumerate() {
430 let tick = Instant::now();
431 let dir = out.join(&p.id);
432 if let Some(consume) = consumer.as_mut() {
433 if position == 0 {
434 pool.install(|| consume.prepare(&owner, &ground))?;
435 }
436 let values =
437 read_values(&model::resolve(base, &p.values_file), req.cell_count)?;
438 pool.install(|| consume.profile(position, &values))?;
439 report["profiles"].as_array_mut().unwrap().push(
440 json!({"id":p.id,"status":"complete","seconds":tick.elapsed().as_secs_f64()}),
441 );
442 continue;
443 }
444 fs::create_dir(&dir)?;
445 let result = (|| -> Result<(), MapError> {
446 let values =
447 read_values(&model::resolve(base, &p.values_file), req.cell_count)?;
448 let mut depth = vec![f32::NAN; count];
449 let mut wse = vec![f32::NAN; count];
450 pool.install(|| {
451 evaluate(
452 &owner,
453 &ground,
454 &values,
455 req.min_depth as f32,
456 &mut depth,
457 &mut wse,
458 )
459 })?;
460 if req.stream_outputs {
461 let stdout = std::io::stdout();
462 let mut stream = BufWriter::with_capacity(65536, stdout.lock());
463 writeln!(
464 stream,
465 "{}",
466 json!({"id":p.id,"status":"complete","pixels":count})
467 )?;
468 write_f32(&mut stream, &depth)?;
469 write_f32(&mut stream, &wse)?;
470 stream.flush()?;
471 } else {
472 write_values(&dir.join("depth.bin"), &depth)?;
473 write_values(&dir.join("wse.bin"), &wse)?;
474 }
475 Ok(())
476 })();
477 let unit = match result {
478 Ok(()) => {
479 json!({"id":p.id,"status":"complete","seconds":tick.elapsed().as_secs_f64()})
480 }
481 Err(e) => {
482 for name in [
483 "depth.bin",
484 "wse.bin",
485 "depth.bin.partial",
486 "wse.bin.partial",
487 ] {
488 let _ = fs::remove_file(dir.join(name));
489 }
490 json!({"id":p.id,"status":"failed","error":e.to_string()})
491 }
492 };
493 if req.stream_outputs && unit["status"] == "failed" {
494 let stdout = std::io::stdout();
495 let mut stream = stdout.lock();
496 writeln!(stream, "{}", unit)?;
497 stream.flush()?;
498 }
499 sink::write_json(&dir.join("unit.json"), &unit)?;
500 report["profiles"].as_array_mut().unwrap().push(unit);
501 sink::write_json(&out.join("report.json"), &report)?;
502 }
503 Ok(())
504 },
505 ));
506 match execution {
507 Ok(Ok(())) => {
508 report["status"] = if report["profiles"]
509 .as_array()
510 .unwrap()
511 .iter()
512 .all(|p| p["status"] == "complete")
513 {
514 "complete".into()
515 } else {
516 "failed".into()
517 };
518 }
519 other => {
520 let message = match other {
521 Ok(Err(e)) => e.to_string(),
522 _ => "horizontal internal panic".into(),
523 };
524 report["status"] = "aborted".into();
525 report["error"] = message.into();
526 for p in &req.profiles {
528 let dir = out.join(&p.id);
529 if dir.is_dir() {
530 for name in [
531 "depth.bin",
532 "wse.bin",
533 "depth.bin.partial",
534 "wse.bin.partial",
535 ] {
536 let _ = fs::remove_file(dir.join(name));
537 }
538 sink::write_json(
539 &dir.join("unit.json"),
540 &json!({"id":p.id,"status":"failed","error":"session aborted"}),
541 )?;
542 }
543 }
544 report["profiles"] = req
545 .profiles
546 .iter()
547 .map(|p| json!({"id":p.id,"status":"failed","error":"session aborted"}))
548 .collect::<Vec<_>>()
549 .into();
550 }
551 }
552 report["total_seconds"] = started.elapsed().as_secs_f64().into();
553 sink::write_json(&out.join("report.json"), &report)?;
554 Ok(report)
555}
556#[cfg(test)]
557mod tests {
558 use super::*;
559 #[test]
560 fn strict_float32_threshold_and_dry() {
561 let mut d = vec![f32::NAN; 5];
562 let mut w = d.clone();
563 evaluate(
564 &[1, 2, 0, 1, 3],
565 &[100., 100., 100., f32::NAN, 100.],
566 &[100.5, 101., f32::NAN],
567 0.5,
568 &mut d,
569 &mut w,
570 )
571 .unwrap();
572 assert!(d[0].is_nan() && d[2].is_nan() && d[3].is_nan() && d[4].is_nan());
573 assert_eq!(d[1], 1.);
574 assert_eq!(w[1], 101.);
575 }
576
577 #[test]
578 fn native_plot_order_masks_and_retains_binary32_boundary_pixels() {
579 let ground = [1000.0, 1000.0, 100000.0, 10.0];
580 let values = [
581 f32::from_bits(0x447a000f),
582 f32::from_bits(0x447a0010),
583 100000.0,
584 10.0005,
585 ];
586 let mut depth = [f32::NAN; 4];
587 let mut wse = depth;
588 evaluate(&[1, 2, 3, 4], &ground, &values, 0.0, &mut depth, &mut wse).unwrap();
589 assert!(depth[0].is_nan() && wse[0].is_nan());
590 assert_eq!(depth[1].to_bits(), (values[1] - ground[1]).to_bits());
591 assert!(depth[1] < 0.001); assert_eq!(wse[1].to_bits(), values[1].to_bits());
593 assert_eq!(depth[2], 0.0); assert!(depth[3].is_nan());
595 }
596}