1use 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 #[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}