use std::{collections::HashMap, fs, io, path::Path};
use memmap2::Mmap;
struct HgtTile {
lat_sw: i32,
lon_sw: i32,
size: usize,
mmap: Mmap,
}
impl HgtTile {
fn load(path: &Path) -> io::Result<Self> {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid HGT filename"))?
.to_ascii_uppercase();
let (lat_sw, lon_sw) = parse_hgt_filename(&stem)?;
let file = fs::File::open(path)?;
let mmap = unsafe { Mmap::map(&file)? };
let n_samples = mmap.len() / 2;
let size = match n_samples {
1_442_401 => 1201, 12_967_201 => 3601, _ => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"unexpected HGT file size: {} bytes ({} samples); expected SRTM1 or SRTM3",
mmap.len(),
n_samples
),
));
}
};
Ok(HgtTile {
lat_sw,
lon_sw,
size,
mmap,
})
}
fn elevation_at(&self, lat: f64, lon: f64) -> Option<f64> {
let lat_sw = self.lat_sw as f64;
let lon_sw = self.lon_sw as f64;
if lat < lat_sw || lat > lat_sw + 1.0 || lon < lon_sw || lon > lon_sw + 1.0 {
return None;
}
let nf = (self.size - 1) as f64;
let rf = (lat_sw + 1.0 - lat) * nf;
let cf = (lon - lon_sw) * nf;
let r0 = rf.floor() as usize;
let c0 = cf.floor() as usize;
let r1 = (r0 + 1).min(self.size - 1);
let c1 = (c0 + 1).min(self.size - 1);
let dr = rf - r0 as f64;
let dc = cf - c0 as f64;
let h00 = self.sample(r0, c0)?;
let h01 = self.sample(r0, c1)?;
let h10 = self.sample(r1, c0)?;
let h11 = self.sample(r1, c1)?;
Some(
(1.0 - dr) * (1.0 - dc) * h00
+ (1.0 - dr) * dc * h01
+ dr * (1.0 - dc) * h10
+ dr * dc * h11,
)
}
#[inline]
fn sample(&self, row: usize, col: usize) -> Option<f64> {
let offset = (row * self.size + col) * 2;
let b0 = self.mmap[offset];
let b1 = self.mmap[offset + 1];
let v = i16::from_be_bytes([b0, b1]);
if v == -32768 { None } else { Some(v as f64) }
}
}
fn parse_hgt_filename(stem: &str) -> io::Result<(i32, i32)> {
let bytes = stem.as_bytes();
if bytes.len() < 7 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("HGT filename stem too short: '{stem}'"),
));
}
let lat_sign: i32 = match bytes[0] {
b'N' => 1,
b'S' => -1,
c => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("expected N/S as first character, got '{}'", c as char),
));
}
};
let ew_pos = bytes[1..]
.iter()
.position(|&b| b == b'E' || b == b'W')
.map(|p| p + 1)
.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "missing E/W in HGT filename")
})?;
let lat: i32 = std::str::from_utf8(&bytes[1..ew_pos])
.ok()
.and_then(|s| s.parse().ok())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"invalid latitude digits in HGT filename",
)
})?;
let lon_sign: i32 = match bytes[ew_pos] {
b'E' => 1,
b'W' => -1,
c => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("expected E/W, got '{}'", c as char),
));
}
};
let lon: i32 = std::str::from_utf8(&bytes[ew_pos + 1..])
.ok()
.and_then(|s| s.parse().ok())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"invalid longitude digits in HGT filename",
)
})?;
Ok((lat_sign * lat, lon_sign * lon))
}
pub struct ElevationData {
tiles: HashMap<(i32, i32), HgtTile>,
}
impl ElevationData {
pub fn from_path(path: &Path) -> anyhow::Result<Self> {
let mut tiles = HashMap::new();
if path.is_dir() {
for entry in fs::read_dir(path)? {
let entry = entry?;
let p = entry.path();
let is_hgt = p
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("hgt"));
if !is_hgt {
continue;
}
match HgtTile::load(&p) {
Ok(tile) => {
log::info!("Memory-mapped elevation tile: {}", p.display());
tiles.insert((tile.lat_sw, tile.lon_sw), tile);
}
Err(e) => {
log::warn!("Skipping {}: {e}", p.display());
}
}
}
} else {
let tile = HgtTile::load(path)?;
log::info!("Memory-mapped elevation tile: {}", path.display());
tiles.insert((tile.lat_sw, tile.lon_sw), tile);
}
if tiles.is_empty() {
anyhow::bail!("No valid HGT tiles found at {}", path.display());
}
log::info!("Loaded {} elevation tile(s)", tiles.len());
Ok(Self { tiles })
}
pub fn elevation_at(&self, lat: f64, lon: f64) -> Option<f64> {
let lat_sw = lat.floor() as i32;
let lon_sw = lon.floor() as i32;
self.tiles.get(&(lat_sw, lon_sw))?.elevation_at(lat, lon)
}
}
#[cfg(test)]
impl HgtTile {
fn synthetic(lat_sw: i32, lon_sw: i32, size: usize, data: &[i16]) -> io::Result<Self> {
use std::io::Write as _;
assert_eq!(data.len(), size * size, "data.len() must equal size*size");
let dir = tempfile::tempdir()?;
let ns = if lat_sw >= 0 { 'N' } else { 'S' };
let ew = if lon_sw >= 0 { 'E' } else { 'W' };
let name = format!(
"{}{:02}{}{:03}.hgt",
ns,
lat_sw.unsigned_abs(),
ew,
lon_sw.unsigned_abs()
);
let path = dir.path().join(&name);
let mut f = fs::File::create(&path)?;
for &v in data {
f.write_all(&v.to_be_bytes())?;
}
f.flush()?;
drop(f);
let file = fs::File::open(&path)?;
let mmap = unsafe { Mmap::map(&file)? };
std::mem::forget(dir);
Ok(HgtTile {
lat_sw,
lon_sw,
size,
mmap,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_north_west() {
assert_eq!(parse_hgt_filename("N48W123").unwrap(), (48, -123));
}
#[test]
fn parse_south_east() {
assert_eq!(parse_hgt_filename("S33E151").unwrap(), (-33, 151));
}
#[test]
fn parse_north_east() {
assert_eq!(parse_hgt_filename("N00E000").unwrap(), (0, 0));
}
#[test]
fn parse_south_west_two_digit_lon() {
assert_eq!(parse_hgt_filename("S05W072").unwrap(), (-5, -72));
}
#[test]
fn parse_rejects_short_stem() {
assert!(parse_hgt_filename("N1W1").is_err());
}
#[test]
fn tile_elevation_at_corners() {
let tile = HgtTile::synthetic(0, 0, 2, &[100, 200, 300, 400]).unwrap();
let nw = tile.elevation_at(1.0, 0.0).unwrap();
assert!((nw - 100.0).abs() < 0.01, "NW corner: {nw}");
let ne = tile.elevation_at(1.0, 1.0).unwrap();
assert!((ne - 200.0).abs() < 0.01, "NE corner: {ne}");
let sw = tile.elevation_at(0.0, 0.0).unwrap();
assert!((sw - 300.0).abs() < 0.01, "SW corner: {sw}");
let se = tile.elevation_at(0.0, 1.0).unwrap();
assert!((se - 400.0).abs() < 0.01, "SE corner: {se}");
}
#[test]
fn tile_elevation_centre_bilinear() {
let tile = HgtTile::synthetic(0, 0, 2, &[100, 200, 300, 400]).unwrap();
let mid = tile.elevation_at(0.5, 0.5).unwrap();
assert!((mid - 250.0).abs() < 0.01, "centre bilinear: {mid}");
}
#[test]
fn tile_elevation_out_of_bounds() {
let tile = HgtTile::synthetic(0, 0, 2, &[100, 100, 100, 100]).unwrap();
assert!(tile.elevation_at(-0.1, 0.5).is_none());
assert!(tile.elevation_at(1.1, 0.5).is_none());
assert!(tile.elevation_at(0.5, -0.1).is_none());
assert!(tile.elevation_at(0.5, 1.1).is_none());
}
#[test]
fn tile_void_returns_none() {
let tile = HgtTile::synthetic(0, 0, 2, &[-32768, -32768, -32768, -32768]).unwrap();
assert!(tile.elevation_at(0.5, 0.5).is_none());
}
}