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 = scaled_cell_fraction(longitude_deg - tile.origin_longitude, postings_per_deg_lon);
207    let lat = scaled_cell_fraction(latitude_deg - tile.origin_latitude, postings_per_deg_lat);
208    let lon_lo = lon.cell;
209    let lat_lo = lat.cell;
210    let fx = lon.fraction;
211    let fy = lat.fraction;
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            nearest_posting_index(latitude - self.origin_latitude, self.lat_count - 1)?;
337        let longitude_index =
338            nearest_posting_index(longitude - self.origin_longitude, self.lon_count - 1)?;
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
519#[derive(Clone, Copy, Debug)]
520pub(crate) struct ScaledCellFraction {
521    pub(crate) cell: i64,
522    pub(crate) fraction: f64,
523    nearest: i64,
524}
525
526/// Scale an exact binary64 value by an integer without first rounding their
527/// product to binary64.
528pub(crate) fn scaled_cell_fraction(offset: f64, postings_per_degree: usize) -> ScaledCellFraction {
529    debug_assert!(offset.is_finite());
530    debug_assert!(postings_per_degree > 0);
531
532    let bits = offset.to_bits();
533    let negative = bits >> 63 != 0;
534    let exponent_bits = ((bits >> 52) & 0x7ff) as i32;
535    let stored_significand = bits & ((1_u64 << 52) - 1);
536    let (significand, exponent) = if exponent_bits == 0 {
537        (stored_significand, -1074)
538    } else {
539        (
540            stored_significand | (1_u64 << 52),
541            exponent_bits - 1023 - 52,
542        )
543    };
544    if significand == 0 {
545        return ScaledCellFraction {
546            cell: 0,
547            fraction: 0.0,
548            nearest: 0,
549        };
550    }
551
552    let numerator = u128::from(significand) * postings_per_degree as u128;
553    if exponent >= 0 {
554        let magnitude = numerator
555            .checked_shl(exponent as u32)
556            .expect("in-tile scaled coordinate must fit u128");
557        let magnitude =
558            i64::try_from(magnitude).expect("in-tile scaled coordinate must fit a posting index");
559        let cell = if negative { -magnitude } else { magnitude };
560        return ScaledCellFraction {
561            cell,
562            fraction: 0.0,
563            nearest: cell,
564        };
565    }
566
567    let denominator_exponent = (-exponent) as u32;
568    if denominator_exponent >= 128 {
569        if negative {
570            return ScaledCellFraction {
571                cell: -1,
572                fraction: one_minus_dyadic(numerator, denominator_exponent),
573                nearest: 0,
574            };
575        }
576        return ScaledCellFraction {
577            cell: 0,
578            fraction: dyadic_to_f64(numerator, exponent),
579            nearest: 0,
580        };
581    }
582
583    let denominator = 1_u128 << denominator_exponent;
584    let integer = numerator >> denominator_exponent;
585    let remainder = numerator & (denominator - 1);
586    let (cell, euclidean_remainder) = if negative {
587        if remainder == 0 {
588            (-(integer as i64), 0)
589        } else {
590            (-(integer as i64) - 1, denominator - remainder)
591        }
592    } else {
593        (integer as i64, remainder)
594    };
595    let half = denominator >> 1;
596    let nearest = if euclidean_remainder < half || (euclidean_remainder == half && cell % 2 == 0) {
597        cell
598    } else {
599        cell + 1
600    };
601    ScaledCellFraction {
602        cell,
603        fraction: dyadic_to_f64(euclidean_remainder, exponent),
604        nearest,
605    }
606}
607
608pub(crate) fn nearest_posting_index(
609    offset: f64,
610    postings_per_degree: usize,
611) -> Result<usize, String> {
612    let scaled = scaled_cell_fraction(offset, postings_per_degree);
613    usize::try_from(scaled.nearest)
614        .map_err(|_| format!("cannot round negative posting index {}", scaled.nearest))
615}
616
617fn one_minus_dyadic(numerator: u128, denominator_exponent: u32) -> f64 {
618    let deficit_units = round_shift_right(numerator, denominator_exponent - 53);
619    1.0 - deficit_units as f64 * (f64::EPSILON / 2.0)
620}
621
622fn dyadic_to_f64(numerator: u128, exponent: i32) -> f64 {
623    if numerator == 0 {
624        return 0.0;
625    }
626
627    let bit_length = 128 - numerator.leading_zeros();
628    let mut binary_exponent = bit_length as i32 - 1 + exponent;
629    if binary_exponent >= -1022 {
630        let mut significand = if bit_length <= 53 {
631            numerator << (53 - bit_length)
632        } else {
633            round_shift_right(numerator, bit_length - 53)
634        };
635        if significand == 1_u128 << 53 {
636            significand >>= 1;
637            binary_exponent += 1;
638        }
639        let exponent_bits = u64::try_from(binary_exponent + 1023)
640            .expect("normal binary64 exponent must be nonnegative");
641        let fraction_bits = significand as u64 & ((1_u64 << 52) - 1);
642        return f64::from_bits((exponent_bits << 52) | fraction_bits);
643    }
644
645    let subnormal_shift = exponent + 1074;
646    let significand = if subnormal_shift >= 0 {
647        numerator << subnormal_shift as u32
648    } else {
649        round_shift_right(numerator, (-subnormal_shift) as u32)
650    };
651    f64::from_bits(significand as u64)
652}
653
654fn round_shift_right(value: u128, shift: u32) -> u128 {
655    if shift == 0 {
656        return value;
657    }
658    if shift > 128 {
659        return 0;
660    }
661
662    let quotient = if shift == 128 { 0 } else { value >> shift };
663    let remainder = if shift == 128 {
664        value
665    } else {
666        value & ((1_u128 << shift) - 1)
667    };
668    let half = 1_u128 << (shift - 1);
669    if remainder > half || (remainder == half && !quotient.is_multiple_of(2)) {
670        quotient + 1
671    } else {
672        quotient
673    }
674}
675
676fn convert_signed_magnitude(raw: i16) -> i16 {
677    if raw < 0 {
678        (-32768i32 - i32::from(raw)) as i16
679    } else {
680        raw
681    }
682}
683
684#[cfg(all(test, sidereon_repo_tests))]
685mod tests {
686    //! DTED batch fixture provenance: adjacent synthetic tiles under
687    //! `tests/fixtures/dted/tiles` are written by
688    //! `crates/sidereon-core/fixtures-generators/generate_dted_points.py` using
689    //! the public DTED UHL/DSI/ACC/data-record layout. Tests compare
690    //! `f64::to_bits` exactly, never tolerances.
691
692    use std::fs;
693    use std::path::Path;
694    use std::path::PathBuf;
695    use std::time::{SystemTime, UNIX_EPOCH};
696
697    use serde_json::Value;
698
699    use crate::test_parity::f64_from_hex;
700    use crate::Error;
701
702    use super::{
703        nearest_posting_index, scaled_cell_fraction, terrain_block_dir, DtedInterpolation,
704        DtedLookupOptions, DtedTerrain, DtedTile, DATA_OFFSET, DATA_SENTINEL,
705    };
706
707    #[test]
708    fn exact_scaling_preserves_the_split_point_fraction() {
709        let tile_origin = -107.0;
710        let coordinate = -106.265_141_029_846_36;
711        let offset = coordinate - tile_origin;
712        assert_eq!(offset, 0.734_858_970_153_638_3);
713
714        let scaled = scaled_cell_fraction(offset, 3600);
715        let naive = offset * 3600.0;
716        let naive_fraction = naive - naive.floor();
717        assert_eq!(scaled.cell, 2645);
718        assert_eq!(scaled.fraction.to_bits(), 0x3fdf_81b8_9fe7_b000);
719        assert_eq!(naive.floor() as i64, 2645);
720        assert_eq!(naive_fraction.to_bits(), 0x3fdf_81b8_9fe7_c000);
721        assert_eq!(naive_fraction.to_bits() - scaled.fraction.to_bits(), 4096);
722
723        let nondiscriminating_offset = -0.265_141_029_846_361_7;
724        let nondiscriminating = scaled_cell_fraction(nondiscriminating_offset, 3600);
725        let naive = nondiscriminating_offset * 3600.0;
726        assert_eq!(
727            nondiscriminating.fraction.to_bits(),
728            (naive - naive.floor()).to_bits()
729        );
730    }
731
732    #[test]
733    fn exact_scaling_keeps_a_coordinate_below_the_posting_in_the_lower_cell() {
734        let posting = 3.0_f64 / 3600.0;
735        let coordinate = f64::from_bits(posting.to_bits() - 1);
736        assert!(coordinate < posting);
737        assert_eq!(coordinate * 3600.0, 3.0);
738
739        let scaled = scaled_cell_fraction(coordinate, 3600);
740        assert_eq!(scaled.cell, 2);
741        assert_eq!(scaled.fraction.to_bits(), 0x3fef_ffff_ffff_fffe);
742        assert_eq!(scaled.nearest, 3);
743    }
744
745    #[test]
746    fn nearest_posting_rounds_the_exact_product_instead_of_the_binary64_product() {
747        let half_posting = 1.5_f64 / 3600.0;
748        let coordinate = f64::from_bits(half_posting.to_bits() - 1);
749        assert!(coordinate < half_posting);
750        assert_eq!(coordinate * 3600.0, 1.5);
751
752        assert_eq!(nearest_posting_index(coordinate, 3600), Ok(1));
753    }
754
755    #[test]
756    fn exact_scaled_value_tracks_the_binary64_product_over_deterministic_offsets() {
757        let mut state = 0x764e_279d_9f41_2c03_u64;
758        for _ in 0..10_000 {
759            state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
760            let mut random = state;
761            random = (random ^ (random >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
762            random = (random ^ (random >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
763            random ^= random >> 31;
764            let offset = f64::from_bits(0x3ff0_0000_0000_0000 | (random >> 12)) - 1.0;
765
766            let exact = scaled_cell_fraction(offset, 3600);
767            let reconstructed = exact.cell as f64 + exact.fraction;
768            let naive = offset * 3600.0;
769            assert!(
770                reconstructed.to_bits().abs_diff(naive.to_bits()) <= 1,
771                "offset={offset} exact={reconstructed} naive={naive}"
772            );
773            assert!(exact.cell <= naive.floor() as i64, "offset={offset}");
774        }
775    }
776
777    #[test]
778    fn terrain_block_dir_matches_reference_bucket_names() {
779        assert_eq!(terrain_block_dir(36, -107), "n30_w100");
780        assert_eq!(terrain_block_dir(32, -117), "n30_w110");
781        assert_eq!(terrain_block_dir(43, -112), "n40_w110");
782        assert_eq!(terrain_block_dir(20, -103), "n20_w100");
783        assert_eq!(terrain_block_dir(36, 107), "n30_e100");
784        assert_eq!(terrain_block_dir(-1, -1), "s00_w000");
785        assert_eq!(terrain_block_dir(1, 1), "n00_e000");
786        assert_eq!(terrain_block_dir(-1, 1), "s00_e000");
787        assert_eq!(terrain_block_dir(32, -110), "n30_w110");
788        assert_eq!(terrain_block_dir(32, -111), "n30_w110");
789        assert_eq!(terrain_block_dir(32, -1), "n30_w000");
790        assert_eq!(terrain_block_dir(32, -10), "n30_w010");
791    }
792
793    #[test]
794    fn negative_tile_indices_resolve_to_negative_block_dir() {
795        let nonce = SystemTime::now()
796            .duration_since(UNIX_EPOCH)
797            .expect("system time after epoch")
798            .as_nanos();
799        let root = std::env::temp_dir().join(format!(
800            "sidereon-dted-negative-block-{}-{nonce}",
801            std::process::id()
802        ));
803        let tile_dir = root.join("s00_w000");
804        let tile_path = tile_dir.join("s01_w001_1arc_v3.dt2");
805        fs::create_dir_all(&tile_dir).expect("create nested DTED block dir");
806        fs::write(&tile_path, []).expect("create nested DTED tile path");
807
808        let terrain = DtedTerrain::new(&root);
809        let got = terrain
810            .terrain_path_for_grid(-1, -1)
811            .expect("negative nested tile path");
812        assert_eq!(got, tile_path);
813
814        fs::remove_dir_all(root).expect("remove temp DTED block dir");
815    }
816
817    fn fixture_path(name: &str) -> PathBuf {
818        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
819            .join("tests")
820            .join("fixtures")
821            .join("dted")
822            .join(name)
823    }
824
825    fn bits(v: &Value) -> f64 {
826        f64_from_hex(v.as_str().expect("hex-bit string")).expect("valid f64 bits")
827    }
828
829    fn temp_path(name: &str) -> PathBuf {
830        let nonce = SystemTime::now()
831            .duration_since(UNIX_EPOCH)
832            .expect("system time after epoch")
833            .as_nanos();
834        std::env::temp_dir().join(format!("sidereon-{name}-{}-{nonce}", std::process::id()))
835    }
836
837    fn scalar_loop(
838        root: &Path,
839        points: &[(f64, f64)],
840        options: DtedLookupOptions,
841    ) -> Vec<crate::Result<f64>> {
842        let mut terrain = DtedTerrain::new(root);
843        points
844            .iter()
845            .map(|&(lon, lat)| terrain.height_m_with_options(lon, lat, options))
846            .collect()
847    }
848
849    fn assert_height_results_match(
850        got: &[crate::Result<f64>],
851        want: &[crate::Result<f64>],
852        context: &str,
853    ) {
854        assert_eq!(got.len(), want.len(), "{context} result length");
855        for (idx, (got, want)) in got.iter().zip(want).enumerate() {
856            match (got, want) {
857                (Ok(got), Ok(want)) => assert_eq!(
858                    got.to_bits(),
859                    want.to_bits(),
860                    "{context} index {idx} height bits"
861                ),
862                (Err(got), Err(want)) => {
863                    assert_eq!(got, want, "{context} index {idx} error")
864                }
865                (got, want) => panic!("{context} index {idx} mismatch: {got:?} != {want:?}"),
866            }
867        }
868    }
869
870    fn copy_fixture_tile(root: &Path, tile_name: &str) {
871        fs::copy(
872            fixture_path(&format!("tiles/{tile_name}")),
873            root.join(tile_name),
874        )
875        .expect("copy DTED fixture tile");
876    }
877
878    fn copy_primary_fixture_root(name: &str) -> PathBuf {
879        let root = temp_path(name);
880        fs::create_dir_all(&root).expect("create temp DTED dir");
881        copy_fixture_tile(&root, "n36_w107_1arc_v3.dt2");
882        root
883    }
884
885    fn write_synthetic_dted_tile(
886        path: &Path,
887        lon_count: usize,
888        lat_count: usize,
889        sample: impl Fn(usize, usize) -> i16,
890    ) {
891        let data_block_length = 12 + 2 * lat_count;
892        let mut bytes = vec![b' '; DATA_OFFSET];
893        bytes[0..4].copy_from_slice(b"UHL1");
894        bytes[4..12].copy_from_slice(b"1070000W");
895        bytes[12..20].copy_from_slice(b"0360000N");
896        bytes[47..51].copy_from_slice(format!("{lon_count:04}").as_bytes());
897        bytes[51..55].copy_from_slice(format!("{lat_count:04}").as_bytes());
898
899        for lon_index in 0..lon_count {
900            let mut block = vec![0u8; data_block_length];
901            block[0] = DATA_SENTINEL;
902            for lat_index in 0..lat_count {
903                let sample_start = 8 + lat_index * 2;
904                block[sample_start..sample_start + 2]
905                    .copy_from_slice(&sample(lon_index, lat_index).to_be_bytes());
906            }
907            let checksum = block[..block.len() - 4]
908                .iter()
909                .fold(0i32, |acc, b| acc + i32::from(*b));
910            let checksum_start = block.len() - 4;
911            block[checksum_start..].copy_from_slice(&checksum.to_be_bytes());
912            bytes.extend(block);
913        }
914
915        fs::write(path, bytes).expect("write synthetic DTED tile");
916    }
917
918    #[test]
919    fn dted_rejects_degenerate_header_counts() {
920        let root = temp_path("dted-degenerate-counts");
921        fs::create_dir_all(&root).expect("create temp DTED dir");
922
923        for (lon_count, lat_count) in [(0, 2), (1, 2), (2, 0), (2, 1)] {
924            let tile_path = root.join(format!("tile-{lon_count}-{lat_count}.dt2"));
925            write_synthetic_dted_tile(&tile_path, lon_count, lat_count, |_, _| 0);
926
927            let err = DtedTile::from_path(&tile_path).expect_err("degenerate counts must error");
928            assert!(
929                err.contains("invalid DTED dimensions"),
930                "unexpected error for lon_count={lon_count} lat_count={lat_count}: {err}"
931            );
932        }
933
934        fs::remove_dir_all(root).expect("remove temp DTED dir");
935    }
936
937    #[test]
938    fn dted_lookup_rejects_nonfinite_coordinates() {
939        let root = temp_path("dted-nonfinite-coordinates");
940        let mut terrain = DtedTerrain::new(&root);
941
942        for (lon, lat, field) in [
943            (f64::NAN, 36.5, "longitude_deg"),
944            (f64::INFINITY, 36.5, "longitude_deg"),
945            (f64::NEG_INFINITY, 36.5, "longitude_deg"),
946            (-106.5, f64::NAN, "latitude_deg"),
947            (-106.5, f64::INFINITY, "latitude_deg"),
948            (-106.5, f64::NEG_INFINITY, "latitude_deg"),
949        ] {
950            assert_eq!(
951                terrain
952                    .height_m_with_options(lon, lat, DtedLookupOptions::default())
953                    .expect_err("non-finite DTED coordinate must error"),
954                Error::InvalidInput(format!("{field} must be finite"))
955            );
956        }
957
958        assert_eq!(
959            terrain
960                .height_m(f64::NAN, 36.5)
961                .expect_err("height_m must also reject non-finite coordinates"),
962            Error::InvalidInput("longitude_deg must be finite".to_string())
963        );
964    }
965
966    #[test]
967    fn dted_lookup_rejects_out_of_range_coordinates() {
968        let root = temp_path("dted-out-of-range-coordinates");
969        let mut terrain = DtedTerrain::new(&root);
970
971        for (lon, lat, error) in [
972            (
973                -106.5,
974                91.0,
975                Error::InvalidInput("latitude_deg must be within [-90, 90]".to_string()),
976            ),
977            (
978                -106.5,
979                -90.5,
980                Error::InvalidInput("latitude_deg must be within [-90, 90]".to_string()),
981            ),
982            (
983                200.0,
984                36.5,
985                Error::InvalidInput("longitude_deg must be within [-180, 180]".to_string()),
986            ),
987            (
988                -180.5,
989                36.5,
990                Error::InvalidInput("longitude_deg must be within [-180, 180]".to_string()),
991            ),
992        ] {
993            assert_eq!(
994                terrain
995                    .height_m_with_options(lon, lat, DtedLookupOptions::default())
996                    .expect_err("out-of-range DTED coordinate must error"),
997                error
998            );
999        }
1000
1001        assert_eq!(
1002            terrain
1003                .height_m(-106.5, 36.5)
1004                .expect("missing in-range tile keeps sea-level fallback"),
1005            0.0
1006        );
1007    }
1008
1009    #[test]
1010    fn dted_valid_minimum_tile_parses_and_interpolates() {
1011        let root = temp_path("dted-valid-minimum");
1012        fs::create_dir_all(&root).expect("create temp DTED dir");
1013        let tile_path = root.join("n36_w107_1arc_v3.dt2");
1014        write_synthetic_dted_tile(&tile_path, 2, 2, |lon_index, lat_index| {
1015            match (lon_index, lat_index) {
1016                (0, 0) => 10,
1017                (0, 1) => 30,
1018                (1, 0) => 50,
1019                (1, 1) => 70,
1020                _ => unreachable!("2x2 synthetic tile"),
1021            }
1022        });
1023
1024        DtedTile::from_path(&tile_path).expect("valid 2x2 DTED tile");
1025        let mut terrain = DtedTerrain::new(&root);
1026        assert_eq!(
1027            terrain
1028                .height_m_with_options(
1029                    -106.5,
1030                    36.5,
1031                    DtedLookupOptions {
1032                        interpolation: DtedInterpolation::Bilinear,
1033                    },
1034                )
1035                .expect("bilinear height"),
1036            40.0
1037        );
1038
1039        fs::remove_dir_all(root).expect("remove temp DTED dir");
1040    }
1041
1042    // Fixture provenance: `tests/fixtures/dted/tiles/n36_w107_1arc_v3.dt2` is a
1043    // synthetic public-format DTED tile written by the committed generator
1044    // `crates/sidereon-core/fixtures-generators/generate_dted_points.py` using the
1045    // DTED UHL/DSI/ACC/data-record layout (tile id `n36_w107`, elevation formula
1046    // `z_m = -20 + 7*lon_i - 5*lat_i + lon_i*lat_i`); no external terrain payload is
1047    // copied. `tests/fixtures/dted/dted_points.json` holds nearest-posting and
1048    // bilinear lookup cases generated from that tile. Floating-point fixture
1049    // values are serialized as f64 hex-bit strings and must be compared with
1050    // `f64::to_bits`, never tolerances.
1051    #[test]
1052    fn dted_lookup_matches_generated_fixture_bits() {
1053        let raw =
1054            std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
1055        let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
1056        assert_eq!(doc["schema"], "gnss-dted-points-v1");
1057
1058        let root = copy_primary_fixture_root("dted-fixture-single-scalar");
1059        let mut terrain = DtedTerrain::new(&root);
1060        let nearest = DtedLookupOptions {
1061            interpolation: DtedInterpolation::NearestPosting,
1062        };
1063        let bilinear = DtedLookupOptions {
1064            interpolation: DtedInterpolation::Bilinear,
1065        };
1066
1067        let mut checked = 0usize;
1068        for case in doc["nearest_cases"].as_array().expect("nearest_cases") {
1069            let lon = bits(&case["longitude_bits"]);
1070            let lat = bits(&case["latitude_bits"]);
1071            let got = terrain
1072                .height_m_with_options(lon, lat, nearest)
1073                .expect("nearest DTED height");
1074            let want = bits(&case["elevation_bits"]);
1075            assert_eq!(
1076                got.to_bits(),
1077                want.to_bits(),
1078                "nearest DTED {},{}",
1079                lon,
1080                lat
1081            );
1082            checked += 1;
1083        }
1084
1085        for case in doc["bilinear_cases"].as_array().expect("bilinear_cases") {
1086            let lon = bits(&case["longitude_bits"]);
1087            let lat = bits(&case["latitude_bits"]);
1088            let got = terrain
1089                .height_m_with_options(lon, lat, bilinear)
1090                .expect("bilinear DTED height");
1091            let want = bits(&case["elevation_bits"]);
1092            assert_eq!(
1093                got.to_bits(),
1094                want.to_bits(),
1095                "bilinear DTED {},{}",
1096                lon,
1097                lat
1098            );
1099            checked += 1;
1100        }
1101        assert!(checked > 0, "empty DTED fixture");
1102        fs::remove_dir_all(root).expect("remove temp DTED dir");
1103    }
1104
1105    #[test]
1106    fn height_batch_matches_scalar_loop_on_fixture_bits() {
1107        let raw =
1108            std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
1109        let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
1110        assert_eq!(doc["schema"], "gnss-dted-points-v1");
1111
1112        let points: Vec<(f64, f64)> = ["nearest_cases", "bilinear_cases"]
1113            .into_iter()
1114            .flat_map(|cases_key| {
1115                doc[cases_key]
1116                    .as_array()
1117                    .expect(cases_key)
1118                    .iter()
1119                    .map(|case| (bits(&case["longitude_bits"]), bits(&case["latitude_bits"])))
1120            })
1121            .collect();
1122
1123        for options in [
1124            DtedLookupOptions {
1125                interpolation: DtedInterpolation::NearestPosting,
1126            },
1127            DtedLookupOptions {
1128                interpolation: DtedInterpolation::Bilinear,
1129            },
1130        ] {
1131            let root = copy_primary_fixture_root("dted-fixture-single-batch");
1132            let want = scalar_loop(&root, &points, options);
1133            let mut terrain = DtedTerrain::new(&root);
1134            let got = terrain.height_batch(&points, options);
1135            assert_height_results_match(&got, &want, "single-tile fixture batch");
1136            fs::remove_dir_all(root).expect("remove temp DTED dir");
1137        }
1138    }
1139
1140    #[test]
1141    fn height_batch_matches_scalar_loop_across_adjacent_tiles_bits() {
1142        let root = fixture_path("tiles");
1143        let options = DtedLookupOptions {
1144            interpolation: DtedInterpolation::Bilinear,
1145        };
1146        let raw =
1147            std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
1148        let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
1149        for case in doc["multi_tile_cases"]
1150            .as_array()
1151            .expect("multi_tile_cases")
1152        {
1153            let lon = bits(&case["longitude_bits"]);
1154            let lat = bits(&case["latitude_bits"]);
1155            let expected = bits(&case["bilinear_bits"]);
1156            let mut terrain = DtedTerrain::new(&root);
1157            let got = terrain
1158                .height_m_with_options(lon, lat, options)
1159                .expect("multi-tile generated bilinear height");
1160            assert_eq!(
1161                got.to_bits(),
1162                expected.to_bits(),
1163                "multi-tile generated case {}",
1164                case["case_id"].as_str().expect("case_id")
1165            );
1166        }
1167
1168        let sequences = [
1169            (
1170                "all_in_a_then_all_in_b",
1171                vec![
1172                    (-106.875, 36.125),
1173                    (-106.625, 36.375),
1174                    (-105.875, 36.125),
1175                    (-105.625, 36.375),
1176                ],
1177            ),
1178            (
1179                "interleaved_a_b_a_b",
1180                vec![
1181                    (-106.875, 36.625),
1182                    (-105.875, 36.625),
1183                    (-106.625, 36.125),
1184                    (-105.625, 36.125),
1185                ],
1186            ),
1187            (
1188                "boundary_after_a_then_missing",
1189                vec![
1190                    (-106.875, 36.5),
1191                    (-106.0, 36.5),
1192                    (-104.5, 36.5),
1193                    (-105.875, 36.5),
1194                ],
1195            ),
1196        ];
1197
1198        for (name, points) in sequences {
1199            let want = scalar_loop(&root, &points, options);
1200            let mut terrain = DtedTerrain::new(&root);
1201            let got = terrain.height_batch(&points, options);
1202            assert_height_results_match(&got, &want, name);
1203        }
1204
1205        let mut terrain = DtedTerrain::new(&root);
1206        let missing = terrain.height_batch(&[(-104.5, 36.5)], options);
1207        assert_eq!(
1208            missing[0].as_ref().map(|v| v.to_bits()),
1209            Ok(0.0f64.to_bits())
1210        );
1211    }
1212
1213    #[test]
1214    fn height_batch_places_errors_at_input_indices() {
1215        let root = temp_path("dted-batch-errors");
1216        fs::create_dir_all(&root).expect("create temp DTED dir");
1217        copy_fixture_tile(&root, "n36_w107_1arc_v3.dt2");
1218        copy_fixture_tile(&root, "n36_w106_1arc_v3.dt2");
1219        fs::write(root.join("n37_w107_1arc_v3.dt2"), b"not a DTED tile")
1220            .expect("write corrupt DTED tile");
1221
1222        let points = [
1223            (-106.875, 36.125),
1224            (-106.5, f64::NAN),
1225            (-105.875, 36.125),
1226            (-106.5, 37.5),
1227            (-106.625, 36.375),
1228        ];
1229        let options = DtedLookupOptions {
1230            interpolation: DtedInterpolation::Bilinear,
1231        };
1232        let want = scalar_loop(&root, &points, options);
1233        let mut terrain = DtedTerrain::new(&root);
1234        let got = terrain.height_batch(&points, options);
1235        assert_height_results_match(&got, &want, "batch error placement");
1236
1237        assert!(got[0].is_ok(), "index 0 remains valid");
1238        assert_eq!(
1239            got[1],
1240            Err(Error::InvalidInput(
1241                "latitude_deg must be finite".to_string()
1242            ))
1243        );
1244        assert!(got[2].is_ok(), "index 2 remains valid");
1245        assert!(
1246            matches!(&got[3], Err(Error::Parse(msg)) if msg.contains("too short")),
1247            "index 3 is the corrupt-tile error: {:?}",
1248            got[3]
1249        );
1250        assert!(got[4].is_ok(), "index 4 remains valid");
1251
1252        fs::remove_dir_all(root).expect("remove temp DTED dir");
1253    }
1254}