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_lat(block_origin(latitude_index)),
329 format_lon(block_origin(longitude_index))
330 )
331}
332
333pub(crate) fn 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 #[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}