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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum DtedInterpolation {
22    NearestPosting,
23    Bilinear,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct DtedLookupOptions {
28    pub interpolation: DtedInterpolation,
29}
30
31impl Default for DtedLookupOptions {
32    fn default() -> Self {
33        Self {
34            interpolation: DtedInterpolation::Bilinear,
35        }
36    }
37}
38
39#[derive(Debug)]
40pub struct DtedTerrain {
41    root: PathBuf,
42    tiles: HashMap<(i32, i32), DtedTile>,
43}
44
45impl DtedTerrain {
46    pub fn new(root: impl Into<PathBuf>) -> Self {
47        Self {
48            root: root.into(),
49            tiles: HashMap::new(),
50        }
51    }
52
53    pub fn height_m(&mut self, longitude_deg: f64, latitude_deg: f64) -> crate::Result<f64> {
54        self.height_m_with_options(longitude_deg, latitude_deg, DtedLookupOptions::default())
55    }
56
57    pub fn height_m_with_options(
58        &mut self,
59        longitude_deg: f64,
60        latitude_deg: f64,
61        options: DtedLookupOptions,
62    ) -> crate::Result<f64> {
63        validate_lookup_coordinates(longitude_deg, latitude_deg)?;
64        let Some(tile) = self.load_tile(longitude_deg, latitude_deg)? else {
65            return Ok(0.0);
66        };
67        if options.interpolation == DtedInterpolation::NearestPosting {
68            return tile
69                .get_elevation(longitude_deg, latitude_deg)
70                .map(|v| v as f64)
71                .map_err(Error::Parse);
72        }
73
74        let postings_per_deg_lon = tile.lon_count - 1;
75        let postings_per_deg_lat = tile.lat_count - 1;
76
77        let lon_idx = (longitude_deg - tile.origin_longitude) * postings_per_deg_lon as f64;
78        let lat_idx = (latitude_deg - tile.origin_latitude) * postings_per_deg_lat as f64;
79        let lon_lo = lon_idx.floor() as i64;
80        let lat_lo = lat_idx.floor() as i64;
81        let fx = lon_idx - lon_lo as f64;
82        let fy = lat_idx - lat_lo as f64;
83
84        let mut z = 0.0;
85        for (di, wx) in [(0i64, 1.0 - fx), (1i64, fx)] {
86            for (dj, wy) in [(0i64, 1.0 - fy), (1i64, fy)] {
87                let w = wx * wy;
88                if w == 0.0 {
89                    continue;
90                }
91                let posting_lon =
92                    tile.origin_longitude + (lon_lo + di) as f64 / postings_per_deg_lon as f64;
93                let posting_lat =
94                    tile.origin_latitude + (lat_lo + dj) as f64 / postings_per_deg_lat as f64;
95                z += w * f64::from(
96                    tile.get_elevation(posting_lon, posting_lat)
97                        .map_err(Error::Parse)?,
98                );
99            }
100        }
101        Ok(z)
102    }
103
104    fn load_tile(&mut self, longitude: f64, latitude: f64) -> crate::Result<Option<&DtedTile>> {
105        let mut selected = None;
106        for grid_idx in terrain_grid_candidates(longitude, latitude) {
107            if !self.tiles.contains_key(&grid_idx) {
108                let Some(path) = self.terrain_path_for_grid(grid_idx.0, grid_idx.1) else {
109                    continue;
110                };
111                if !path.is_file() {
112                    continue;
113                }
114                let tile = DtedTile::from_path(path).map_err(Error::Parse)?;
115                self.tiles.insert(grid_idx, tile);
116            }
117            if let Some(tile) = self.tiles.get(&grid_idx) {
118                if tile.contains(longitude, latitude) {
119                    selected = Some(grid_idx);
120                    break;
121                }
122            }
123        }
124        Ok(selected.and_then(|grid_idx| self.tiles.get(&grid_idx)))
125    }
126
127    fn terrain_path_for_grid(&self, latitude_index: i32, longitude_index: i32) -> Option<PathBuf> {
128        let tile_name = format!(
129            "{}_{}{}",
130            format_lat(latitude_index),
131            format_lon(longitude_index),
132            DTED_SUFFIX
133        );
134
135        let direct = self.root.join(&tile_name);
136        if direct.is_file() {
137            return Some(direct);
138        }
139
140        let block_dir = terrain_block_dir(latitude_index, longitude_index);
141        let nested = self.root.join(&block_dir).join(&tile_name);
142        if nested.is_file() {
143            return Some(nested);
144        }
145
146        let sibling = self.root.parent()?.join(&block_dir).join(&tile_name);
147        sibling.is_file().then_some(sibling)
148    }
149}
150
151fn validate_lookup_coordinates(longitude_deg: f64, latitude_deg: f64) -> crate::Result<()> {
152    if !longitude_deg.is_finite() {
153        return Err(Error::InvalidInput(
154            "longitude_deg must be finite".to_string(),
155        ));
156    }
157    if !latitude_deg.is_finite() {
158        return Err(Error::InvalidInput(
159            "latitude_deg must be finite".to_string(),
160        ));
161    }
162    if !(MIN_LOOKUP_LONGITUDE_DEG..=MAX_LOOKUP_LONGITUDE_DEG).contains(&longitude_deg) {
163        return Err(Error::InvalidInput(
164            "longitude_deg must be within [-180, 180]".to_string(),
165        ));
166    }
167    if !(MIN_LOOKUP_LATITUDE_DEG..=MAX_LOOKUP_LATITUDE_DEG).contains(&latitude_deg) {
168        return Err(Error::InvalidInput(
169            "latitude_deg must be within [-90, 90]".to_string(),
170        ));
171    }
172    Ok(())
173}
174
175#[derive(Debug)]
176pub struct DtedTile {
177    origin_latitude: f64,
178    origin_longitude: f64,
179    lon_count: usize,
180    lat_count: usize,
181    data_block_length: usize,
182    bytes: Vec<u8>,
183}
184
185impl DtedTile {
186    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, String> {
187        let bytes =
188            fs::read(path.as_ref()).map_err(|e| format!("{}: {e}", path.as_ref().display()))?;
189        if bytes.len() < DATA_OFFSET {
190            return Err(format!(
191                "{} is too short for DTED headers",
192                path.as_ref().display()
193            ));
194        }
195        if &bytes[0..4] != b"UHL1" {
196            return Err(format!("{} missing UHL1 header", path.as_ref().display()));
197        }
198
199        let origin_longitude =
200            parse_dted_coord(std::str::from_utf8(&bytes[4..12]).map_err(|e| e.to_string())?)?;
201        let origin_latitude =
202            parse_dted_coord(std::str::from_utf8(&bytes[12..20]).map_err(|e| e.to_string())?)?;
203        let lon_count = parse_ascii_usize(&bytes[47..51])?;
204        let lat_count = parse_ascii_usize(&bytes[51..55])?;
205        if lon_count < 2 || lat_count < 2 {
206            return Err(format!(
207                "{} has invalid DTED dimensions lon_count={} lat_count={}; both must be at least 2",
208                path.as_ref().display(),
209                lon_count,
210                lat_count
211            ));
212        }
213        let data_block_length = 12 + 2 * lat_count;
214        let expected_len = DATA_OFFSET + lon_count * data_block_length;
215        if bytes.len() < expected_len {
216            return Err(format!(
217                "{} has {} bytes but expected at least {}",
218                path.as_ref().display(),
219                bytes.len(),
220                expected_len
221            ));
222        }
223
224        Ok(Self {
225            origin_latitude,
226            origin_longitude,
227            lon_count,
228            lat_count,
229            data_block_length,
230            bytes,
231        })
232    }
233
234    pub fn get_elevation(&self, longitude: f64, latitude: f64) -> Result<i16, String> {
235        if !self.contains(longitude, latitude) {
236            return Err(format!(
237                "point ({longitude},{latitude}) is outside DTED tile ({},{})",
238                self.origin_longitude, self.origin_latitude
239            ));
240        }
241
242        let latitude_index =
243            py_round_to_usize((latitude - self.origin_latitude) * (self.lat_count - 1) as f64)?;
244        let longitude_index =
245            py_round_to_usize((longitude - self.origin_longitude) * (self.lon_count - 1) as f64)?;
246        if latitude_index >= self.lat_count || longitude_index >= self.lon_count {
247            return Err(format!(
248                "posting index out of bounds lon={longitude_index} lat={latitude_index}"
249            ));
250        }
251
252        let block_start = DATA_OFFSET + longitude_index * self.data_block_length;
253        let block_end = block_start + self.data_block_length;
254        let block = &self.bytes[block_start..block_end];
255        if block[0] != DATA_SENTINEL {
256            return Err(format!(
257                "DTED block {longitude_index} missing data sentinel"
258            ));
259        }
260        let checksum = i32::from_be_bytes([
261            block[block.len() - 4],
262            block[block.len() - 3],
263            block[block.len() - 2],
264            block[block.len() - 1],
265        ]);
266        let sum = block[..block.len() - 4]
267            .iter()
268            .fold(0i32, |acc, b| acc + i32::from(*b));
269        if sum != checksum {
270            return Err(format!(
271                "DTED checksum failed for block {longitude_index}: expected {checksum}, found {sum}"
272            ));
273        }
274
275        let sample_start = 8 + latitude_index * 2;
276        let raw = i16::from_be_bytes([block[sample_start], block[sample_start + 1]]);
277        Ok(convert_signed_magnitude(raw))
278    }
279
280    fn contains(&self, longitude: f64, latitude: f64) -> bool {
281        latitude >= self.origin_latitude
282            && latitude <= self.origin_latitude + 1.0
283            && longitude >= self.origin_longitude
284            && longitude <= self.origin_longitude + 1.0
285    }
286}
287
288pub(crate) fn terrain_grid(longitude: f64, latitude: f64) -> (i32, i32) {
289    (latitude.floor() as i32, longitude.floor() as i32)
290}
291
292fn terrain_grid_candidates(longitude: f64, latitude: f64) -> Vec<(i32, i32)> {
293    let (lat, lon) = terrain_grid(longitude, latitude);
294    let mut out = vec![(lat, lon)];
295    let on_lat_edge = latitude == latitude.floor();
296    let on_lon_edge = longitude == longitude.floor();
297    if on_lat_edge {
298        out.push((lat - 1, lon));
299    }
300    if on_lon_edge {
301        out.push((lat, lon - 1));
302    }
303    if on_lat_edge && on_lon_edge {
304        out.push((lat - 1, lon - 1));
305    }
306    out
307}
308
309pub(crate) fn format_lat(latitude_index: i32) -> String {
310    if latitude_index >= 0 {
311        format!("n{latitude_index:02}")
312    } else {
313        format!("s{:02}", -latitude_index)
314    }
315}
316
317pub(crate) fn format_lon(longitude_index: i32) -> String {
318    if longitude_index >= 0 {
319        format!("e{longitude_index:03}")
320    } else {
321        format!("w{:03}", -longitude_index)
322    }
323}
324
325pub(crate) fn terrain_block_dir(latitude_index: i32, longitude_index: i32) -> String {
326    format!(
327        "{}_{}",
328        format_block_lat(latitude_index),
329        format_block_lon(longitude_index)
330    )
331}
332
333fn format_block_lat(latitude_index: i32) -> String {
334    let origin = block_origin(latitude_index);
335    if latitude_index >= 0 {
336        format!("n{origin:02}")
337    } else {
338        format!("s{origin:02}")
339    }
340}
341
342fn format_block_lon(longitude_index: i32) -> String {
343    let origin = block_origin(longitude_index);
344    if longitude_index >= 0 {
345        format!("e{origin:03}")
346    } else {
347        format!("w{origin:03}")
348    }
349}
350
351pub(crate) fn block_origin(index: i32) -> u32 {
352    (index.unsigned_abs() / 10) * 10
353}
354
355fn parse_ascii_usize(bytes: &[u8]) -> Result<usize, String> {
356    std::str::from_utf8(bytes)
357        .map_err(|e| e.to_string())?
358        .trim()
359        .parse::<usize>()
360        .map_err(|e| e.to_string())
361}
362
363fn parse_dted_coord(input: &str) -> Result<f64, String> {
364    let hemi = input
365        .chars()
366        .last()
367        .ok_or_else(|| "empty DTED coordinate".to_string())?;
368    let sign = match hemi {
369        'S' | 'W' => -1.0,
370        'N' | 'E' => 1.0,
371        _ => return Err(format!("invalid DTED hemisphere {hemi}")),
372    };
373    let coord = &input[..input.len() - 1];
374    let seconds_index = if coord.as_bytes().get(coord.len().saturating_sub(2)) == Some(&b'.') {
375        coord.len() - 4
376    } else {
377        coord.len() - 2
378    };
379    let minutes_index = seconds_index - 2;
380    let degree = coord[..minutes_index]
381        .parse::<i32>()
382        .map_err(|e| e.to_string())?;
383    let minute = coord[minutes_index..seconds_index]
384        .parse::<i32>()
385        .map_err(|e| e.to_string())?;
386    let second = coord[seconds_index..]
387        .parse::<f64>()
388        .map_err(|e| e.to_string())?;
389    Ok(sign * (degree as f64 + ((minute as f64 + second / 60.0) / 60.0)))
390}
391
392fn py_round_to_usize(value: f64) -> Result<usize, String> {
393    if value < 0.0 {
394        return Err(format!("cannot round negative posting index {value}"));
395    }
396    let lo = value.floor();
397    let frac = value - lo;
398    let rounded = if frac < 0.5 {
399        lo
400    } else if frac > 0.5 {
401        lo + 1.0
402    } else {
403        let lo_i = lo as u64;
404        if lo_i.is_multiple_of(2) {
405            lo
406        } else {
407            lo + 1.0
408        }
409    };
410    Ok(rounded as usize)
411}
412
413fn convert_signed_magnitude(raw: i16) -> i16 {
414    if raw < 0 {
415        (-32768i32 - i32::from(raw)) as i16
416    } else {
417        raw
418    }
419}
420
421#[cfg(all(test, sidereon_repo_tests))]
422mod tests {
423    use std::fs;
424    use std::path::Path;
425    use std::path::PathBuf;
426    use std::time::{SystemTime, UNIX_EPOCH};
427
428    use serde_json::Value;
429
430    use crate::test_parity::f64_from_hex;
431    use crate::Error;
432
433    use super::{
434        terrain_block_dir, DtedInterpolation, DtedLookupOptions, DtedTerrain, DtedTile,
435        DATA_OFFSET, DATA_SENTINEL,
436    };
437
438    #[test]
439    fn terrain_block_dir_matches_reference_bucket_names() {
440        assert_eq!(terrain_block_dir(36, -107), "n30_w100");
441        assert_eq!(terrain_block_dir(32, -117), "n30_w110");
442        assert_eq!(terrain_block_dir(43, -112), "n40_w110");
443        assert_eq!(terrain_block_dir(20, -103), "n20_w100");
444        assert_eq!(terrain_block_dir(36, 107), "n30_e100");
445        assert_eq!(terrain_block_dir(-1, -1), "s00_w000");
446        assert_eq!(terrain_block_dir(1, 1), "n00_e000");
447        assert_eq!(terrain_block_dir(-1, 1), "s00_e000");
448        assert_eq!(terrain_block_dir(32, -110), "n30_w110");
449        assert_eq!(terrain_block_dir(32, -111), "n30_w110");
450        assert_eq!(terrain_block_dir(32, -1), "n30_w000");
451        assert_eq!(terrain_block_dir(32, -10), "n30_w010");
452    }
453
454    #[test]
455    fn negative_tile_indices_resolve_to_negative_block_dir() {
456        let nonce = SystemTime::now()
457            .duration_since(UNIX_EPOCH)
458            .expect("system time after epoch")
459            .as_nanos();
460        let root = std::env::temp_dir().join(format!(
461            "sidereon-dted-negative-block-{}-{nonce}",
462            std::process::id()
463        ));
464        let tile_dir = root.join("s00_w000");
465        let tile_path = tile_dir.join("s01_w001_1arc_v3.dt2");
466        fs::create_dir_all(&tile_dir).expect("create nested DTED block dir");
467        fs::write(&tile_path, []).expect("create nested DTED tile path");
468
469        let terrain = DtedTerrain::new(&root);
470        let got = terrain
471            .terrain_path_for_grid(-1, -1)
472            .expect("negative nested tile path");
473        assert_eq!(got, tile_path);
474
475        fs::remove_dir_all(root).expect("remove temp DTED block dir");
476    }
477
478    fn fixture_path(name: &str) -> PathBuf {
479        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
480            .join("tests")
481            .join("fixtures")
482            .join("dted")
483            .join(name)
484    }
485
486    fn bits(v: &Value) -> f64 {
487        f64_from_hex(v.as_str().expect("hex-bit string")).expect("valid f64 bits")
488    }
489
490    fn temp_path(name: &str) -> PathBuf {
491        let nonce = SystemTime::now()
492            .duration_since(UNIX_EPOCH)
493            .expect("system time after epoch")
494            .as_nanos();
495        std::env::temp_dir().join(format!("sidereon-{name}-{}-{nonce}", std::process::id()))
496    }
497
498    fn write_synthetic_dted_tile(
499        path: &Path,
500        lon_count: usize,
501        lat_count: usize,
502        sample: impl Fn(usize, usize) -> i16,
503    ) {
504        let data_block_length = 12 + 2 * lat_count;
505        let mut bytes = vec![b' '; DATA_OFFSET];
506        bytes[0..4].copy_from_slice(b"UHL1");
507        bytes[4..12].copy_from_slice(b"1070000W");
508        bytes[12..20].copy_from_slice(b"0360000N");
509        bytes[47..51].copy_from_slice(format!("{lon_count:04}").as_bytes());
510        bytes[51..55].copy_from_slice(format!("{lat_count:04}").as_bytes());
511
512        for lon_index in 0..lon_count {
513            let mut block = vec![0u8; data_block_length];
514            block[0] = DATA_SENTINEL;
515            for lat_index in 0..lat_count {
516                let sample_start = 8 + lat_index * 2;
517                block[sample_start..sample_start + 2]
518                    .copy_from_slice(&sample(lon_index, lat_index).to_be_bytes());
519            }
520            let checksum = block[..block.len() - 4]
521                .iter()
522                .fold(0i32, |acc, b| acc + i32::from(*b));
523            let checksum_start = block.len() - 4;
524            block[checksum_start..].copy_from_slice(&checksum.to_be_bytes());
525            bytes.extend(block);
526        }
527
528        fs::write(path, bytes).expect("write synthetic DTED tile");
529    }
530
531    #[test]
532    fn dted_rejects_degenerate_header_counts() {
533        let root = temp_path("dted-degenerate-counts");
534        fs::create_dir_all(&root).expect("create temp DTED dir");
535
536        for (lon_count, lat_count) in [(0, 2), (1, 2), (2, 0), (2, 1)] {
537            let tile_path = root.join(format!("tile-{lon_count}-{lat_count}.dt2"));
538            write_synthetic_dted_tile(&tile_path, lon_count, lat_count, |_, _| 0);
539
540            let err = DtedTile::from_path(&tile_path).expect_err("degenerate counts must error");
541            assert!(
542                err.contains("invalid DTED dimensions"),
543                "unexpected error for lon_count={lon_count} lat_count={lat_count}: {err}"
544            );
545        }
546
547        fs::remove_dir_all(root).expect("remove temp DTED dir");
548    }
549
550    #[test]
551    fn dted_lookup_rejects_nonfinite_coordinates() {
552        let root = temp_path("dted-nonfinite-coordinates");
553        let mut terrain = DtedTerrain::new(&root);
554
555        for (lon, lat, field) in [
556            (f64::NAN, 36.5, "longitude_deg"),
557            (f64::INFINITY, 36.5, "longitude_deg"),
558            (f64::NEG_INFINITY, 36.5, "longitude_deg"),
559            (-106.5, f64::NAN, "latitude_deg"),
560            (-106.5, f64::INFINITY, "latitude_deg"),
561            (-106.5, f64::NEG_INFINITY, "latitude_deg"),
562        ] {
563            assert_eq!(
564                terrain
565                    .height_m_with_options(lon, lat, DtedLookupOptions::default())
566                    .expect_err("non-finite DTED coordinate must error"),
567                Error::InvalidInput(format!("{field} must be finite"))
568            );
569        }
570
571        assert_eq!(
572            terrain
573                .height_m(f64::NAN, 36.5)
574                .expect_err("height_m must also reject non-finite coordinates"),
575            Error::InvalidInput("longitude_deg must be finite".to_string())
576        );
577    }
578
579    #[test]
580    fn dted_lookup_rejects_out_of_range_coordinates() {
581        let root = temp_path("dted-out-of-range-coordinates");
582        let mut terrain = DtedTerrain::new(&root);
583
584        for (lon, lat, error) in [
585            (
586                -106.5,
587                91.0,
588                Error::InvalidInput("latitude_deg must be within [-90, 90]".to_string()),
589            ),
590            (
591                -106.5,
592                -90.5,
593                Error::InvalidInput("latitude_deg must be within [-90, 90]".to_string()),
594            ),
595            (
596                200.0,
597                36.5,
598                Error::InvalidInput("longitude_deg must be within [-180, 180]".to_string()),
599            ),
600            (
601                -180.5,
602                36.5,
603                Error::InvalidInput("longitude_deg must be within [-180, 180]".to_string()),
604            ),
605        ] {
606            assert_eq!(
607                terrain
608                    .height_m_with_options(lon, lat, DtedLookupOptions::default())
609                    .expect_err("out-of-range DTED coordinate must error"),
610                error
611            );
612        }
613
614        assert_eq!(
615            terrain
616                .height_m(-106.5, 36.5)
617                .expect("missing in-range tile keeps sea-level fallback"),
618            0.0
619        );
620    }
621
622    #[test]
623    fn dted_valid_minimum_tile_parses_and_interpolates() {
624        let root = temp_path("dted-valid-minimum");
625        fs::create_dir_all(&root).expect("create temp DTED dir");
626        let tile_path = root.join("n36_w107_1arc_v3.dt2");
627        write_synthetic_dted_tile(&tile_path, 2, 2, |lon_index, lat_index| {
628            match (lon_index, lat_index) {
629                (0, 0) => 10,
630                (0, 1) => 30,
631                (1, 0) => 50,
632                (1, 1) => 70,
633                _ => unreachable!("2x2 synthetic tile"),
634            }
635        });
636
637        DtedTile::from_path(&tile_path).expect("valid 2x2 DTED tile");
638        let mut terrain = DtedTerrain::new(&root);
639        assert_eq!(
640            terrain
641                .height_m_with_options(
642                    -106.5,
643                    36.5,
644                    DtedLookupOptions {
645                        interpolation: DtedInterpolation::Bilinear,
646                    },
647                )
648                .expect("bilinear height"),
649            40.0
650        );
651
652        fs::remove_dir_all(root).expect("remove temp DTED dir");
653    }
654
655    // Fixture provenance: `tests/fixtures/dted/tiles/n36_w107_1arc_v3.dt2` is a
656    // synthetic public-format DTED tile written by the committed generator
657    // `crates/sidereon-core/fixtures-generators/generate_dted_points.py` using the
658    // DTED UHL/DSI/ACC/data-record layout (tile id `n36_w107`, elevation formula
659    // `z_m = -20 + 7*lon_i - 5*lat_i + lon_i*lat_i`); no external terrain payload is
660    // copied. `tests/fixtures/dted/dted_points.json` holds nearest-posting and
661    // bilinear lookup cases generated from that tile. Generated with Python 3.11.15
662    // on macOS-26.5.1-arm64. Floating-point fixture values are serialized as f64
663    // hex-bit strings and must be compared with `f64::to_bits`, never tolerances.
664    #[test]
665    fn dted_lookup_matches_generated_fixture_bits() {
666        let raw =
667            std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
668        let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
669        assert_eq!(doc["schema"], "gnss-dted-points-v1");
670
671        let mut terrain = DtedTerrain::new(fixture_path("tiles"));
672        let nearest = DtedLookupOptions {
673            interpolation: DtedInterpolation::NearestPosting,
674        };
675        let bilinear = DtedLookupOptions {
676            interpolation: DtedInterpolation::Bilinear,
677        };
678
679        let mut checked = 0usize;
680        for case in doc["nearest_cases"].as_array().expect("nearest_cases") {
681            let lon = bits(&case["longitude_bits"]);
682            let lat = bits(&case["latitude_bits"]);
683            let got = terrain
684                .height_m_with_options(lon, lat, nearest)
685                .expect("nearest DTED height");
686            let want = bits(&case["elevation_bits"]);
687            assert_eq!(
688                got.to_bits(),
689                want.to_bits(),
690                "nearest DTED {},{}",
691                lon,
692                lat
693            );
694            checked += 1;
695        }
696
697        for case in doc["bilinear_cases"].as_array().expect("bilinear_cases") {
698            let lon = bits(&case["longitude_bits"]);
699            let lat = bits(&case["latitude_bits"]);
700            let got = terrain
701                .height_m_with_options(lon, lat, bilinear)
702                .expect("bilinear DTED height");
703            let want = bits(&case["elevation_bits"]);
704            assert_eq!(
705                got.to_bits(),
706                want.to_bits(),
707                "bilinear DTED {},{}",
708                lon,
709                lat
710            );
711            checked += 1;
712        }
713        assert!(checked > 0, "empty DTED fixture");
714    }
715}