Skip to main content

sidereon_core/
terrain.rs

1//! DTED tile reader and bilinear terrain lookup.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::Error;
8
9pub(crate) const UHL_SIZE: usize = 80;
10pub(crate) const DSI_SIZE: usize = 648;
11pub(crate) const ACC_SIZE: usize = 2700;
12pub(crate) const DATA_OFFSET: usize = UHL_SIZE + DSI_SIZE + ACC_SIZE;
13pub(crate) const DATA_SENTINEL: u8 = 0xAA;
14pub(crate) const DTED_SUFFIX: &str = concat!("_1arc_v3.d", "t", "2");
15const MIN_LOOKUP_LATITUDE_DEG: f64 = -90.0;
16const MAX_LOOKUP_LATITUDE_DEG: f64 = 90.0;
17const MIN_LOOKUP_LONGITUDE_DEG: f64 = -180.0;
18const MAX_LOOKUP_LONGITUDE_DEG: f64 = 180.0;
19
20/// Interpolation mode for DTED terrain lookups.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum DtedInterpolation {
23    /// Return the nearest DTED posting as an orthometric height in metres.
24    NearestPosting,
25    /// Bilinearly interpolate the four surrounding DTED postings as an
26    /// orthometric height in metres.
27    Bilinear,
28}
29
30/// Lookup options for DTED terrain queries.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct DtedLookupOptions {
33    /// Interpolation mode used for each orthometric height query.
34    pub interpolation: DtedInterpolation,
35}
36
37impl Default for DtedLookupOptions {
38    fn default() -> Self {
39        Self {
40            interpolation: DtedInterpolation::Bilinear,
41        }
42    }
43}
44
45/// Lazy DTED terrain reader backed by raw `.dt2` tile bytes.
46///
47/// Heights returned by this reader are orthometric metres, `H`, above the
48/// EGM96 mean sea level geoid used by DTED/SRTM terrain products. They are not
49/// ellipsoidal heights above the WGS84 reference ellipsoid.
50#[derive(Debug)]
51pub struct DtedTerrain {
52    root: PathBuf,
53    tiles: HashMap<(i32, i32), DtedTile>,
54}
55
56impl DtedTerrain {
57    /// Build a terrain reader rooted at a directory containing DTED `.dt2`
58    /// tiles, either directly or under the repository's block directories.
59    #[must_use]
60    pub fn new(root: impl Into<PathBuf>) -> Self {
61        Self {
62            root: root.into(),
63            tiles: HashMap::new(),
64        }
65    }
66
67    /// Return the bilinearly interpolated orthometric height `H` in metres at a
68    /// longitude-first geodetic position in degrees.
69    pub fn height_m(&mut self, longitude_deg: f64, latitude_deg: f64) -> crate::Result<f64> {
70        self.height_m_with_options(longitude_deg, latitude_deg, DtedLookupOptions::default())
71    }
72
73    /// Return the orthometric height `H` in metres at a longitude-first
74    /// geodetic position in degrees using explicit lookup options.
75    pub fn height_m_with_options(
76        &mut self,
77        longitude_deg: f64,
78        latitude_deg: f64,
79        options: DtedLookupOptions,
80    ) -> crate::Result<f64> {
81        validate_lookup_coordinates(longitude_deg, latitude_deg)?;
82        let Some(tile) = self.load_tile(longitude_deg, latitude_deg)? else {
83            return Ok(0.0);
84        };
85        height_from_tile(tile, longitude_deg, latitude_deg, options)
86    }
87
88    /// Evaluate `(longitude_deg, latitude_deg)` points in order using one
89    /// mutable borrow of the resident tile cache.
90    ///
91    /// The tuple order is intentionally longitude-first, matching
92    /// [`Self::height_m_with_options`], even though geoid batch helpers use
93    /// latitude-first points.
94    pub fn height_batch(
95        &mut self,
96        points: &[(f64, f64)],
97        options: DtedLookupOptions,
98    ) -> Vec<crate::Result<f64>> {
99        let mut out = Vec::with_capacity(points.len());
100        let mut current = None;
101
102        for &(longitude_deg, latitude_deg) in points {
103            if let Err(err) = validate_lookup_coordinates(longitude_deg, latitude_deg) {
104                out.push(Err(err));
105                continue;
106            }
107
108            let primary_grid = terrain_grid(longitude_deg, latitude_deg);
109            if current == Some(primary_grid) {
110                if let Some(tile) = self.tiles.get(&primary_grid) {
111                    if tile.contains(longitude_deg, latitude_deg) {
112                        out.push(height_from_tile(tile, longitude_deg, latitude_deg, options));
113                        continue;
114                    }
115                }
116            }
117
118            match self.resolve_grid(longitude_deg, latitude_deg) {
119                Ok(Some(grid_idx)) => {
120                    current = Some(grid_idx);
121                    let tile = self
122                        .tiles
123                        .get(&grid_idx)
124                        .expect("resolved DTED grid must be present in tile cache");
125                    out.push(height_from_tile(tile, longitude_deg, latitude_deg, options));
126                }
127                Ok(None) => {
128                    current = None;
129                    out.push(Ok(0.0));
130                }
131                Err(err) => out.push(Err(err)),
132            }
133        }
134
135        out
136    }
137
138    fn load_tile(&mut self, longitude: f64, latitude: f64) -> crate::Result<Option<&DtedTile>> {
139        let Some(grid_idx) = self.resolve_grid(longitude, latitude)? else {
140            return Ok(None);
141        };
142        Ok(self.tiles.get(&grid_idx))
143    }
144
145    fn resolve_grid(&mut self, longitude: f64, latitude: f64) -> crate::Result<Option<(i32, i32)>> {
146        for grid_idx in terrain_grid_candidates(longitude, latitude) {
147            if !self.tiles.contains_key(&grid_idx) {
148                let Some(path) = self.terrain_path_for_grid(grid_idx.0, grid_idx.1) else {
149                    continue;
150                };
151                if !path.is_file() {
152                    continue;
153                }
154                let tile = DtedTile::from_path(path).map_err(Error::Parse)?;
155                self.tiles.insert(grid_idx, tile);
156            }
157            if let Some(tile) = self.tiles.get(&grid_idx) {
158                if tile.contains(longitude, latitude) {
159                    return Ok(Some(grid_idx));
160                }
161            }
162        }
163        Ok(None)
164    }
165
166    fn terrain_path_for_grid(&self, latitude_index: i32, longitude_index: i32) -> Option<PathBuf> {
167        let tile_name = format!(
168            "{}_{}{}",
169            format_lat(latitude_index),
170            format_lon(longitude_index),
171            DTED_SUFFIX
172        );
173
174        let direct = self.root.join(&tile_name);
175        if direct.is_file() {
176            return Some(direct);
177        }
178
179        let block_dir = terrain_block_dir(latitude_index, longitude_index);
180        let nested = self.root.join(&block_dir).join(&tile_name);
181        if nested.is_file() {
182            return Some(nested);
183        }
184
185        let sibling = self.root.parent()?.join(&block_dir).join(&tile_name);
186        sibling.is_file().then_some(sibling)
187    }
188}
189
190fn height_from_tile(
191    tile: &DtedTile,
192    longitude_deg: f64,
193    latitude_deg: f64,
194    options: DtedLookupOptions,
195) -> crate::Result<f64> {
196    if options.interpolation == DtedInterpolation::NearestPosting {
197        return tile
198            .get_elevation(longitude_deg, latitude_deg)
199            .map(|v| v as f64)
200            .map_err(Error::Parse);
201    }
202
203    let postings_per_deg_lon = tile.lon_count - 1;
204    let postings_per_deg_lat = tile.lat_count - 1;
205
206    let lon_idx = (longitude_deg - tile.origin_longitude) * postings_per_deg_lon as f64;
207    let lat_idx = (latitude_deg - tile.origin_latitude) * postings_per_deg_lat as f64;
208    let lon_lo = lon_idx.floor() as i64;
209    let lat_lo = lat_idx.floor() as i64;
210    let fx = lon_idx - lon_lo as f64;
211    let fy = lat_idx - lat_lo as f64;
212
213    let mut z = 0.0;
214    for (di, wx) in [(0i64, 1.0 - fx), (1i64, fx)] {
215        for (dj, wy) in [(0i64, 1.0 - fy), (1i64, fy)] {
216            let w = wx * wy;
217            if w == 0.0 {
218                continue;
219            }
220            let posting_lon =
221                tile.origin_longitude + (lon_lo + di) as f64 / postings_per_deg_lon as f64;
222            let posting_lat =
223                tile.origin_latitude + (lat_lo + dj) as f64 / postings_per_deg_lat as f64;
224            z += w * f64::from(
225                tile.get_elevation(posting_lon, posting_lat)
226                    .map_err(Error::Parse)?,
227            );
228        }
229    }
230    Ok(z)
231}
232
233pub(crate) fn validate_lookup_coordinates(
234    longitude_deg: f64,
235    latitude_deg: f64,
236) -> crate::Result<()> {
237    if !longitude_deg.is_finite() {
238        return Err(Error::InvalidInput(
239            "longitude_deg must be finite".to_string(),
240        ));
241    }
242    if !latitude_deg.is_finite() {
243        return Err(Error::InvalidInput(
244            "latitude_deg must be finite".to_string(),
245        ));
246    }
247    if !(MIN_LOOKUP_LONGITUDE_DEG..=MAX_LOOKUP_LONGITUDE_DEG).contains(&longitude_deg) {
248        return Err(Error::InvalidInput(
249            "longitude_deg must be within [-180, 180]".to_string(),
250        ));
251    }
252    if !(MIN_LOOKUP_LATITUDE_DEG..=MAX_LOOKUP_LATITUDE_DEG).contains(&latitude_deg) {
253        return Err(Error::InvalidInput(
254            "latitude_deg must be within [-90, 90]".to_string(),
255        ));
256    }
257    Ok(())
258}
259
260/// Parsed DTED tile backed by raw `.dt2` bytes.
261///
262/// Posting values are decoded lazily from DTED signed-magnitude samples.
263/// Returned heights are orthometric metres, `H`, above the EGM96 mean sea level
264/// geoid.
265#[derive(Debug)]
266pub struct DtedTile {
267    origin_latitude: f64,
268    origin_longitude: f64,
269    lon_count: usize,
270    lat_count: usize,
271    data_block_length: usize,
272    bytes: Vec<u8>,
273}
274
275impl DtedTile {
276    /// Read and parse a DTED `.dt2` tile from disk.
277    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, String> {
278        let bytes =
279            fs::read(path.as_ref()).map_err(|e| format!("{}: {e}", path.as_ref().display()))?;
280        if bytes.len() < DATA_OFFSET {
281            return Err(format!(
282                "{} is too short for DTED headers",
283                path.as_ref().display()
284            ));
285        }
286        if &bytes[0..4] != b"UHL1" {
287            return Err(format!("{} missing UHL1 header", path.as_ref().display()));
288        }
289
290        let origin_longitude =
291            parse_dted_coord(std::str::from_utf8(&bytes[4..12]).map_err(|e| e.to_string())?)?;
292        let origin_latitude =
293            parse_dted_coord(std::str::from_utf8(&bytes[12..20]).map_err(|e| e.to_string())?)?;
294        let lon_count = parse_ascii_usize(&bytes[47..51])?;
295        let lat_count = parse_ascii_usize(&bytes[51..55])?;
296        if lon_count < 2 || lat_count < 2 {
297            return Err(format!(
298                "{} has invalid DTED dimensions lon_count={} lat_count={}; both must be at least 2",
299                path.as_ref().display(),
300                lon_count,
301                lat_count
302            ));
303        }
304        let data_block_length = 12 + 2 * lat_count;
305        let expected_len = DATA_OFFSET + lon_count * data_block_length;
306        if bytes.len() < expected_len {
307            return Err(format!(
308                "{} has {} bytes but expected at least {}",
309                path.as_ref().display(),
310                bytes.len(),
311                expected_len
312            ));
313        }
314
315        Ok(Self {
316            origin_latitude,
317            origin_longitude,
318            lon_count,
319            lat_count,
320            data_block_length,
321            bytes,
322        })
323    }
324
325    /// Return the nearest orthometric posting value in metres for a
326    /// longitude-first geodetic position in degrees.
327    pub fn get_elevation(&self, longitude: f64, latitude: f64) -> Result<i16, String> {
328        if !self.contains(longitude, latitude) {
329            return Err(format!(
330                "point ({longitude},{latitude}) is outside DTED tile ({},{})",
331                self.origin_longitude, self.origin_latitude
332            ));
333        }
334
335        let latitude_index =
336            py_round_to_usize((latitude - self.origin_latitude) * (self.lat_count - 1) as f64)?;
337        let longitude_index =
338            py_round_to_usize((longitude - self.origin_longitude) * (self.lon_count - 1) as f64)?;
339        if latitude_index >= self.lat_count || longitude_index >= self.lon_count {
340            return Err(format!(
341                "posting index out of bounds lon={longitude_index} lat={latitude_index}"
342            ));
343        }
344
345        let block = self.validated_block(longitude_index)?;
346
347        let sample_start = 8 + latitude_index * 2;
348        let raw = i16::from_be_bytes([block[sample_start], block[sample_start + 1]]);
349        Ok(convert_signed_magnitude(raw))
350    }
351
352    pub(crate) fn origin_latitude(&self) -> f64 {
353        self.origin_latitude
354    }
355
356    pub(crate) fn origin_longitude(&self) -> f64 {
357        self.origin_longitude
358    }
359
360    pub(crate) fn lon_count(&self) -> usize {
361        self.lon_count
362    }
363
364    pub(crate) fn lat_count(&self) -> usize {
365        self.lat_count
366    }
367
368    pub(crate) fn decoded_postings_lon_major(&self) -> Result<Vec<i16>, String> {
369        let mut out = Vec::with_capacity(self.lon_count * self.lat_count);
370        for longitude_index in 0..self.lon_count {
371            let block = self.validated_block(longitude_index)?;
372            for latitude_index in 0..self.lat_count {
373                let sample_start = 8 + latitude_index * 2;
374                let raw = i16::from_be_bytes([block[sample_start], block[sample_start + 1]]);
375                out.push(convert_signed_magnitude(raw));
376            }
377        }
378        Ok(out)
379    }
380
381    fn contains(&self, longitude: f64, latitude: f64) -> bool {
382        latitude >= self.origin_latitude
383            && latitude <= self.origin_latitude + 1.0
384            && longitude >= self.origin_longitude
385            && longitude <= self.origin_longitude + 1.0
386    }
387
388    fn validated_block(&self, longitude_index: usize) -> Result<&[u8], String> {
389        let block_start = DATA_OFFSET + longitude_index * self.data_block_length;
390        let block_end = block_start + self.data_block_length;
391        let block = &self.bytes[block_start..block_end];
392        if block[0] != DATA_SENTINEL {
393            return Err(format!(
394                "DTED block {longitude_index} missing data sentinel"
395            ));
396        }
397        let checksum = i32::from_be_bytes([
398            block[block.len() - 4],
399            block[block.len() - 3],
400            block[block.len() - 2],
401            block[block.len() - 1],
402        ]);
403        let sum = block[..block.len() - 4]
404            .iter()
405            .fold(0i32, |acc, b| acc + i32::from(*b));
406        if sum != checksum {
407            return Err(format!(
408                "DTED checksum failed for block {longitude_index}: expected {checksum}, found {sum}"
409            ));
410        }
411        Ok(block)
412    }
413}
414
415pub(crate) fn terrain_grid(longitude: f64, latitude: f64) -> (i32, i32) {
416    (latitude.floor() as i32, longitude.floor() as i32)
417}
418
419pub(crate) fn terrain_grid_candidates(longitude: f64, latitude: f64) -> Vec<(i32, i32)> {
420    let (lat, lon) = terrain_grid(longitude, latitude);
421    let mut out = vec![(lat, lon)];
422    let on_lat_edge = latitude == latitude.floor();
423    let on_lon_edge = longitude == longitude.floor();
424    if on_lat_edge {
425        out.push((lat - 1, lon));
426    }
427    if on_lon_edge {
428        out.push((lat, lon - 1));
429    }
430    if on_lat_edge && on_lon_edge {
431        out.push((lat - 1, lon - 1));
432    }
433    out
434}
435
436pub(crate) fn format_lat(latitude_index: i32) -> String {
437    if latitude_index >= 0 {
438        format!("n{latitude_index:02}")
439    } else {
440        format!("s{:02}", -latitude_index)
441    }
442}
443
444pub(crate) fn format_lon(longitude_index: i32) -> String {
445    if longitude_index >= 0 {
446        format!("e{longitude_index:03}")
447    } else {
448        format!("w{:03}", -longitude_index)
449    }
450}
451
452pub(crate) fn terrain_block_dir(latitude_index: i32, longitude_index: i32) -> String {
453    format!(
454        "{}_{}",
455        format_block_lat(latitude_index),
456        format_block_lon(longitude_index)
457    )
458}
459
460fn format_block_lat(latitude_index: i32) -> String {
461    let origin = block_origin(latitude_index);
462    if latitude_index >= 0 {
463        format!("n{origin:02}")
464    } else {
465        format!("s{origin:02}")
466    }
467}
468
469fn format_block_lon(longitude_index: i32) -> String {
470    let origin = block_origin(longitude_index);
471    if longitude_index >= 0 {
472        format!("e{origin:03}")
473    } else {
474        format!("w{origin:03}")
475    }
476}
477
478pub(crate) fn block_origin(index: i32) -> u32 {
479    (index.unsigned_abs() / 10) * 10
480}
481
482fn parse_ascii_usize(bytes: &[u8]) -> Result<usize, String> {
483    std::str::from_utf8(bytes)
484        .map_err(|e| e.to_string())?
485        .trim()
486        .parse::<usize>()
487        .map_err(|e| e.to_string())
488}
489
490fn parse_dted_coord(input: &str) -> Result<f64, String> {
491    let hemi = input
492        .chars()
493        .last()
494        .ok_or_else(|| "empty DTED coordinate".to_string())?;
495    let sign = match hemi {
496        'S' | 'W' => -1.0,
497        'N' | 'E' => 1.0,
498        _ => return Err(format!("invalid DTED hemisphere {hemi}")),
499    };
500    let coord = &input[..input.len() - 1];
501    let seconds_index = if coord.as_bytes().get(coord.len().saturating_sub(2)) == Some(&b'.') {
502        coord.len() - 4
503    } else {
504        coord.len() - 2
505    };
506    let minutes_index = seconds_index - 2;
507    let degree = coord[..minutes_index]
508        .parse::<i32>()
509        .map_err(|e| e.to_string())?;
510    let minute = coord[minutes_index..seconds_index]
511        .parse::<i32>()
512        .map_err(|e| e.to_string())?;
513    let second = coord[seconds_index..]
514        .parse::<f64>()
515        .map_err(|e| e.to_string())?;
516    Ok(sign * (degree as f64 + ((minute as f64 + second / 60.0) / 60.0)))
517}
518
519pub(crate) fn py_round_to_usize(value: f64) -> Result<usize, String> {
520    if value < 0.0 {
521        return Err(format!("cannot round negative posting index {value}"));
522    }
523    let lo = value.floor();
524    let frac = value - lo;
525    let rounded = if frac < 0.5 {
526        lo
527    } else if frac > 0.5 {
528        lo + 1.0
529    } else {
530        let lo_i = lo as u64;
531        if lo_i.is_multiple_of(2) {
532            lo
533        } else {
534            lo + 1.0
535        }
536    };
537    Ok(rounded as usize)
538}
539
540fn convert_signed_magnitude(raw: i16) -> i16 {
541    if raw < 0 {
542        (-32768i32 - i32::from(raw)) as i16
543    } else {
544        raw
545    }
546}
547
548#[cfg(all(test, sidereon_repo_tests))]
549mod tests {
550    //! DTED batch fixture provenance: adjacent synthetic tiles under
551    //! `tests/fixtures/dted/tiles` are written by
552    //! `crates/sidereon-core/fixtures-generators/generate_dted_points.py` using
553    //! the public DTED UHL/DSI/ACC/data-record layout. Tests compare
554    //! `f64::to_bits` exactly, never tolerances.
555
556    use std::fs;
557    use std::path::Path;
558    use std::path::PathBuf;
559    use std::time::{SystemTime, UNIX_EPOCH};
560
561    use serde_json::Value;
562
563    use crate::test_parity::f64_from_hex;
564    use crate::Error;
565
566    use super::{
567        terrain_block_dir, DtedInterpolation, DtedLookupOptions, DtedTerrain, DtedTile,
568        DATA_OFFSET, DATA_SENTINEL,
569    };
570
571    #[test]
572    fn terrain_block_dir_matches_reference_bucket_names() {
573        assert_eq!(terrain_block_dir(36, -107), "n30_w100");
574        assert_eq!(terrain_block_dir(32, -117), "n30_w110");
575        assert_eq!(terrain_block_dir(43, -112), "n40_w110");
576        assert_eq!(terrain_block_dir(20, -103), "n20_w100");
577        assert_eq!(terrain_block_dir(36, 107), "n30_e100");
578        assert_eq!(terrain_block_dir(-1, -1), "s00_w000");
579        assert_eq!(terrain_block_dir(1, 1), "n00_e000");
580        assert_eq!(terrain_block_dir(-1, 1), "s00_e000");
581        assert_eq!(terrain_block_dir(32, -110), "n30_w110");
582        assert_eq!(terrain_block_dir(32, -111), "n30_w110");
583        assert_eq!(terrain_block_dir(32, -1), "n30_w000");
584        assert_eq!(terrain_block_dir(32, -10), "n30_w010");
585    }
586
587    #[test]
588    fn negative_tile_indices_resolve_to_negative_block_dir() {
589        let nonce = SystemTime::now()
590            .duration_since(UNIX_EPOCH)
591            .expect("system time after epoch")
592            .as_nanos();
593        let root = std::env::temp_dir().join(format!(
594            "sidereon-dted-negative-block-{}-{nonce}",
595            std::process::id()
596        ));
597        let tile_dir = root.join("s00_w000");
598        let tile_path = tile_dir.join("s01_w001_1arc_v3.dt2");
599        fs::create_dir_all(&tile_dir).expect("create nested DTED block dir");
600        fs::write(&tile_path, []).expect("create nested DTED tile path");
601
602        let terrain = DtedTerrain::new(&root);
603        let got = terrain
604            .terrain_path_for_grid(-1, -1)
605            .expect("negative nested tile path");
606        assert_eq!(got, tile_path);
607
608        fs::remove_dir_all(root).expect("remove temp DTED block dir");
609    }
610
611    fn fixture_path(name: &str) -> PathBuf {
612        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
613            .join("tests")
614            .join("fixtures")
615            .join("dted")
616            .join(name)
617    }
618
619    fn bits(v: &Value) -> f64 {
620        f64_from_hex(v.as_str().expect("hex-bit string")).expect("valid f64 bits")
621    }
622
623    fn temp_path(name: &str) -> PathBuf {
624        let nonce = SystemTime::now()
625            .duration_since(UNIX_EPOCH)
626            .expect("system time after epoch")
627            .as_nanos();
628        std::env::temp_dir().join(format!("sidereon-{name}-{}-{nonce}", std::process::id()))
629    }
630
631    fn scalar_loop(
632        root: &Path,
633        points: &[(f64, f64)],
634        options: DtedLookupOptions,
635    ) -> Vec<crate::Result<f64>> {
636        let mut terrain = DtedTerrain::new(root);
637        points
638            .iter()
639            .map(|&(lon, lat)| terrain.height_m_with_options(lon, lat, options))
640            .collect()
641    }
642
643    fn assert_height_results_match(
644        got: &[crate::Result<f64>],
645        want: &[crate::Result<f64>],
646        context: &str,
647    ) {
648        assert_eq!(got.len(), want.len(), "{context} result length");
649        for (idx, (got, want)) in got.iter().zip(want).enumerate() {
650            match (got, want) {
651                (Ok(got), Ok(want)) => assert_eq!(
652                    got.to_bits(),
653                    want.to_bits(),
654                    "{context} index {idx} height bits"
655                ),
656                (Err(got), Err(want)) => {
657                    assert_eq!(got, want, "{context} index {idx} error")
658                }
659                (got, want) => panic!("{context} index {idx} mismatch: {got:?} != {want:?}"),
660            }
661        }
662    }
663
664    fn copy_fixture_tile(root: &Path, tile_name: &str) {
665        fs::copy(
666            fixture_path(&format!("tiles/{tile_name}")),
667            root.join(tile_name),
668        )
669        .expect("copy DTED fixture tile");
670    }
671
672    fn copy_primary_fixture_root(name: &str) -> PathBuf {
673        let root = temp_path(name);
674        fs::create_dir_all(&root).expect("create temp DTED dir");
675        copy_fixture_tile(&root, "n36_w107_1arc_v3.dt2");
676        root
677    }
678
679    fn write_synthetic_dted_tile(
680        path: &Path,
681        lon_count: usize,
682        lat_count: usize,
683        sample: impl Fn(usize, usize) -> i16,
684    ) {
685        let data_block_length = 12 + 2 * lat_count;
686        let mut bytes = vec![b' '; DATA_OFFSET];
687        bytes[0..4].copy_from_slice(b"UHL1");
688        bytes[4..12].copy_from_slice(b"1070000W");
689        bytes[12..20].copy_from_slice(b"0360000N");
690        bytes[47..51].copy_from_slice(format!("{lon_count:04}").as_bytes());
691        bytes[51..55].copy_from_slice(format!("{lat_count:04}").as_bytes());
692
693        for lon_index in 0..lon_count {
694            let mut block = vec![0u8; data_block_length];
695            block[0] = DATA_SENTINEL;
696            for lat_index in 0..lat_count {
697                let sample_start = 8 + lat_index * 2;
698                block[sample_start..sample_start + 2]
699                    .copy_from_slice(&sample(lon_index, lat_index).to_be_bytes());
700            }
701            let checksum = block[..block.len() - 4]
702                .iter()
703                .fold(0i32, |acc, b| acc + i32::from(*b));
704            let checksum_start = block.len() - 4;
705            block[checksum_start..].copy_from_slice(&checksum.to_be_bytes());
706            bytes.extend(block);
707        }
708
709        fs::write(path, bytes).expect("write synthetic DTED tile");
710    }
711
712    #[test]
713    fn dted_rejects_degenerate_header_counts() {
714        let root = temp_path("dted-degenerate-counts");
715        fs::create_dir_all(&root).expect("create temp DTED dir");
716
717        for (lon_count, lat_count) in [(0, 2), (1, 2), (2, 0), (2, 1)] {
718            let tile_path = root.join(format!("tile-{lon_count}-{lat_count}.dt2"));
719            write_synthetic_dted_tile(&tile_path, lon_count, lat_count, |_, _| 0);
720
721            let err = DtedTile::from_path(&tile_path).expect_err("degenerate counts must error");
722            assert!(
723                err.contains("invalid DTED dimensions"),
724                "unexpected error for lon_count={lon_count} lat_count={lat_count}: {err}"
725            );
726        }
727
728        fs::remove_dir_all(root).expect("remove temp DTED dir");
729    }
730
731    #[test]
732    fn dted_lookup_rejects_nonfinite_coordinates() {
733        let root = temp_path("dted-nonfinite-coordinates");
734        let mut terrain = DtedTerrain::new(&root);
735
736        for (lon, lat, field) in [
737            (f64::NAN, 36.5, "longitude_deg"),
738            (f64::INFINITY, 36.5, "longitude_deg"),
739            (f64::NEG_INFINITY, 36.5, "longitude_deg"),
740            (-106.5, f64::NAN, "latitude_deg"),
741            (-106.5, f64::INFINITY, "latitude_deg"),
742            (-106.5, f64::NEG_INFINITY, "latitude_deg"),
743        ] {
744            assert_eq!(
745                terrain
746                    .height_m_with_options(lon, lat, DtedLookupOptions::default())
747                    .expect_err("non-finite DTED coordinate must error"),
748                Error::InvalidInput(format!("{field} must be finite"))
749            );
750        }
751
752        assert_eq!(
753            terrain
754                .height_m(f64::NAN, 36.5)
755                .expect_err("height_m must also reject non-finite coordinates"),
756            Error::InvalidInput("longitude_deg must be finite".to_string())
757        );
758    }
759
760    #[test]
761    fn dted_lookup_rejects_out_of_range_coordinates() {
762        let root = temp_path("dted-out-of-range-coordinates");
763        let mut terrain = DtedTerrain::new(&root);
764
765        for (lon, lat, error) in [
766            (
767                -106.5,
768                91.0,
769                Error::InvalidInput("latitude_deg must be within [-90, 90]".to_string()),
770            ),
771            (
772                -106.5,
773                -90.5,
774                Error::InvalidInput("latitude_deg must be within [-90, 90]".to_string()),
775            ),
776            (
777                200.0,
778                36.5,
779                Error::InvalidInput("longitude_deg must be within [-180, 180]".to_string()),
780            ),
781            (
782                -180.5,
783                36.5,
784                Error::InvalidInput("longitude_deg must be within [-180, 180]".to_string()),
785            ),
786        ] {
787            assert_eq!(
788                terrain
789                    .height_m_with_options(lon, lat, DtedLookupOptions::default())
790                    .expect_err("out-of-range DTED coordinate must error"),
791                error
792            );
793        }
794
795        assert_eq!(
796            terrain
797                .height_m(-106.5, 36.5)
798                .expect("missing in-range tile keeps sea-level fallback"),
799            0.0
800        );
801    }
802
803    #[test]
804    fn dted_valid_minimum_tile_parses_and_interpolates() {
805        let root = temp_path("dted-valid-minimum");
806        fs::create_dir_all(&root).expect("create temp DTED dir");
807        let tile_path = root.join("n36_w107_1arc_v3.dt2");
808        write_synthetic_dted_tile(&tile_path, 2, 2, |lon_index, lat_index| {
809            match (lon_index, lat_index) {
810                (0, 0) => 10,
811                (0, 1) => 30,
812                (1, 0) => 50,
813                (1, 1) => 70,
814                _ => unreachable!("2x2 synthetic tile"),
815            }
816        });
817
818        DtedTile::from_path(&tile_path).expect("valid 2x2 DTED tile");
819        let mut terrain = DtedTerrain::new(&root);
820        assert_eq!(
821            terrain
822                .height_m_with_options(
823                    -106.5,
824                    36.5,
825                    DtedLookupOptions {
826                        interpolation: DtedInterpolation::Bilinear,
827                    },
828                )
829                .expect("bilinear height"),
830            40.0
831        );
832
833        fs::remove_dir_all(root).expect("remove temp DTED dir");
834    }
835
836    // Fixture provenance: `tests/fixtures/dted/tiles/n36_w107_1arc_v3.dt2` is a
837    // synthetic public-format DTED tile written by the committed generator
838    // `crates/sidereon-core/fixtures-generators/generate_dted_points.py` using the
839    // DTED UHL/DSI/ACC/data-record layout (tile id `n36_w107`, elevation formula
840    // `z_m = -20 + 7*lon_i - 5*lat_i + lon_i*lat_i`); no external terrain payload is
841    // copied. `tests/fixtures/dted/dted_points.json` holds nearest-posting and
842    // bilinear lookup cases generated from that tile. Floating-point fixture
843    // values are serialized as f64 hex-bit strings and must be compared with
844    // `f64::to_bits`, never tolerances.
845    #[test]
846    fn dted_lookup_matches_generated_fixture_bits() {
847        let raw =
848            std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
849        let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
850        assert_eq!(doc["schema"], "gnss-dted-points-v1");
851
852        let root = copy_primary_fixture_root("dted-fixture-single-scalar");
853        let mut terrain = DtedTerrain::new(&root);
854        let nearest = DtedLookupOptions {
855            interpolation: DtedInterpolation::NearestPosting,
856        };
857        let bilinear = DtedLookupOptions {
858            interpolation: DtedInterpolation::Bilinear,
859        };
860
861        let mut checked = 0usize;
862        for case in doc["nearest_cases"].as_array().expect("nearest_cases") {
863            let lon = bits(&case["longitude_bits"]);
864            let lat = bits(&case["latitude_bits"]);
865            let got = terrain
866                .height_m_with_options(lon, lat, nearest)
867                .expect("nearest DTED height");
868            let want = bits(&case["elevation_bits"]);
869            assert_eq!(
870                got.to_bits(),
871                want.to_bits(),
872                "nearest DTED {},{}",
873                lon,
874                lat
875            );
876            checked += 1;
877        }
878
879        for case in doc["bilinear_cases"].as_array().expect("bilinear_cases") {
880            let lon = bits(&case["longitude_bits"]);
881            let lat = bits(&case["latitude_bits"]);
882            let got = terrain
883                .height_m_with_options(lon, lat, bilinear)
884                .expect("bilinear DTED height");
885            let want = bits(&case["elevation_bits"]);
886            assert_eq!(
887                got.to_bits(),
888                want.to_bits(),
889                "bilinear DTED {},{}",
890                lon,
891                lat
892            );
893            checked += 1;
894        }
895        assert!(checked > 0, "empty DTED fixture");
896        fs::remove_dir_all(root).expect("remove temp DTED dir");
897    }
898
899    #[test]
900    fn height_batch_matches_scalar_loop_on_fixture_bits() {
901        let raw =
902            std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
903        let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
904        assert_eq!(doc["schema"], "gnss-dted-points-v1");
905
906        let points: Vec<(f64, f64)> = ["nearest_cases", "bilinear_cases"]
907            .into_iter()
908            .flat_map(|cases_key| {
909                doc[cases_key]
910                    .as_array()
911                    .expect(cases_key)
912                    .iter()
913                    .map(|case| (bits(&case["longitude_bits"]), bits(&case["latitude_bits"])))
914            })
915            .collect();
916
917        for options in [
918            DtedLookupOptions {
919                interpolation: DtedInterpolation::NearestPosting,
920            },
921            DtedLookupOptions {
922                interpolation: DtedInterpolation::Bilinear,
923            },
924        ] {
925            let root = copy_primary_fixture_root("dted-fixture-single-batch");
926            let want = scalar_loop(&root, &points, options);
927            let mut terrain = DtedTerrain::new(&root);
928            let got = terrain.height_batch(&points, options);
929            assert_height_results_match(&got, &want, "single-tile fixture batch");
930            fs::remove_dir_all(root).expect("remove temp DTED dir");
931        }
932    }
933
934    #[test]
935    fn height_batch_matches_scalar_loop_across_adjacent_tiles_bits() {
936        let root = fixture_path("tiles");
937        let options = DtedLookupOptions {
938            interpolation: DtedInterpolation::Bilinear,
939        };
940        let raw =
941            std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
942        let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
943        for case in doc["multi_tile_cases"]
944            .as_array()
945            .expect("multi_tile_cases")
946        {
947            let lon = bits(&case["longitude_bits"]);
948            let lat = bits(&case["latitude_bits"]);
949            let expected = bits(&case["bilinear_bits"]);
950            let mut terrain = DtedTerrain::new(&root);
951            let got = terrain
952                .height_m_with_options(lon, lat, options)
953                .expect("multi-tile generated bilinear height");
954            assert_eq!(
955                got.to_bits(),
956                expected.to_bits(),
957                "multi-tile generated case {}",
958                case["case_id"].as_str().expect("case_id")
959            );
960        }
961
962        let sequences = [
963            (
964                "all_in_a_then_all_in_b",
965                vec![
966                    (-106.875, 36.125),
967                    (-106.625, 36.375),
968                    (-105.875, 36.125),
969                    (-105.625, 36.375),
970                ],
971            ),
972            (
973                "interleaved_a_b_a_b",
974                vec![
975                    (-106.875, 36.625),
976                    (-105.875, 36.625),
977                    (-106.625, 36.125),
978                    (-105.625, 36.125),
979                ],
980            ),
981            (
982                "boundary_after_a_then_missing",
983                vec![
984                    (-106.875, 36.5),
985                    (-106.0, 36.5),
986                    (-104.5, 36.5),
987                    (-105.875, 36.5),
988                ],
989            ),
990        ];
991
992        for (name, points) in sequences {
993            let want = scalar_loop(&root, &points, options);
994            let mut terrain = DtedTerrain::new(&root);
995            let got = terrain.height_batch(&points, options);
996            assert_height_results_match(&got, &want, name);
997        }
998
999        let mut terrain = DtedTerrain::new(&root);
1000        let missing = terrain.height_batch(&[(-104.5, 36.5)], options);
1001        assert_eq!(
1002            missing[0].as_ref().map(|v| v.to_bits()),
1003            Ok(0.0f64.to_bits())
1004        );
1005    }
1006
1007    #[test]
1008    fn height_batch_places_errors_at_input_indices() {
1009        let root = temp_path("dted-batch-errors");
1010        fs::create_dir_all(&root).expect("create temp DTED dir");
1011        copy_fixture_tile(&root, "n36_w107_1arc_v3.dt2");
1012        copy_fixture_tile(&root, "n36_w106_1arc_v3.dt2");
1013        fs::write(root.join("n37_w107_1arc_v3.dt2"), b"not a DTED tile")
1014            .expect("write corrupt DTED tile");
1015
1016        let points = [
1017            (-106.875, 36.125),
1018            (-106.5, f64::NAN),
1019            (-105.875, 36.125),
1020            (-106.5, 37.5),
1021            (-106.625, 36.375),
1022        ];
1023        let options = DtedLookupOptions {
1024            interpolation: DtedInterpolation::Bilinear,
1025        };
1026        let want = scalar_loop(&root, &points, options);
1027        let mut terrain = DtedTerrain::new(&root);
1028        let got = terrain.height_batch(&points, options);
1029        assert_height_results_match(&got, &want, "batch error placement");
1030
1031        assert!(got[0].is_ok(), "index 0 remains valid");
1032        assert_eq!(
1033            got[1],
1034            Err(Error::InvalidInput(
1035                "latitude_deg must be finite".to_string()
1036            ))
1037        );
1038        assert!(got[2].is_ok(), "index 2 remains valid");
1039        assert!(
1040            matches!(&got[3], Err(Error::Parse(msg)) if msg.contains("too short")),
1041            "index 3 is the corrupt-tile error: {:?}",
1042            got[3]
1043        );
1044        assert!(got[4].is_ok(), "index 4 remains valid");
1045
1046        fs::remove_dir_all(root).expect("remove temp DTED dir");
1047    }
1048}