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 = scaled_cell_fraction(longitude_deg - tile.origin_longitude, postings_per_deg_lon);
207 let lat = scaled_cell_fraction(latitude_deg - tile.origin_latitude, postings_per_deg_lat);
208 let lon_lo = lon.cell;
209 let lat_lo = lat.cell;
210 let fx = lon.fraction;
211 let fy = lat.fraction;
212
213 let mut z = 0.0;
214 for (di, wx) in [(0i64, 1.0 - fx), (1i64, fx)] {
215 for (dj, wy) in [(0i64, 1.0 - fy), (1i64, fy)] {
216 let w = wx * wy;
217 if w == 0.0 {
218 continue;
219 }
220 let posting_lon =
221 tile.origin_longitude + (lon_lo + di) as f64 / postings_per_deg_lon as f64;
222 let posting_lat =
223 tile.origin_latitude + (lat_lo + dj) as f64 / postings_per_deg_lat as f64;
224 z += w * f64::from(
225 tile.get_elevation(posting_lon, posting_lat)
226 .map_err(Error::Parse)?,
227 );
228 }
229 }
230 Ok(z)
231}
232
233pub(crate) fn validate_lookup_coordinates(
234 longitude_deg: f64,
235 latitude_deg: f64,
236) -> crate::Result<()> {
237 if !longitude_deg.is_finite() {
238 return Err(Error::InvalidInput(
239 "longitude_deg must be finite".to_string(),
240 ));
241 }
242 if !latitude_deg.is_finite() {
243 return Err(Error::InvalidInput(
244 "latitude_deg must be finite".to_string(),
245 ));
246 }
247 if !(MIN_LOOKUP_LONGITUDE_DEG..=MAX_LOOKUP_LONGITUDE_DEG).contains(&longitude_deg) {
248 return Err(Error::InvalidInput(
249 "longitude_deg must be within [-180, 180]".to_string(),
250 ));
251 }
252 if !(MIN_LOOKUP_LATITUDE_DEG..=MAX_LOOKUP_LATITUDE_DEG).contains(&latitude_deg) {
253 return Err(Error::InvalidInput(
254 "latitude_deg must be within [-90, 90]".to_string(),
255 ));
256 }
257 Ok(())
258}
259
260#[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 nearest_posting_index(latitude - self.origin_latitude, self.lat_count - 1)?;
337 let longitude_index =
338 nearest_posting_index(longitude - self.origin_longitude, self.lon_count - 1)?;
339 if latitude_index >= self.lat_count || longitude_index >= self.lon_count {
340 return Err(format!(
341 "posting index out of bounds lon={longitude_index} lat={latitude_index}"
342 ));
343 }
344
345 let block = self.validated_block(longitude_index)?;
346
347 let sample_start = 8 + latitude_index * 2;
348 let raw = i16::from_be_bytes([block[sample_start], block[sample_start + 1]]);
349 Ok(convert_signed_magnitude(raw))
350 }
351
352 pub(crate) fn origin_latitude(&self) -> f64 {
353 self.origin_latitude
354 }
355
356 pub(crate) fn origin_longitude(&self) -> f64 {
357 self.origin_longitude
358 }
359
360 pub(crate) fn lon_count(&self) -> usize {
361 self.lon_count
362 }
363
364 pub(crate) fn lat_count(&self) -> usize {
365 self.lat_count
366 }
367
368 pub(crate) fn decoded_postings_lon_major(&self) -> Result<Vec<i16>, String> {
369 let mut out = Vec::with_capacity(self.lon_count * self.lat_count);
370 for longitude_index in 0..self.lon_count {
371 let block = self.validated_block(longitude_index)?;
372 for latitude_index in 0..self.lat_count {
373 let sample_start = 8 + latitude_index * 2;
374 let raw = i16::from_be_bytes([block[sample_start], block[sample_start + 1]]);
375 out.push(convert_signed_magnitude(raw));
376 }
377 }
378 Ok(out)
379 }
380
381 fn contains(&self, longitude: f64, latitude: f64) -> bool {
382 latitude >= self.origin_latitude
383 && latitude <= self.origin_latitude + 1.0
384 && longitude >= self.origin_longitude
385 && longitude <= self.origin_longitude + 1.0
386 }
387
388 fn validated_block(&self, longitude_index: usize) -> Result<&[u8], String> {
389 let block_start = DATA_OFFSET + longitude_index * self.data_block_length;
390 let block_end = block_start + self.data_block_length;
391 let block = &self.bytes[block_start..block_end];
392 if block[0] != DATA_SENTINEL {
393 return Err(format!(
394 "DTED block {longitude_index} missing data sentinel"
395 ));
396 }
397 let checksum = i32::from_be_bytes([
398 block[block.len() - 4],
399 block[block.len() - 3],
400 block[block.len() - 2],
401 block[block.len() - 1],
402 ]);
403 let sum = block[..block.len() - 4]
404 .iter()
405 .fold(0i32, |acc, b| acc + i32::from(*b));
406 if sum != checksum {
407 return Err(format!(
408 "DTED checksum failed for block {longitude_index}: expected {checksum}, found {sum}"
409 ));
410 }
411 Ok(block)
412 }
413}
414
415pub(crate) fn terrain_grid(longitude: f64, latitude: f64) -> (i32, i32) {
416 (latitude.floor() as i32, longitude.floor() as i32)
417}
418
419pub(crate) fn terrain_grid_candidates(longitude: f64, latitude: f64) -> Vec<(i32, i32)> {
420 let (lat, lon) = terrain_grid(longitude, latitude);
421 let mut out = vec![(lat, lon)];
422 let on_lat_edge = latitude == latitude.floor();
423 let on_lon_edge = longitude == longitude.floor();
424 if on_lat_edge {
425 out.push((lat - 1, lon));
426 }
427 if on_lon_edge {
428 out.push((lat, lon - 1));
429 }
430 if on_lat_edge && on_lon_edge {
431 out.push((lat - 1, lon - 1));
432 }
433 out
434}
435
436pub(crate) fn format_lat(latitude_index: i32) -> String {
437 if latitude_index >= 0 {
438 format!("n{latitude_index:02}")
439 } else {
440 format!("s{:02}", -latitude_index)
441 }
442}
443
444pub(crate) fn format_lon(longitude_index: i32) -> String {
445 if longitude_index >= 0 {
446 format!("e{longitude_index:03}")
447 } else {
448 format!("w{:03}", -longitude_index)
449 }
450}
451
452pub(crate) fn terrain_block_dir(latitude_index: i32, longitude_index: i32) -> String {
453 format!(
454 "{}_{}",
455 format_block_lat(latitude_index),
456 format_block_lon(longitude_index)
457 )
458}
459
460fn format_block_lat(latitude_index: i32) -> String {
461 let origin = block_origin(latitude_index);
462 if latitude_index >= 0 {
463 format!("n{origin:02}")
464 } else {
465 format!("s{origin:02}")
466 }
467}
468
469fn format_block_lon(longitude_index: i32) -> String {
470 let origin = block_origin(longitude_index);
471 if longitude_index >= 0 {
472 format!("e{origin:03}")
473 } else {
474 format!("w{origin:03}")
475 }
476}
477
478pub(crate) fn block_origin(index: i32) -> u32 {
479 (index.unsigned_abs() / 10) * 10
480}
481
482fn parse_ascii_usize(bytes: &[u8]) -> Result<usize, String> {
483 std::str::from_utf8(bytes)
484 .map_err(|e| e.to_string())?
485 .trim()
486 .parse::<usize>()
487 .map_err(|e| e.to_string())
488}
489
490fn parse_dted_coord(input: &str) -> Result<f64, String> {
491 let hemi = input
492 .chars()
493 .last()
494 .ok_or_else(|| "empty DTED coordinate".to_string())?;
495 let sign = match hemi {
496 'S' | 'W' => -1.0,
497 'N' | 'E' => 1.0,
498 _ => return Err(format!("invalid DTED hemisphere {hemi}")),
499 };
500 let coord = &input[..input.len() - 1];
501 let seconds_index = if coord.as_bytes().get(coord.len().saturating_sub(2)) == Some(&b'.') {
502 coord.len() - 4
503 } else {
504 coord.len() - 2
505 };
506 let minutes_index = seconds_index - 2;
507 let degree = coord[..minutes_index]
508 .parse::<i32>()
509 .map_err(|e| e.to_string())?;
510 let minute = coord[minutes_index..seconds_index]
511 .parse::<i32>()
512 .map_err(|e| e.to_string())?;
513 let second = coord[seconds_index..]
514 .parse::<f64>()
515 .map_err(|e| e.to_string())?;
516 Ok(sign * (degree as f64 + ((minute as f64 + second / 60.0) / 60.0)))
517}
518
519#[derive(Clone, Copy, Debug)]
520pub(crate) struct ScaledCellFraction {
521 pub(crate) cell: i64,
522 pub(crate) fraction: f64,
523 nearest: i64,
524}
525
526pub(crate) fn scaled_cell_fraction(offset: f64, postings_per_degree: usize) -> ScaledCellFraction {
529 debug_assert!(offset.is_finite());
530 debug_assert!(postings_per_degree > 0);
531
532 let bits = offset.to_bits();
533 let negative = bits >> 63 != 0;
534 let exponent_bits = ((bits >> 52) & 0x7ff) as i32;
535 let stored_significand = bits & ((1_u64 << 52) - 1);
536 let (significand, exponent) = if exponent_bits == 0 {
537 (stored_significand, -1074)
538 } else {
539 (
540 stored_significand | (1_u64 << 52),
541 exponent_bits - 1023 - 52,
542 )
543 };
544 if significand == 0 {
545 return ScaledCellFraction {
546 cell: 0,
547 fraction: 0.0,
548 nearest: 0,
549 };
550 }
551
552 let numerator = u128::from(significand) * postings_per_degree as u128;
553 if exponent >= 0 {
554 let magnitude = numerator
555 .checked_shl(exponent as u32)
556 .expect("in-tile scaled coordinate must fit u128");
557 let magnitude =
558 i64::try_from(magnitude).expect("in-tile scaled coordinate must fit a posting index");
559 let cell = if negative { -magnitude } else { magnitude };
560 return ScaledCellFraction {
561 cell,
562 fraction: 0.0,
563 nearest: cell,
564 };
565 }
566
567 let denominator_exponent = (-exponent) as u32;
568 if denominator_exponent >= 128 {
569 if negative {
570 return ScaledCellFraction {
571 cell: -1,
572 fraction: one_minus_dyadic(numerator, denominator_exponent),
573 nearest: 0,
574 };
575 }
576 return ScaledCellFraction {
577 cell: 0,
578 fraction: dyadic_to_f64(numerator, exponent),
579 nearest: 0,
580 };
581 }
582
583 let denominator = 1_u128 << denominator_exponent;
584 let integer = numerator >> denominator_exponent;
585 let remainder = numerator & (denominator - 1);
586 let (cell, euclidean_remainder) = if negative {
587 if remainder == 0 {
588 (-(integer as i64), 0)
589 } else {
590 (-(integer as i64) - 1, denominator - remainder)
591 }
592 } else {
593 (integer as i64, remainder)
594 };
595 let half = denominator >> 1;
596 let nearest = if euclidean_remainder < half || (euclidean_remainder == half && cell % 2 == 0) {
597 cell
598 } else {
599 cell + 1
600 };
601 ScaledCellFraction {
602 cell,
603 fraction: dyadic_to_f64(euclidean_remainder, exponent),
604 nearest,
605 }
606}
607
608pub(crate) fn nearest_posting_index(
609 offset: f64,
610 postings_per_degree: usize,
611) -> Result<usize, String> {
612 let scaled = scaled_cell_fraction(offset, postings_per_degree);
613 usize::try_from(scaled.nearest)
614 .map_err(|_| format!("cannot round negative posting index {}", scaled.nearest))
615}
616
617fn one_minus_dyadic(numerator: u128, denominator_exponent: u32) -> f64 {
618 let deficit_units = round_shift_right(numerator, denominator_exponent - 53);
619 1.0 - deficit_units as f64 * (f64::EPSILON / 2.0)
620}
621
622fn dyadic_to_f64(numerator: u128, exponent: i32) -> f64 {
623 if numerator == 0 {
624 return 0.0;
625 }
626
627 let bit_length = 128 - numerator.leading_zeros();
628 let mut binary_exponent = bit_length as i32 - 1 + exponent;
629 if binary_exponent >= -1022 {
630 let mut significand = if bit_length <= 53 {
631 numerator << (53 - bit_length)
632 } else {
633 round_shift_right(numerator, bit_length - 53)
634 };
635 if significand == 1_u128 << 53 {
636 significand >>= 1;
637 binary_exponent += 1;
638 }
639 let exponent_bits = u64::try_from(binary_exponent + 1023)
640 .expect("normal binary64 exponent must be nonnegative");
641 let fraction_bits = significand as u64 & ((1_u64 << 52) - 1);
642 return f64::from_bits((exponent_bits << 52) | fraction_bits);
643 }
644
645 let subnormal_shift = exponent + 1074;
646 let significand = if subnormal_shift >= 0 {
647 numerator << subnormal_shift as u32
648 } else {
649 round_shift_right(numerator, (-subnormal_shift) as u32)
650 };
651 f64::from_bits(significand as u64)
652}
653
654fn round_shift_right(value: u128, shift: u32) -> u128 {
655 if shift == 0 {
656 return value;
657 }
658 if shift > 128 {
659 return 0;
660 }
661
662 let quotient = if shift == 128 { 0 } else { value >> shift };
663 let remainder = if shift == 128 {
664 value
665 } else {
666 value & ((1_u128 << shift) - 1)
667 };
668 let half = 1_u128 << (shift - 1);
669 if remainder > half || (remainder == half && !quotient.is_multiple_of(2)) {
670 quotient + 1
671 } else {
672 quotient
673 }
674}
675
676fn convert_signed_magnitude(raw: i16) -> i16 {
677 if raw < 0 {
678 (-32768i32 - i32::from(raw)) as i16
679 } else {
680 raw
681 }
682}
683
684#[cfg(all(test, sidereon_repo_tests))]
685mod tests {
686 use std::fs;
693 use std::path::Path;
694 use std::path::PathBuf;
695 use std::time::{SystemTime, UNIX_EPOCH};
696
697 use serde_json::Value;
698
699 use crate::test_parity::f64_from_hex;
700 use crate::Error;
701
702 use super::{
703 nearest_posting_index, scaled_cell_fraction, terrain_block_dir, DtedInterpolation,
704 DtedLookupOptions, DtedTerrain, DtedTile, DATA_OFFSET, DATA_SENTINEL,
705 };
706
707 #[test]
708 fn exact_scaling_preserves_the_split_point_fraction() {
709 let tile_origin = -107.0;
710 let coordinate = -106.265_141_029_846_36;
711 let offset = coordinate - tile_origin;
712 assert_eq!(offset, 0.734_858_970_153_638_3);
713
714 let scaled = scaled_cell_fraction(offset, 3600);
715 let naive = offset * 3600.0;
716 let naive_fraction = naive - naive.floor();
717 assert_eq!(scaled.cell, 2645);
718 assert_eq!(scaled.fraction.to_bits(), 0x3fdf_81b8_9fe7_b000);
719 assert_eq!(naive.floor() as i64, 2645);
720 assert_eq!(naive_fraction.to_bits(), 0x3fdf_81b8_9fe7_c000);
721 assert_eq!(naive_fraction.to_bits() - scaled.fraction.to_bits(), 4096);
722
723 let nondiscriminating_offset = -0.265_141_029_846_361_7;
724 let nondiscriminating = scaled_cell_fraction(nondiscriminating_offset, 3600);
725 let naive = nondiscriminating_offset * 3600.0;
726 assert_eq!(
727 nondiscriminating.fraction.to_bits(),
728 (naive - naive.floor()).to_bits()
729 );
730 }
731
732 #[test]
733 fn exact_scaling_keeps_a_coordinate_below_the_posting_in_the_lower_cell() {
734 let posting = 3.0_f64 / 3600.0;
735 let coordinate = f64::from_bits(posting.to_bits() - 1);
736 assert!(coordinate < posting);
737 assert_eq!(coordinate * 3600.0, 3.0);
738
739 let scaled = scaled_cell_fraction(coordinate, 3600);
740 assert_eq!(scaled.cell, 2);
741 assert_eq!(scaled.fraction.to_bits(), 0x3fef_ffff_ffff_fffe);
742 assert_eq!(scaled.nearest, 3);
743 }
744
745 #[test]
746 fn nearest_posting_rounds_the_exact_product_instead_of_the_binary64_product() {
747 let half_posting = 1.5_f64 / 3600.0;
748 let coordinate = f64::from_bits(half_posting.to_bits() - 1);
749 assert!(coordinate < half_posting);
750 assert_eq!(coordinate * 3600.0, 1.5);
751
752 assert_eq!(nearest_posting_index(coordinate, 3600), Ok(1));
753 }
754
755 #[test]
756 fn exact_scaled_value_tracks_the_binary64_product_over_deterministic_offsets() {
757 let mut state = 0x764e_279d_9f41_2c03_u64;
758 for _ in 0..10_000 {
759 state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
760 let mut random = state;
761 random = (random ^ (random >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
762 random = (random ^ (random >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
763 random ^= random >> 31;
764 let offset = f64::from_bits(0x3ff0_0000_0000_0000 | (random >> 12)) - 1.0;
765
766 let exact = scaled_cell_fraction(offset, 3600);
767 let reconstructed = exact.cell as f64 + exact.fraction;
768 let naive = offset * 3600.0;
769 assert!(
770 reconstructed.to_bits().abs_diff(naive.to_bits()) <= 1,
771 "offset={offset} exact={reconstructed} naive={naive}"
772 );
773 assert!(exact.cell <= naive.floor() as i64, "offset={offset}");
774 }
775 }
776
777 #[test]
778 fn terrain_block_dir_matches_reference_bucket_names() {
779 assert_eq!(terrain_block_dir(36, -107), "n30_w100");
780 assert_eq!(terrain_block_dir(32, -117), "n30_w110");
781 assert_eq!(terrain_block_dir(43, -112), "n40_w110");
782 assert_eq!(terrain_block_dir(20, -103), "n20_w100");
783 assert_eq!(terrain_block_dir(36, 107), "n30_e100");
784 assert_eq!(terrain_block_dir(-1, -1), "s00_w000");
785 assert_eq!(terrain_block_dir(1, 1), "n00_e000");
786 assert_eq!(terrain_block_dir(-1, 1), "s00_e000");
787 assert_eq!(terrain_block_dir(32, -110), "n30_w110");
788 assert_eq!(terrain_block_dir(32, -111), "n30_w110");
789 assert_eq!(terrain_block_dir(32, -1), "n30_w000");
790 assert_eq!(terrain_block_dir(32, -10), "n30_w010");
791 }
792
793 #[test]
794 fn negative_tile_indices_resolve_to_negative_block_dir() {
795 let nonce = SystemTime::now()
796 .duration_since(UNIX_EPOCH)
797 .expect("system time after epoch")
798 .as_nanos();
799 let root = std::env::temp_dir().join(format!(
800 "sidereon-dted-negative-block-{}-{nonce}",
801 std::process::id()
802 ));
803 let tile_dir = root.join("s00_w000");
804 let tile_path = tile_dir.join("s01_w001_1arc_v3.dt2");
805 fs::create_dir_all(&tile_dir).expect("create nested DTED block dir");
806 fs::write(&tile_path, []).expect("create nested DTED tile path");
807
808 let terrain = DtedTerrain::new(&root);
809 let got = terrain
810 .terrain_path_for_grid(-1, -1)
811 .expect("negative nested tile path");
812 assert_eq!(got, tile_path);
813
814 fs::remove_dir_all(root).expect("remove temp DTED block dir");
815 }
816
817 fn fixture_path(name: &str) -> PathBuf {
818 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
819 .join("tests")
820 .join("fixtures")
821 .join("dted")
822 .join(name)
823 }
824
825 fn bits(v: &Value) -> f64 {
826 f64_from_hex(v.as_str().expect("hex-bit string")).expect("valid f64 bits")
827 }
828
829 fn temp_path(name: &str) -> PathBuf {
830 let nonce = SystemTime::now()
831 .duration_since(UNIX_EPOCH)
832 .expect("system time after epoch")
833 .as_nanos();
834 std::env::temp_dir().join(format!("sidereon-{name}-{}-{nonce}", std::process::id()))
835 }
836
837 fn scalar_loop(
838 root: &Path,
839 points: &[(f64, f64)],
840 options: DtedLookupOptions,
841 ) -> Vec<crate::Result<f64>> {
842 let mut terrain = DtedTerrain::new(root);
843 points
844 .iter()
845 .map(|&(lon, lat)| terrain.height_m_with_options(lon, lat, options))
846 .collect()
847 }
848
849 fn assert_height_results_match(
850 got: &[crate::Result<f64>],
851 want: &[crate::Result<f64>],
852 context: &str,
853 ) {
854 assert_eq!(got.len(), want.len(), "{context} result length");
855 for (idx, (got, want)) in got.iter().zip(want).enumerate() {
856 match (got, want) {
857 (Ok(got), Ok(want)) => assert_eq!(
858 got.to_bits(),
859 want.to_bits(),
860 "{context} index {idx} height bits"
861 ),
862 (Err(got), Err(want)) => {
863 assert_eq!(got, want, "{context} index {idx} error")
864 }
865 (got, want) => panic!("{context} index {idx} mismatch: {got:?} != {want:?}"),
866 }
867 }
868 }
869
870 fn copy_fixture_tile(root: &Path, tile_name: &str) {
871 fs::copy(
872 fixture_path(&format!("tiles/{tile_name}")),
873 root.join(tile_name),
874 )
875 .expect("copy DTED fixture tile");
876 }
877
878 fn copy_primary_fixture_root(name: &str) -> PathBuf {
879 let root = temp_path(name);
880 fs::create_dir_all(&root).expect("create temp DTED dir");
881 copy_fixture_tile(&root, "n36_w107_1arc_v3.dt2");
882 root
883 }
884
885 fn write_synthetic_dted_tile(
886 path: &Path,
887 lon_count: usize,
888 lat_count: usize,
889 sample: impl Fn(usize, usize) -> i16,
890 ) {
891 let data_block_length = 12 + 2 * lat_count;
892 let mut bytes = vec![b' '; DATA_OFFSET];
893 bytes[0..4].copy_from_slice(b"UHL1");
894 bytes[4..12].copy_from_slice(b"1070000W");
895 bytes[12..20].copy_from_slice(b"0360000N");
896 bytes[47..51].copy_from_slice(format!("{lon_count:04}").as_bytes());
897 bytes[51..55].copy_from_slice(format!("{lat_count:04}").as_bytes());
898
899 for lon_index in 0..lon_count {
900 let mut block = vec![0u8; data_block_length];
901 block[0] = DATA_SENTINEL;
902 for lat_index in 0..lat_count {
903 let sample_start = 8 + lat_index * 2;
904 block[sample_start..sample_start + 2]
905 .copy_from_slice(&sample(lon_index, lat_index).to_be_bytes());
906 }
907 let checksum = block[..block.len() - 4]
908 .iter()
909 .fold(0i32, |acc, b| acc + i32::from(*b));
910 let checksum_start = block.len() - 4;
911 block[checksum_start..].copy_from_slice(&checksum.to_be_bytes());
912 bytes.extend(block);
913 }
914
915 fs::write(path, bytes).expect("write synthetic DTED tile");
916 }
917
918 #[test]
919 fn dted_rejects_degenerate_header_counts() {
920 let root = temp_path("dted-degenerate-counts");
921 fs::create_dir_all(&root).expect("create temp DTED dir");
922
923 for (lon_count, lat_count) in [(0, 2), (1, 2), (2, 0), (2, 1)] {
924 let tile_path = root.join(format!("tile-{lon_count}-{lat_count}.dt2"));
925 write_synthetic_dted_tile(&tile_path, lon_count, lat_count, |_, _| 0);
926
927 let err = DtedTile::from_path(&tile_path).expect_err("degenerate counts must error");
928 assert!(
929 err.contains("invalid DTED dimensions"),
930 "unexpected error for lon_count={lon_count} lat_count={lat_count}: {err}"
931 );
932 }
933
934 fs::remove_dir_all(root).expect("remove temp DTED dir");
935 }
936
937 #[test]
938 fn dted_lookup_rejects_nonfinite_coordinates() {
939 let root = temp_path("dted-nonfinite-coordinates");
940 let mut terrain = DtedTerrain::new(&root);
941
942 for (lon, lat, field) in [
943 (f64::NAN, 36.5, "longitude_deg"),
944 (f64::INFINITY, 36.5, "longitude_deg"),
945 (f64::NEG_INFINITY, 36.5, "longitude_deg"),
946 (-106.5, f64::NAN, "latitude_deg"),
947 (-106.5, f64::INFINITY, "latitude_deg"),
948 (-106.5, f64::NEG_INFINITY, "latitude_deg"),
949 ] {
950 assert_eq!(
951 terrain
952 .height_m_with_options(lon, lat, DtedLookupOptions::default())
953 .expect_err("non-finite DTED coordinate must error"),
954 Error::InvalidInput(format!("{field} must be finite"))
955 );
956 }
957
958 assert_eq!(
959 terrain
960 .height_m(f64::NAN, 36.5)
961 .expect_err("height_m must also reject non-finite coordinates"),
962 Error::InvalidInput("longitude_deg must be finite".to_string())
963 );
964 }
965
966 #[test]
967 fn dted_lookup_rejects_out_of_range_coordinates() {
968 let root = temp_path("dted-out-of-range-coordinates");
969 let mut terrain = DtedTerrain::new(&root);
970
971 for (lon, lat, error) in [
972 (
973 -106.5,
974 91.0,
975 Error::InvalidInput("latitude_deg must be within [-90, 90]".to_string()),
976 ),
977 (
978 -106.5,
979 -90.5,
980 Error::InvalidInput("latitude_deg must be within [-90, 90]".to_string()),
981 ),
982 (
983 200.0,
984 36.5,
985 Error::InvalidInput("longitude_deg must be within [-180, 180]".to_string()),
986 ),
987 (
988 -180.5,
989 36.5,
990 Error::InvalidInput("longitude_deg must be within [-180, 180]".to_string()),
991 ),
992 ] {
993 assert_eq!(
994 terrain
995 .height_m_with_options(lon, lat, DtedLookupOptions::default())
996 .expect_err("out-of-range DTED coordinate must error"),
997 error
998 );
999 }
1000
1001 assert_eq!(
1002 terrain
1003 .height_m(-106.5, 36.5)
1004 .expect("missing in-range tile keeps sea-level fallback"),
1005 0.0
1006 );
1007 }
1008
1009 #[test]
1010 fn dted_valid_minimum_tile_parses_and_interpolates() {
1011 let root = temp_path("dted-valid-minimum");
1012 fs::create_dir_all(&root).expect("create temp DTED dir");
1013 let tile_path = root.join("n36_w107_1arc_v3.dt2");
1014 write_synthetic_dted_tile(&tile_path, 2, 2, |lon_index, lat_index| {
1015 match (lon_index, lat_index) {
1016 (0, 0) => 10,
1017 (0, 1) => 30,
1018 (1, 0) => 50,
1019 (1, 1) => 70,
1020 _ => unreachable!("2x2 synthetic tile"),
1021 }
1022 });
1023
1024 DtedTile::from_path(&tile_path).expect("valid 2x2 DTED tile");
1025 let mut terrain = DtedTerrain::new(&root);
1026 assert_eq!(
1027 terrain
1028 .height_m_with_options(
1029 -106.5,
1030 36.5,
1031 DtedLookupOptions {
1032 interpolation: DtedInterpolation::Bilinear,
1033 },
1034 )
1035 .expect("bilinear height"),
1036 40.0
1037 );
1038
1039 fs::remove_dir_all(root).expect("remove temp DTED dir");
1040 }
1041
1042 #[test]
1052 fn dted_lookup_matches_generated_fixture_bits() {
1053 let raw =
1054 std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
1055 let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
1056 assert_eq!(doc["schema"], "gnss-dted-points-v1");
1057
1058 let root = copy_primary_fixture_root("dted-fixture-single-scalar");
1059 let mut terrain = DtedTerrain::new(&root);
1060 let nearest = DtedLookupOptions {
1061 interpolation: DtedInterpolation::NearestPosting,
1062 };
1063 let bilinear = DtedLookupOptions {
1064 interpolation: DtedInterpolation::Bilinear,
1065 };
1066
1067 let mut checked = 0usize;
1068 for case in doc["nearest_cases"].as_array().expect("nearest_cases") {
1069 let lon = bits(&case["longitude_bits"]);
1070 let lat = bits(&case["latitude_bits"]);
1071 let got = terrain
1072 .height_m_with_options(lon, lat, nearest)
1073 .expect("nearest DTED height");
1074 let want = bits(&case["elevation_bits"]);
1075 assert_eq!(
1076 got.to_bits(),
1077 want.to_bits(),
1078 "nearest DTED {},{}",
1079 lon,
1080 lat
1081 );
1082 checked += 1;
1083 }
1084
1085 for case in doc["bilinear_cases"].as_array().expect("bilinear_cases") {
1086 let lon = bits(&case["longitude_bits"]);
1087 let lat = bits(&case["latitude_bits"]);
1088 let got = terrain
1089 .height_m_with_options(lon, lat, bilinear)
1090 .expect("bilinear DTED height");
1091 let want = bits(&case["elevation_bits"]);
1092 assert_eq!(
1093 got.to_bits(),
1094 want.to_bits(),
1095 "bilinear DTED {},{}",
1096 lon,
1097 lat
1098 );
1099 checked += 1;
1100 }
1101 assert!(checked > 0, "empty DTED fixture");
1102 fs::remove_dir_all(root).expect("remove temp DTED dir");
1103 }
1104
1105 #[test]
1106 fn height_batch_matches_scalar_loop_on_fixture_bits() {
1107 let raw =
1108 std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
1109 let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
1110 assert_eq!(doc["schema"], "gnss-dted-points-v1");
1111
1112 let points: Vec<(f64, f64)> = ["nearest_cases", "bilinear_cases"]
1113 .into_iter()
1114 .flat_map(|cases_key| {
1115 doc[cases_key]
1116 .as_array()
1117 .expect(cases_key)
1118 .iter()
1119 .map(|case| (bits(&case["longitude_bits"]), bits(&case["latitude_bits"])))
1120 })
1121 .collect();
1122
1123 for options in [
1124 DtedLookupOptions {
1125 interpolation: DtedInterpolation::NearestPosting,
1126 },
1127 DtedLookupOptions {
1128 interpolation: DtedInterpolation::Bilinear,
1129 },
1130 ] {
1131 let root = copy_primary_fixture_root("dted-fixture-single-batch");
1132 let want = scalar_loop(&root, &points, options);
1133 let mut terrain = DtedTerrain::new(&root);
1134 let got = terrain.height_batch(&points, options);
1135 assert_height_results_match(&got, &want, "single-tile fixture batch");
1136 fs::remove_dir_all(root).expect("remove temp DTED dir");
1137 }
1138 }
1139
1140 #[test]
1141 fn height_batch_matches_scalar_loop_across_adjacent_tiles_bits() {
1142 let root = fixture_path("tiles");
1143 let options = DtedLookupOptions {
1144 interpolation: DtedInterpolation::Bilinear,
1145 };
1146 let raw =
1147 std::fs::read_to_string(fixture_path("dted_points.json")).expect("read dted fixture");
1148 let doc: Value = serde_json::from_str(&raw).expect("parse dted fixture");
1149 for case in doc["multi_tile_cases"]
1150 .as_array()
1151 .expect("multi_tile_cases")
1152 {
1153 let lon = bits(&case["longitude_bits"]);
1154 let lat = bits(&case["latitude_bits"]);
1155 let expected = bits(&case["bilinear_bits"]);
1156 let mut terrain = DtedTerrain::new(&root);
1157 let got = terrain
1158 .height_m_with_options(lon, lat, options)
1159 .expect("multi-tile generated bilinear height");
1160 assert_eq!(
1161 got.to_bits(),
1162 expected.to_bits(),
1163 "multi-tile generated case {}",
1164 case["case_id"].as_str().expect("case_id")
1165 );
1166 }
1167
1168 let sequences = [
1169 (
1170 "all_in_a_then_all_in_b",
1171 vec![
1172 (-106.875, 36.125),
1173 (-106.625, 36.375),
1174 (-105.875, 36.125),
1175 (-105.625, 36.375),
1176 ],
1177 ),
1178 (
1179 "interleaved_a_b_a_b",
1180 vec![
1181 (-106.875, 36.625),
1182 (-105.875, 36.625),
1183 (-106.625, 36.125),
1184 (-105.625, 36.125),
1185 ],
1186 ),
1187 (
1188 "boundary_after_a_then_missing",
1189 vec![
1190 (-106.875, 36.5),
1191 (-106.0, 36.5),
1192 (-104.5, 36.5),
1193 (-105.875, 36.5),
1194 ],
1195 ),
1196 ];
1197
1198 for (name, points) in sequences {
1199 let want = scalar_loop(&root, &points, options);
1200 let mut terrain = DtedTerrain::new(&root);
1201 let got = terrain.height_batch(&points, options);
1202 assert_height_results_match(&got, &want, name);
1203 }
1204
1205 let mut terrain = DtedTerrain::new(&root);
1206 let missing = terrain.height_batch(&[(-104.5, 36.5)], options);
1207 assert_eq!(
1208 missing[0].as_ref().map(|v| v.to_bits()),
1209 Ok(0.0f64.to_bits())
1210 );
1211 }
1212
1213 #[test]
1214 fn height_batch_places_errors_at_input_indices() {
1215 let root = temp_path("dted-batch-errors");
1216 fs::create_dir_all(&root).expect("create temp DTED dir");
1217 copy_fixture_tile(&root, "n36_w107_1arc_v3.dt2");
1218 copy_fixture_tile(&root, "n36_w106_1arc_v3.dt2");
1219 fs::write(root.join("n37_w107_1arc_v3.dt2"), b"not a DTED tile")
1220 .expect("write corrupt DTED tile");
1221
1222 let points = [
1223 (-106.875, 36.125),
1224 (-106.5, f64::NAN),
1225 (-105.875, 36.125),
1226 (-106.5, 37.5),
1227 (-106.625, 36.375),
1228 ];
1229 let options = DtedLookupOptions {
1230 interpolation: DtedInterpolation::Bilinear,
1231 };
1232 let want = scalar_loop(&root, &points, options);
1233 let mut terrain = DtedTerrain::new(&root);
1234 let got = terrain.height_batch(&points, options);
1235 assert_height_results_match(&got, &want, "batch error placement");
1236
1237 assert!(got[0].is_ok(), "index 0 remains valid");
1238 assert_eq!(
1239 got[1],
1240 Err(Error::InvalidInput(
1241 "latitude_deg must be finite".to_string()
1242 ))
1243 );
1244 assert!(got[2].is_ok(), "index 2 remains valid");
1245 assert!(
1246 matches!(&got[3], Err(Error::Parse(msg)) if msg.contains("too short")),
1247 "index 3 is the corrupt-tile error: {:?}",
1248 got[3]
1249 );
1250 assert!(got[4].is_ok(), "index 4 remains valid");
1251
1252 fs::remove_dir_all(root).expect("remove temp DTED dir");
1253 }
1254}