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
9const UHL_SIZE: usize = 80;
10const DSI_SIZE: usize = 648;
11const ACC_SIZE: usize = 2700;
12const DATA_OFFSET: usize = UHL_SIZE + DSI_SIZE + ACC_SIZE;
13const DATA_SENTINEL: u8 = 0xAA;
14const 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
288fn 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
309fn 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
317fn 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
325fn terrain_block_dir(latitude_index: i32, longitude_index: i32) -> String {
326    format!(
327        "{}_{}",
328        format_lat(block_origin(latitude_index)),
329        format_lon(block_origin(longitude_index))
330    )
331}
332
333fn block_origin(index: i32) -> i32 {
334    index.div_euclid(10) * 10
335}
336
337fn parse_ascii_usize(bytes: &[u8]) -> Result<usize, String> {
338    std::str::from_utf8(bytes)
339        .map_err(|e| e.to_string())?
340        .trim()
341        .parse::<usize>()
342        .map_err(|e| e.to_string())
343}
344
345fn parse_dted_coord(input: &str) -> Result<f64, String> {
346    let hemi = input
347        .chars()
348        .last()
349        .ok_or_else(|| "empty DTED coordinate".to_string())?;
350    let sign = match hemi {
351        'S' | 'W' => -1.0,
352        'N' | 'E' => 1.0,
353        _ => return Err(format!("invalid DTED hemisphere {hemi}")),
354    };
355    let coord = &input[..input.len() - 1];
356    let seconds_index = if coord.as_bytes().get(coord.len().saturating_sub(2)) == Some(&b'.') {
357        coord.len() - 4
358    } else {
359        coord.len() - 2
360    };
361    let minutes_index = seconds_index - 2;
362    let degree = coord[..minutes_index]
363        .parse::<i32>()
364        .map_err(|e| e.to_string())?;
365    let minute = coord[minutes_index..seconds_index]
366        .parse::<i32>()
367        .map_err(|e| e.to_string())?;
368    let second = coord[seconds_index..]
369        .parse::<f64>()
370        .map_err(|e| e.to_string())?;
371    Ok(sign * (degree as f64 + ((minute as f64 + second / 60.0) / 60.0)))
372}
373
374fn py_round_to_usize(value: f64) -> Result<usize, String> {
375    if value < 0.0 {
376        return Err(format!("cannot round negative posting index {value}"));
377    }
378    let lo = value.floor();
379    let frac = value - lo;
380    let rounded = if frac < 0.5 {
381        lo
382    } else if frac > 0.5 {
383        lo + 1.0
384    } else {
385        let lo_i = lo as u64;
386        if lo_i.is_multiple_of(2) {
387            lo
388        } else {
389            lo + 1.0
390        }
391    };
392    Ok(rounded as usize)
393}
394
395fn convert_signed_magnitude(raw: i16) -> i16 {
396    if raw < 0 {
397        (-32768i32 - i32::from(raw)) as i16
398    } else {
399        raw
400    }
401}
402
403#[cfg(all(test, sidereon_repo_tests))]
404mod tests {
405    use std::fs;
406    use std::path::Path;
407    use std::path::PathBuf;
408    use std::time::{SystemTime, UNIX_EPOCH};
409
410    use serde_json::Value;
411
412    use crate::test_parity::f64_from_hex;
413    use crate::Error;
414
415    use super::{
416        terrain_block_dir, DtedInterpolation, DtedLookupOptions, DtedTerrain, DtedTile,
417        DATA_OFFSET, DATA_SENTINEL,
418    };
419
420    #[test]
421    fn terrain_block_dir_matches_reference_bucket_names() {
422        assert_eq!(terrain_block_dir(36, -107), "n30_w110");
423        assert_eq!(terrain_block_dir(32, -117), "n30_w120");
424        assert_eq!(terrain_block_dir(43, -112), "n40_w120");
425        assert_eq!(terrain_block_dir(20, -103), "n20_w110");
426        assert_eq!(terrain_block_dir(36, 107), "n30_e100");
427        assert_eq!(terrain_block_dir(-1, -1), "s10_w010");
428    }
429
430    #[test]
431    fn negative_tile_indices_resolve_to_negative_block_dir() {
432        let nonce = SystemTime::now()
433            .duration_since(UNIX_EPOCH)
434            .expect("system time after epoch")
435            .as_nanos();
436        let root = std::env::temp_dir().join(format!(
437            "sidereon-dted-negative-block-{}-{nonce}",
438            std::process::id()
439        ));
440        let tile_dir = root.join("s10_w010");
441        let tile_path = tile_dir.join("s01_w001_1arc_v3.dt2");
442        fs::create_dir_all(&tile_dir).expect("create nested DTED block dir");
443        fs::write(&tile_path, []).expect("create nested DTED tile path");
444
445        let terrain = DtedTerrain::new(&root);
446        let got = terrain
447            .terrain_path_for_grid(-1, -1)
448            .expect("negative nested tile path");
449        assert_eq!(got, tile_path);
450
451        fs::remove_dir_all(root).expect("remove temp DTED block dir");
452    }
453
454    fn fixture_path(name: &str) -> PathBuf {
455        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
456            .join("tests")
457            .join("fixtures")
458            .join("dted")
459            .join(name)
460    }
461
462    fn bits(v: &Value) -> f64 {
463        f64_from_hex(v.as_str().expect("hex-bit string")).expect("valid f64 bits")
464    }
465
466    fn temp_path(name: &str) -> PathBuf {
467        let nonce = SystemTime::now()
468            .duration_since(UNIX_EPOCH)
469            .expect("system time after epoch")
470            .as_nanos();
471        std::env::temp_dir().join(format!("sidereon-{name}-{}-{nonce}", std::process::id()))
472    }
473
474    fn write_synthetic_dted_tile(
475        path: &Path,
476        lon_count: usize,
477        lat_count: usize,
478        sample: impl Fn(usize, usize) -> i16,
479    ) {
480        let data_block_length = 12 + 2 * lat_count;
481        let mut bytes = vec![b' '; DATA_OFFSET];
482        bytes[0..4].copy_from_slice(b"UHL1");
483        bytes[4..12].copy_from_slice(b"1070000W");
484        bytes[12..20].copy_from_slice(b"0360000N");
485        bytes[47..51].copy_from_slice(format!("{lon_count:04}").as_bytes());
486        bytes[51..55].copy_from_slice(format!("{lat_count:04}").as_bytes());
487
488        for lon_index in 0..lon_count {
489            let mut block = vec![0u8; data_block_length];
490            block[0] = DATA_SENTINEL;
491            for lat_index in 0..lat_count {
492                let sample_start = 8 + lat_index * 2;
493                block[sample_start..sample_start + 2]
494                    .copy_from_slice(&sample(lon_index, lat_index).to_be_bytes());
495            }
496            let checksum = block[..block.len() - 4]
497                .iter()
498                .fold(0i32, |acc, b| acc + i32::from(*b));
499            let checksum_start = block.len() - 4;
500            block[checksum_start..].copy_from_slice(&checksum.to_be_bytes());
501            bytes.extend(block);
502        }
503
504        fs::write(path, bytes).expect("write synthetic DTED tile");
505    }
506
507    #[test]
508    fn dted_rejects_degenerate_header_counts() {
509        let root = temp_path("dted-degenerate-counts");
510        fs::create_dir_all(&root).expect("create temp DTED dir");
511
512        for (lon_count, lat_count) in [(0, 2), (1, 2), (2, 0), (2, 1)] {
513            let tile_path = root.join(format!("tile-{lon_count}-{lat_count}.dt2"));
514            write_synthetic_dted_tile(&tile_path, lon_count, lat_count, |_, _| 0);
515
516            let err = DtedTile::from_path(&tile_path).expect_err("degenerate counts must error");
517            assert!(
518                err.contains("invalid DTED dimensions"),
519                "unexpected error for lon_count={lon_count} lat_count={lat_count}: {err}"
520            );
521        }
522
523        fs::remove_dir_all(root).expect("remove temp DTED dir");
524    }
525
526    #[test]
527    fn dted_lookup_rejects_nonfinite_coordinates() {
528        let root = temp_path("dted-nonfinite-coordinates");
529        let mut terrain = DtedTerrain::new(&root);
530
531        for (lon, lat, field) in [
532            (f64::NAN, 36.5, "longitude_deg"),
533            (f64::INFINITY, 36.5, "longitude_deg"),
534            (f64::NEG_INFINITY, 36.5, "longitude_deg"),
535            (-106.5, f64::NAN, "latitude_deg"),
536            (-106.5, f64::INFINITY, "latitude_deg"),
537            (-106.5, f64::NEG_INFINITY, "latitude_deg"),
538        ] {
539            assert_eq!(
540                terrain
541                    .height_m_with_options(lon, lat, DtedLookupOptions::default())
542                    .expect_err("non-finite DTED coordinate must error"),
543                Error::InvalidInput(format!("{field} must be finite"))
544            );
545        }
546
547        assert_eq!(
548            terrain
549                .height_m(f64::NAN, 36.5)
550                .expect_err("height_m must also reject non-finite coordinates"),
551            Error::InvalidInput("longitude_deg must be finite".to_string())
552        );
553    }
554
555    #[test]
556    fn dted_lookup_rejects_out_of_range_coordinates() {
557        let root = temp_path("dted-out-of-range-coordinates");
558        let mut terrain = DtedTerrain::new(&root);
559
560        for (lon, lat, error) in [
561            (
562                -106.5,
563                91.0,
564                Error::InvalidInput("latitude_deg must be within [-90, 90]".to_string()),
565            ),
566            (
567                -106.5,
568                -90.5,
569                Error::InvalidInput("latitude_deg must be within [-90, 90]".to_string()),
570            ),
571            (
572                200.0,
573                36.5,
574                Error::InvalidInput("longitude_deg must be within [-180, 180]".to_string()),
575            ),
576            (
577                -180.5,
578                36.5,
579                Error::InvalidInput("longitude_deg must be within [-180, 180]".to_string()),
580            ),
581        ] {
582            assert_eq!(
583                terrain
584                    .height_m_with_options(lon, lat, DtedLookupOptions::default())
585                    .expect_err("out-of-range DTED coordinate must error"),
586                error
587            );
588        }
589
590        assert_eq!(
591            terrain
592                .height_m(-106.5, 36.5)
593                .expect("missing in-range tile keeps sea-level fallback"),
594            0.0
595        );
596    }
597
598    #[test]
599    fn dted_valid_minimum_tile_parses_and_interpolates() {
600        let root = temp_path("dted-valid-minimum");
601        fs::create_dir_all(&root).expect("create temp DTED dir");
602        let tile_path = root.join("n36_w107_1arc_v3.dt2");
603        write_synthetic_dted_tile(&tile_path, 2, 2, |lon_index, lat_index| {
604            match (lon_index, lat_index) {
605                (0, 0) => 10,
606                (0, 1) => 30,
607                (1, 0) => 50,
608                (1, 1) => 70,
609                _ => unreachable!("2x2 synthetic tile"),
610            }
611        });
612
613        DtedTile::from_path(&tile_path).expect("valid 2x2 DTED tile");
614        let mut terrain = DtedTerrain::new(&root);
615        assert_eq!(
616            terrain
617                .height_m_with_options(
618                    -106.5,
619                    36.5,
620                    DtedLookupOptions {
621                        interpolation: DtedInterpolation::Bilinear,
622                    },
623                )
624                .expect("bilinear height"),
625            40.0
626        );
627
628        fs::remove_dir_all(root).expect("remove temp DTED dir");
629    }
630
631    // Fixture provenance: `tests/fixtures/dted/tiles/n36_w107_1arc_v3.dt2` is a
632    // synthetic public-format DTED tile written by the committed generator
633    // `crates/sidereon-core/fixtures-generators/generate_dted_points.py` using the
634    // DTED UHL/DSI/ACC/data-record layout (tile id `n36_w107`, elevation formula
635    // `z_m = -20 + 7*lon_i - 5*lat_i + lon_i*lat_i`); no external terrain payload is
636    // copied. `tests/fixtures/dted/dted_points.json` holds nearest-posting and
637    // bilinear lookup cases generated from that tile. Generated with Python 3.11.15
638    // on macOS-26.5.1-arm64. Floating-point fixture values are serialized as f64
639    // hex-bit strings and must be compared with `f64::to_bits`, never tolerances.
640    #[test]
641    fn dted_lookup_matches_generated_fixture_bits() {
642        let raw =
643            std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
644        let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
645        assert_eq!(doc["schema"], "gnss-dted-points-v1");
646
647        let mut terrain = DtedTerrain::new(fixture_path("tiles"));
648        let nearest = DtedLookupOptions {
649            interpolation: DtedInterpolation::NearestPosting,
650        };
651        let bilinear = DtedLookupOptions {
652            interpolation: DtedInterpolation::Bilinear,
653        };
654
655        let mut checked = 0usize;
656        for case in doc["nearest_cases"].as_array().expect("nearest_cases") {
657            let lon = bits(&case["longitude_bits"]);
658            let lat = bits(&case["latitude_bits"]);
659            let got = terrain
660                .height_m_with_options(lon, lat, nearest)
661                .expect("nearest DTED height");
662            let want = bits(&case["elevation_bits"]);
663            assert_eq!(
664                got.to_bits(),
665                want.to_bits(),
666                "nearest DTED {},{}",
667                lon,
668                lat
669            );
670            checked += 1;
671        }
672
673        for case in doc["bilinear_cases"].as_array().expect("bilinear_cases") {
674            let lon = bits(&case["longitude_bits"]);
675            let lat = bits(&case["latitude_bits"]);
676            let got = terrain
677                .height_m_with_options(lon, lat, bilinear)
678                .expect("bilinear DTED height");
679            let want = bits(&case["elevation_bits"]);
680            assert_eq!(
681                got.to_bits(),
682                want.to_bits(),
683                "bilinear DTED {},{}",
684                lon,
685                lat
686            );
687            checked += 1;
688        }
689        assert!(checked > 0, "empty DTED fixture");
690    }
691}