#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::many_single_char_names,
clippy::similar_names,
clippy::unreadable_literal
)]
use core::f64::consts::PI;
use core::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VmfModel {
Vmf1,
Vmf3,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VmfCoefficients {
pub ah: f64,
pub aw: f64,
pub zhd: f64,
pub zwd: f64,
}
impl VmfCoefficients {
#[must_use]
pub fn lerp(self, other: Self, frac: f64) -> Self {
let f = frac.clamp(0.0, 1.0);
let mix = |a: f64, b: f64| a + (b - a) * f;
Self {
ah: mix(self.ah, other.ah),
aw: mix(self.aw, other.aw),
zhd: mix(self.zhd, other.zhd),
zwd: mix(self.zwd, other.zwd),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VmfGridError {
MissingDataType,
MalformedHeader,
MalformedRow(usize),
RowCountMismatch {
expected: usize,
found: usize,
},
}
impl fmt::Display for VmfGridError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingDataType => write!(f, "vmf grid: no VMF1/VMF3 Data_types header"),
Self::MalformedHeader => {
write!(f, "vmf grid: missing or malformed Range/resolution header")
}
Self::MalformedRow(line) => write!(f, "vmf grid: malformed data row at line {line}"),
Self::RowCountMismatch { expected, found } => {
write!(
f,
"vmf grid: expected {expected} rows from the header, found {found}"
)
}
}
}
}
impl std::error::Error for VmfGridError {}
#[derive(Debug, Clone)]
pub struct VmfGrid {
model: VmfModel,
lat0: f64,
lon0: f64,
dlat: f64,
dlon: f64,
nlat: usize,
nlon: usize,
ah: Vec<f64>,
aw: Vec<f64>,
zhd: Vec<f64>,
zwd: Vec<f64>,
}
impl VmfGrid {
#[must_use]
pub fn model(&self) -> VmfModel {
self.model
}
pub fn parse(text: &str) -> Result<Self, VmfGridError> {
let mut model: Option<VmfModel> = None;
let mut range: Option<[f64; 6]> = None;
let mut ah = Vec::new();
let mut aw = Vec::new();
let mut zhd = Vec::new();
let mut zwd = Vec::new();
for (lineno, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix('!') {
let rest = rest.trim_start();
if let Some(payload) = rest.strip_prefix("Data_types:") {
if payload.contains("VMF3") {
model = Some(VmfModel::Vmf3);
} else if payload.contains("VMF1") {
model = Some(VmfModel::Vmf1);
}
} else if let Some(payload) = rest.strip_prefix("Range/resolution:") {
let vals: Vec<f64> = payload
.split_whitespace()
.filter_map(|t| t.parse::<f64>().ok())
.collect();
if vals.len() < 6 {
return Err(VmfGridError::MalformedHeader);
}
range = Some([vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]]);
}
continue;
}
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() < 6 {
return Err(VmfGridError::MalformedRow(lineno + 1));
}
let num = |s: &str| {
s.parse::<f64>()
.map_err(|_| VmfGridError::MalformedRow(lineno + 1))
};
ah.push(num(cols[2])?);
aw.push(num(cols[3])?);
zhd.push(num(cols[4])?);
zwd.push(num(cols[5])?);
}
let model = model.ok_or(VmfGridError::MissingDataType)?;
let [latmin, latmax, lonmin, lonmax, dlat_deg, dlon_deg] =
range.ok_or(VmfGridError::MalformedHeader)?;
if dlat_deg <= 0.0 || dlon_deg <= 0.0 {
return Err(VmfGridError::MalformedHeader);
}
let nlat = ((latmax - latmin) / dlat_deg).round() as usize + 1;
let mut nlon = ((lonmax - lonmin) / dlon_deg).round() as usize + 1;
if model == VmfModel::Vmf1 {
let last = lonmin + (nlon as f64 - 1.0) * dlon_deg;
if last > 357.5 + dlon_deg / 2.0 {
nlon -= 1;
}
}
if nlat < 2 || nlon < 2 {
return Err(VmfGridError::MalformedHeader);
}
let expected = nlat * nlon;
if ah.len() != expected {
return Err(VmfGridError::RowCountMismatch {
expected,
found: ah.len(),
});
}
let mut g_ah = vec![0.0; expected];
let mut g_aw = vec![0.0; expected];
let mut g_zhd = vec![0.0; expected];
let mut g_zwd = vec![0.0; expected];
for m in 0..expected {
let lat_desc = m / nlon;
let lon_idx = m % nlon;
let dst = (nlat - 1 - lat_desc) * nlon + lon_idx;
g_ah[dst] = ah[m];
g_aw[dst] = aw[m];
g_zhd[dst] = zhd[m];
g_zwd[dst] = zwd[m];
}
Ok(Self {
model,
lat0: latmin.to_radians(),
lon0: lonmin.to_radians(),
dlat: dlat_deg.to_radians(),
dlon: dlon_deg.to_radians(),
nlat,
nlon,
ah: g_ah,
aw: g_aw,
zhd: g_zhd,
zwd: g_zwd,
})
}
#[must_use]
pub fn interpolate(&self, latitude: f64, longitude: f64) -> VmfCoefficients {
let (i, t) = locate(latitude, self.lat0, self.dlat, self.nlat);
let (j, u) = locate(
normalize_longitude(longitude),
self.lon0,
self.dlon,
self.nlon,
);
let base = i * self.nlon + j;
let bilinear = |g: &[f64]| {
let v00 = g[base];
let v01 = g[base + 1];
let v10 = g[base + self.nlon];
let v11 = g[base + self.nlon + 1];
(1.0 - t) * (1.0 - u) * v00 + (1.0 - t) * u * v01 + t * (1.0 - u) * v10 + t * u * v11
};
VmfCoefficients {
ah: bilinear(&self.ah),
aw: bilinear(&self.aw),
zhd: bilinear(&self.zhd),
zwd: bilinear(&self.zwd),
}
}
}
fn locate(x: f64, x0: f64, dx: f64, n: usize) -> (usize, f64) {
let f = (x - x0) / dx;
let i = (f.floor() as isize).clamp(0, n as isize - 2) as usize;
let t = (f - i as f64).clamp(0.0, 1.0);
(i, t)
}
fn normalize_longitude(a: f64) -> f64 {
let two_pi = 2.0 * PI;
let r = a % two_pi;
if r < 0.0 { r + two_pi } else { r }
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::float_cmp)]
use super::*;
const MINI: &str = "\
! Data_types: VMF3 (lat lon ah aw zhd zwd)
! Range/resolution: 0 10 0 10 10 10
10.0 0.0 0.0010 0.0004 2.0 0.10
10.0 10.0 0.0020 0.0004 2.0 0.10
0.0 0.0 0.0030 0.0004 2.0 0.10
0.0 10.0 0.0040 0.0004 2.0 0.10
";
#[test]
fn parses_real_vmf3_5x5_and_matches_orekit_at_a_node() {
let grid = VmfGrid::parse(include_str!("../tests/fixtures/vmf3_5x5_20181125.H00")).unwrap();
assert_eq!(grid.model(), VmfModel::Vmf3);
let c = grid.interpolate(77.5_f64.to_radians(), 2.5_f64.to_radians());
assert!((c.ah - 0.00117002).abs() < 1e-8, "ah {}", c.ah);
assert!((c.aw - 0.00045484).abs() < 1e-8, "aw {}", c.aw);
assert!((c.zhd - 2.3203).abs() < 1e-4, "zhd {}", c.zhd);
assert!((c.zwd - 0.0191).abs() < 1e-4, "zwd {}", c.zwd);
}
#[test]
fn bilinear_at_the_cell_centre_is_the_mean_of_the_corners() {
let grid = VmfGrid::parse(MINI).unwrap();
let c = grid.interpolate(5.0_f64.to_radians(), 5.0_f64.to_radians());
assert!((c.ah - 0.0025).abs() < 1e-12, "ah {}", c.ah); }
#[test]
fn interpolation_at_a_node_returns_that_node() {
let grid = VmfGrid::parse(MINI).unwrap();
assert!((grid.interpolate(0.0, 0.0).ah - 0.0030).abs() < 1e-12);
let corner = grid.interpolate(10.0_f64.to_radians(), 10.0_f64.to_radians());
assert!((corner.ah - 0.0020).abs() < 1e-12);
}
#[test]
fn lerp_blends_two_epochs_at_the_midpoint() {
let a = VmfCoefficients {
ah: 0.001,
aw: 0.0004,
zhd: 2.0,
zwd: 0.1,
};
let b = VmfCoefficients {
ah: 0.003,
aw: 0.0006,
zhd: 2.4,
zwd: 0.3,
};
let m = a.lerp(b, 0.5);
assert!((m.ah - 0.002).abs() < 1e-12 && (m.zhd - 2.2).abs() < 1e-12);
}
#[test]
fn feeds_the_vmf3_mapping_function() {
let grid = VmfGrid::parse(include_str!("../tests/fixtures/vmf3_5x5_20181125.H00")).unwrap();
let c = grid.interpolate(77.5_f64.to_radians(), 2.5_f64.to_radians());
let (mh, mw) = crate::vmf3_mapping(
c.ah,
c.aw,
77.5_f64.to_radians(),
2.5_f64.to_radians(),
0.0,
329.0,
20.0_f64.to_radians(),
);
assert!(mh > 1.0 && mw > 1.0, "mapping factors {mh} {mw}");
}
#[test]
fn rejects_a_row_count_that_disagrees_with_the_header() {
let bad =
"! Data_types: VMF3\n! Range/resolution: 0 10 0 10 10 10\n 10 0 0.001 0.0004 2.0 0.1\n";
assert!(matches!(
VmfGrid::parse(bad),
Err(VmfGridError::RowCountMismatch { .. })
));
}
#[test]
fn a_malformed_row_reports_the_line_it_is_on() {
let short = "! Data_types: VMF3\n\
! Range/resolution: 0 10 0 10 10 10\n\
10 0 0.001 0.0004 2.0\n";
assert_eq!(
VmfGrid::parse(short).unwrap_err(),
VmfGridError::MalformedRow(3)
);
let unparsable = "! Data_types: VMF3\n\
\n\
10 0 0.001 nan-ish 2.0 0.1\n";
assert_eq!(
VmfGrid::parse(unparsable).unwrap_err(),
VmfGridError::MalformedRow(3)
);
}
#[test]
fn a_single_unusable_axis_is_enough_to_refuse_the_header() {
let with_range = |range: &str| {
format!(
"! Data_types: VMF3\n! Range/resolution: {range}\n\
10 0 0.001 0.0004 2.0 0.1\n 0 0 0.002 0.0004 2.0 0.1\n"
)
};
assert_eq!(
VmfGrid::parse(&with_range("90 -90 0 10 -2 5")).unwrap_err(),
VmfGridError::MalformedHeader
);
assert_eq!(
VmfGrid::parse(&with_range("0 10 360 0 5 -10")).unwrap_err(),
VmfGridError::MalformedHeader
);
assert_eq!(
VmfGrid::parse(&with_range("0 0 0 10 10 10")).unwrap_err(),
VmfGridError::MalformedHeader
);
assert_eq!(
VmfGrid::parse(&with_range("0 10 0 0 10 10")).unwrap_err(),
VmfGridError::MalformedHeader
);
}
fn vmf1_grid(lonmin: f64, lonmax: f64, columns: usize) -> String {
use std::fmt::Write as _;
let mut s = format!(
"! Data_types: VMF1 (lat lon ah aw zhd zwd)\n\
! Range/resolution: 0 2 {lonmin} {lonmax} 2 2.5\n"
);
for lat in [2.0_f64, 0.0] {
for k in 0..columns {
let lon = lonmin + 2.5 * k as f64;
let _ = writeln!(s, " {lat} {lon} 0.0012 0.0004 2.0 0.10");
}
}
s
}
#[test]
fn a_vmf1_axis_that_reaches_360_degrees_drops_its_duplicate_column() {
let wrapped = VmfGrid::parse(&vmf1_grid(0.0, 360.0, 144)).unwrap();
assert_eq!(wrapped.model(), VmfModel::Vmf1);
assert_eq!(wrapped.nlon, 144);
assert_eq!(wrapped.nlat, 2);
let exact = VmfGrid::parse(&vmf1_grid(0.0, 357.5, 144)).unwrap();
assert_eq!(exact.nlon, 144);
let boundary = VmfGrid::parse(&vmf1_grid(1.25, 358.75, 144)).unwrap();
assert_eq!(boundary.nlon, 144);
}
const MINI3: &str = "\
! Data_types: VMF3 (lat lon ah aw zhd zwd)
! Range/resolution: 0 20 0 20 10 10
20.0 0.0 0.0070 0.0004 2.0 0.10
20.0 10.0 0.0080 0.0004 2.0 0.10
20.0 20.0 0.0090 0.0004 2.0 0.10
10.0 0.0 0.0040 0.0004 2.0 0.10
10.0 10.0 0.0050 0.0004 2.0 0.10
10.0 20.0 0.0060 0.0004 2.0 0.10
0.0 0.0 0.0010 0.0004 2.0 0.10
0.0 10.0 0.0020 0.0004 2.0 0.10
0.0 20.0 0.0030 0.0004 2.0 0.10
";
#[test]
fn the_longitude_index_offsets_the_row_it_selects() {
let grid = VmfGrid::parse(MINI3).unwrap();
assert!(
(grid
.interpolate(10.0_f64.to_radians(), 10.0_f64.to_radians())
.ah
- 0.0050)
.abs()
< 1e-12
);
let c = grid.interpolate(15.0_f64.to_radians(), 15.0_f64.to_radians());
assert!((c.ah - 0.0070).abs() < 1e-12, "ah {}", c.ah);
}
#[test]
fn longitude_normalisation_folds_both_ways() {
let two_pi = 2.0 * PI;
assert!((normalize_longitude(-0.1) - (two_pi - 0.1)).abs() < 1e-15);
assert!((normalize_longitude(7.0) - (7.0 - two_pi)).abs() < 1e-15);
assert!((normalize_longitude(-two_pi - 0.25) - (two_pi - 0.25)).abs() < 1e-14);
for a in [0.0, 1.0, 5.5, two_pi - 1e-9] {
assert!((normalize_longitude(a) - a).abs() < 1e-15, "{a}");
}
}
}