use super::{ionex_epoch_from_j2000_seconds, j2000_seconds_from_instant};
use crate::astro::constants::time::SECONDS_PER_DAY_I64;
use crate::astro::time::civil::j2000_seconds;
use crate::astro::time::model::Instant;
use crate::error::{Error, Result};
use crate::format::columns::{raw_field, raw_field_from};
use crate::format::{Diagnostics, RecordRef, Skip, SkipReason};
use crate::validate;
const IONEX_AXIS_DEG_LIMIT: f64 = 360.0;
const IONEX_AXIS_MAX_NODES: usize = 10_000;
const IONEX_AXIS_MAX_SPAN: f64 = (IONEX_AXIS_MAX_NODES - 1) as f64;
#[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: Vec<Instant>,
tec_maps: Vec<Vec<Vec<f64>>>,
rms_maps: Vec<Vec<Vec<f64>>>,
skipped_records: usize,
}
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(&self) -> &[Instant] {
&self.map_epochs
}
pub fn map_epochs_s(&self) -> Vec<i64> {
self.map_epochs
.iter()
.map(|epoch| {
j2000_seconds_from_instant(*epoch)
.expect("IONEX map epoch is convertible to J2000 seconds")
})
.collect()
}
pub fn tec_maps(&self) -> &[Vec<Vec<f64>>] {
&self.tec_maps
}
pub fn rms_maps(&self) -> &[Vec<Vec<f64>>] {
&self.rms_maps
}
pub fn skipped_records(&self) -> usize {
self.skipped_records
}
pub(crate) fn with_map_epochs_shifted_days(&self, days: i64) -> Result<Self> {
let shift_s = days.checked_mul(SECONDS_PER_DAY_I64).ok_or_else(|| {
Error::InvalidInput("IONEX diurnal-shift day count overflows seconds".into())
})?;
let mut shifted = self.clone();
for epoch in &mut shifted.map_epochs {
let seconds = j2000_seconds_from_instant(*epoch).ok_or_else(|| {
Error::Parse("IONEX map epoch cannot be projected onto J2000 seconds".into())
})?;
let target = seconds.checked_add(shift_s).ok_or_else(|| {
Error::InvalidInput("IONEX diurnal-shifted map epoch overflows".into())
})?;
*epoch = ionex_epoch_from_j2000_seconds(target);
}
Ok(shifted)
}
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 diagnostics = Diagnostics::new();
let mut line_number = 0usize;
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() {
line_number += 1;
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 = 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<Instant> = None;
let mut band_vals: Vec<f64> = Vec::new();
let mut reading_band = false;
for line in lines {
line_number += 1;
let label = label_of(line);
match label {
"START OF AUX DATA" => {
diagnostics.push_skip(Skip {
at: RecordRef::at_line(line_number),
reason: SkipReason::UnsupportedRecordType("AUX DATA"),
});
}
"END OF AUX DATA" => {}
"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_instant(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.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 reading_band {
finish_band(&mut cur_grid, &mut band_vals, nlon)?;
}
if let Some(kind) = cur_kind {
return Err(Error::Parse(format!(
"IONEX {} map truncated before END OF {} MAP",
kind.label(),
kind.label()
)));
}
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(),
));
}
validate_map_epochs_strictly_increasing(&map_epochs)?;
Ok(Self {
lat_nodes_deg,
lon_nodes_deg,
dlat_deg: dlat,
dlon_deg: dlon,
shell_height_km,
base_radius_km,
exponent,
map_epochs,
tec_maps,
rms_maps,
skipped_records: diagnostics.skips.len(),
})
}
}
fn validate_map_epochs_strictly_increasing(map_epochs: &[Instant]) -> Result<()> {
let mut previous_s = None;
for (index, &epoch) in map_epochs.iter().enumerate() {
let seconds = j2000_seconds_from_instant(epoch).ok_or_else(|| {
Error::Parse(format!(
"IONEX map epoch {index} cannot be projected onto J2000 seconds"
))
})?;
if previous_s.is_some_and(|previous| seconds <= previous) {
return Err(Error::Parse(
"IONEX map epochs must be strictly increasing".into(),
));
}
previous_s = Some(seconds);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MapKind {
Tec,
Rms,
}
impl MapKind {
fn label(self) -> &'static str {
match self {
Self::Tec => "TEC",
Self::Rms => "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 {
raw_field_from(line, 60).trim()
}
}
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 {
raw_field(line, 0, 60)
}
fn node_axis(v1: f64, v2: f64, step: f64) -> Result<Vec<f64>> {
let v1 = validate_axis_degree(v1, "IONEX grid axis start")?;
let v2 = validate_axis_degree(v2, "IONEX grid axis end")?;
let step = validate_axis_degree(step, "IONEX grid step")?;
if step == 0.0 {
return Err(Error::Parse("IONEX grid step is zero".into()));
}
let guard = 0.5 * step;
let span = validate::finite((v2 + guard - v1) / step, "IONEX grid span")
.map_err(map_axis_field_error)?;
if span < 0.0 {
return Err(Error::Parse("IONEX grid span has the wrong sign".into()));
}
validate::finite_in_range(span, 0.0, IONEX_AXIS_MAX_SPAN, "IONEX grid span")
.map_err(map_axis_field_error)?;
let n = span.floor() as usize + 1;
Ok((0..n).map(|i| v1 + (i as f64) * step).collect())
}
fn validate_axis_degree(value: f64, field: &'static str) -> Result<f64> {
validate::finite_in_range(value, -IONEX_AXIS_DEG_LIMIT, IONEX_AXIS_DEG_LIMIT, field)
.map_err(map_axis_field_error)
}
fn map_axis_field_error(error: validate::FieldError) -> Error {
Error::Parse(format!("IONEX {error}"))
}
fn parse_epoch_instant(line: &str) -> Result<Instant> {
let seconds = parse_epoch_j2000_s(line)?;
Ok(ionex_epoch_from_j2000_seconds(seconds))
}
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: &'static str| -> Result<i64> {
let token = it
.next()
.ok_or_else(|| Error::Parse(format!("IONEX epoch missing {what}")))?;
validate::strict_int::<i64>(token, what)
.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 civil = validate::civil_datetime_with_second_policy(
year,
month,
day,
hour,
minute,
second as f64,
validate::CivilSecondPolicy::Continuous,
)
.map_err(|error| Error::Parse(format!("IONEX epoch {error}")))?;
Ok(j2000_seconds(
civil.year as i32,
civil.month as i32,
civil.day as i32,
civil.hour as i32,
civil.minute as i32,
civil.second,
) as i64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_epoch_rejects_invalid_civil_datetime() {
for line in [
" 2020 2 30 0 0 0 EPOCH OF CURRENT MAP",
" 2020 6 25 24 0 0 EPOCH OF CURRENT MAP",
" 2020 6 25 23 59 60 EPOCH OF CURRENT MAP",
] {
assert!(matches!(parse_epoch_instant(line), Err(Error::Parse(_))));
}
}
#[test]
fn parse_epoch_rejects_years_outside_civil_product_range() {
for line in [
" 100000000000000 1 1 0 0 0 EPOCH OF CURRENT MAP",
" -100000000000000 1 1 0 0 0 EPOCH OF CURRENT MAP",
] {
assert!(matches!(parse_epoch_instant(line), Err(Error::Parse(_))));
}
}
#[test]
fn parse_epoch_accepts_valid_civil_datetime() {
assert_eq!(
j2000_seconds_from_instant(
parse_epoch_instant(
" 2020 6 25 0 0 0 EPOCH OF CURRENT MAP"
)
.expect("valid IONEX epoch")
)
.expect("J2000 seconds"),
646_315_200
);
}
}