Skip to main content

ras_commander_fast_map/
horizontal.rs

1//! Horizontal cell-constant mapping. Caller supplies deterministic pixel ownership;
2//! native terrain is decoded once for the constrained window and reused across profiles.
3//! No triangulation, resampling, per-pixel cache lock, or GDAL dependency.
4use 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], // col, row, width, height
31    #[serde(default)]
32    pub owner_file: String, // u32 little endian, 0=dry, i+1=cell table position
33    #[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    /// Declares `terrain` as the top-priority source of a registered terrain.
45    #[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}
85/// One little-endian float32 per cell; NaN allowed, infinities rejected.
86pub(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}
123/// Pure float32 kernel; each pixel is independent and never uses fused operations.
124fn 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            // Native visibility compares elevations before subtracting depth.
150            // Replacing this with delta >= 0.001 changes binary32 boundaries.
151            // Zero means no additional cutoff, including native-valid zero
152            // depths when terrain + tolerance rounds back to terrain.
153            if value >= ground[i] + 0.001_f32 && (threshold == 0.0 || delta > threshold) {
154                *d = delta;
155                *w = value;
156            }
157            Ok(())
158        })
159}
160/// One pixel of [`evaluate`] without an added cutoff: the native float32 plot
161/// test, then subtraction. Returns the depth of a visible pixel.
162#[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
189/// Receives the prepared window once, then each profile's raw values in
190/// request order, instead of rendered products being delivered.
191pub(crate) trait Consumer: Send {
192    /// Window ownership (0 = none, else cell position + 1) and terrain
193    /// (NaN = nodata), after AOI masking.
194    fn prepare(&mut self, owner: &[u32], ground: &[f32]) -> Result<(), MapError>;
195    /// Cell WSE (NaN = dry) of one profile.
196    fn profile(&mut self, position: usize, values: &[f32]) -> Result<(), MapError>;
197}
198
199/// Runs the horizontal pipeline. With a consumer, profiles are handed to it in
200/// request order instead of being delivered as products; any profile failure
201/// then aborts the whole run.
202pub(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    // Fixed resident cache plus conservative array/IO-copy admission. Decoder transients are
238    // included separately after inspecting the terrain layout; this is not an OS RSS cap.
239    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    // Check all lengths before reading pixel arrays.
311    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                // Horizontal requests carry no cell minimum, so a foreign pixel's
421                // visibility cannot be bounded without its terrain: refuse.
422                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            // Mark all earlier outputs invalid after a session failure.
527            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); // A post-subtraction cutoff would be wrong.
592        assert_eq!(wse[1].to_bits(), values[1].to_bits());
593        assert_eq!(depth[2], 0.0); // Native addition rounds to the terrain.
594        assert!(depth[3].is_nan());
595    }
596}