use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq)]
pub struct Ionex {
lat_nodes_deg: Vec<f64>,
lon_nodes_deg: Vec<f64>,
dlat_deg: f64,
dlon_deg: f64,
shell_height_km: f64,
base_radius_km: f64,
exponent: i32,
map_epochs_s: Vec<i64>,
tec_maps: Vec<Vec<Vec<f64>>>,
rms_maps: Vec<Vec<Vec<f64>>>,
}
impl Ionex {
pub fn lat_nodes_deg(&self) -> &[f64] {
&self.lat_nodes_deg
}
pub fn lon_nodes_deg(&self) -> &[f64] {
&self.lon_nodes_deg
}
pub fn dlat_deg(&self) -> f64 {
self.dlat_deg
}
pub fn dlon_deg(&self) -> f64 {
self.dlon_deg
}
pub fn shell_height_km(&self) -> f64 {
self.shell_height_km
}
pub fn base_radius_km(&self) -> f64 {
self.base_radius_km
}
pub fn exponent(&self) -> i32 {
self.exponent
}
pub fn map_epochs_s(&self) -> &[i64] {
&self.map_epochs_s
}
pub fn tec_maps(&self) -> &[Vec<Vec<f64>>] {
&self.tec_maps
}
pub fn rms_maps(&self) -> &[Vec<Vec<f64>>] {
&self.rms_maps
}
pub fn parse(bytes: &[u8]) -> Result<Self> {
let text = core::str::from_utf8(bytes)
.map_err(|_| Error::Parse("IONEX is not valid UTF-8".into()))?;
Self::parse_str(text)
}
pub fn parse_str(text: &str) -> Result<Self> {
let mut lines = text.lines();
let mut lat1 = None;
let mut lat2 = None;
let mut dlat = None;
let mut lon1 = None;
let mut lon2 = None;
let mut dlon = None;
let mut shell_height_km = None;
let mut base_radius_km = None;
let mut exponent: i32 = -1;
for line in lines.by_ref() {
let label = label_of(line);
match label {
"LAT1 / LAT2 / DLAT" => {
let (a, b, c) = three_fields(line, "LAT1 / LAT2 / DLAT")?;
lat1 = Some(a);
lat2 = Some(b);
dlat = Some(c);
}
"LON1 / LON2 / DLON" => {
let (a, b, c) = three_fields(line, "LON1 / LON2 / DLON")?;
lon1 = Some(a);
lon2 = Some(b);
dlon = Some(c);
}
"HGT1 / HGT2 / DHGT" => {
let (a, _b, _c) = three_fields(line, "HGT1 / HGT2 / DHGT")?;
shell_height_km = Some(a);
}
"BASE RADIUS" => {
base_radius_km = Some(first_field(line, "BASE RADIUS")?);
}
"EXPONENT" => {
exponent = first_field_int(line, "EXPONENT")?;
}
"END OF HEADER" => break,
_ => {}
}
}
let lat1 = lat1.ok_or_else(|| Error::Parse("IONEX missing LAT1 / LAT2 / DLAT".into()))?;
let lat2 = lat2.unwrap();
let dlat = dlat.unwrap();
let lon1 = lon1.ok_or_else(|| Error::Parse("IONEX missing LON1 / LON2 / DLON".into()))?;
let lon2 = lon2.unwrap();
let dlon = dlon.unwrap();
let shell_height_km = shell_height_km
.ok_or_else(|| Error::Parse("IONEX missing HGT1 / HGT2 / DHGT".into()))?;
let base_radius_km =
base_radius_km.ok_or_else(|| Error::Parse("IONEX missing BASE RADIUS".into()))?;
let lat_nodes_deg = node_axis(lat1, lat2, dlat)?;
let lon_nodes_deg = node_axis(lon1, lon2, dlon)?;
let nlat = lat_nodes_deg.len();
let nlon = lon_nodes_deg.len();
let scale = 10f64.powi(exponent);
let mut map_epochs_s = Vec::new();
let mut tec_maps: Vec<Vec<Vec<f64>>> = Vec::new();
let mut rms_maps: Vec<Vec<Vec<f64>>> = Vec::new();
let mut cur_kind: Option<MapKind> = None;
let mut cur_grid: Vec<Vec<f64>> = Vec::new();
let mut cur_epoch: Option<i64> = None;
let mut band_vals: Vec<f64> = Vec::new();
let mut reading_band = false;
for line in lines {
let label = label_of(line);
match label {
"START OF TEC MAP" => {
cur_kind = Some(MapKind::Tec);
cur_grid = Vec::with_capacity(nlat);
cur_epoch = None;
}
"START OF RMS MAP" => {
cur_kind = Some(MapKind::Rms);
cur_grid = Vec::with_capacity(nlat);
cur_epoch = None;
}
"EPOCH OF CURRENT MAP" => {
cur_epoch = Some(parse_epoch_j2000_s(line)?);
}
"LAT/LON1/LON2/DLON/H" => {
if reading_band {
finish_band(&mut cur_grid, &mut band_vals, nlon)?;
}
reading_band = true;
band_vals = Vec::with_capacity(nlon);
}
"END OF TEC MAP" | "END OF RMS MAP" => {
if reading_band {
finish_band(&mut cur_grid, &mut band_vals, nlon)?;
reading_band = false;
}
if cur_grid.len() != nlat {
return Err(Error::Parse(format!(
"IONEX map has {} latitude bands, expected {nlat}",
cur_grid.len()
)));
}
match cur_kind {
Some(MapKind::Tec) => {
let ep = cur_epoch.ok_or_else(|| {
Error::Parse("IONEX TEC map missing EPOCH OF CURRENT MAP".into())
})?;
map_epochs_s.push(ep);
tec_maps.push(core::mem::take(&mut cur_grid));
}
Some(MapKind::Rms) => {
rms_maps.push(core::mem::take(&mut cur_grid));
}
None => {
return Err(Error::Parse("IONEX END OF MAP without START".into()));
}
}
cur_kind = None;
}
_ => {
if reading_band {
for tok in line.split_whitespace() {
let v: i64 = tok.parse().map_err(|_| {
Error::Parse(format!("IONEX TEC field unparsable: {tok:?}"))
})?;
band_vals.push(v as f64 * scale);
}
}
}
}
}
if tec_maps.is_empty() {
return Err(Error::Parse("IONEX has no TEC maps".into()));
}
if lat_nodes_deg.len() < 2 || lon_nodes_deg.len() < 2 {
return Err(Error::Parse(format!(
"IONEX grid needs >= 2 nodes per axis (got {} lat, {} lon)",
lat_nodes_deg.len(),
lon_nodes_deg.len()
)));
}
if !rms_maps.is_empty() && rms_maps.len() != tec_maps.len() {
return Err(Error::Parse(
"IONEX RMS map count does not match TEC map count".into(),
));
}
Ok(Self {
lat_nodes_deg,
lon_nodes_deg,
dlat_deg: dlat,
dlon_deg: dlon,
shell_height_km,
base_radius_km,
exponent,
map_epochs_s,
tec_maps,
rms_maps,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MapKind {
Tec,
Rms,
}
fn finish_band(grid: &mut Vec<Vec<f64>>, band: &mut Vec<f64>, nlon: usize) -> Result<()> {
if band.len() != nlon {
return Err(Error::Parse(format!(
"IONEX latitude band has {} values, expected {nlon}",
band.len()
)));
}
grid.push(core::mem::take(band));
Ok(())
}
fn label_of(line: &str) -> &str {
if line.len() <= 60 {
line.trim()
} else {
line[60..].trim_end().trim_start()
}
}
fn first_field(line: &str, label: &str) -> Result<f64> {
let data = data_of(line);
data.split_whitespace()
.next()
.ok_or_else(|| Error::Parse(format!("IONEX {label} record empty")))?
.parse()
.map_err(|_| Error::Parse(format!("IONEX {label} field unparsable")))
}
fn first_field_int(line: &str, label: &str) -> Result<i32> {
let data = data_of(line);
data.split_whitespace()
.next()
.ok_or_else(|| Error::Parse(format!("IONEX {label} record empty")))?
.parse()
.map_err(|_| Error::Parse(format!("IONEX {label} field unparsable")))
}
fn three_fields(line: &str, label: &str) -> Result<(f64, f64, f64)> {
let data = data_of(line);
let mut it = data.split_whitespace();
let a = next_float(&mut it, label)?;
let b = next_float(&mut it, label)?;
let c = next_float(&mut it, label)?;
Ok((a, b, c))
}
fn next_float<'a>(it: &mut impl Iterator<Item = &'a str>, label: &str) -> Result<f64> {
it.next()
.ok_or_else(|| Error::Parse(format!("IONEX {label} record short")))?
.parse()
.map_err(|_| Error::Parse(format!("IONEX {label} field unparsable")))
}
fn data_of(line: &str) -> &str {
if line.len() <= 60 {
line
} else {
&line[..60]
}
}
fn node_axis(v1: f64, v2: f64, step: f64) -> Result<Vec<f64>> {
if step == 0.0 {
return Err(Error::Parse("IONEX grid step is zero".into()));
}
let guard = if step > 0.0 { 0.1 } else { -0.1 };
let span = (v2 + guard - v1) / step;
if span < 0.0 {
return Err(Error::Parse("IONEX grid span has the wrong sign".into()));
}
let n = span.floor() as usize + 1;
Ok((0..n).map(|i| v1 + (i as f64) * step).collect())
}
fn parse_epoch_j2000_s(line: &str) -> Result<i64> {
let data = data_of(line);
let mut it = data.split_whitespace();
let mut next_int = |what: &str| -> Result<i64> {
it.next()
.ok_or_else(|| Error::Parse(format!("IONEX epoch missing {what}")))?
.parse::<i64>()
.map_err(|_| Error::Parse(format!("IONEX epoch {what} unparsable")))
};
let year = next_int("year")?;
let month = next_int("month")?;
let day = next_int("day")?;
let hour = next_int("hour")?;
let minute = next_int("minute")?;
let second = next_int("second")?;
let days = days_from_civil(year, month, day) - days_from_civil(2000, 1, 1);
Ok(days * 86_400 - 43_200 + hour * 3_600 + minute * 60 + second)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = m + if m > 2 { -3 } else { 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}