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