#![doc(
html_logo_url = "https://raw.githubusercontent.com/nav-solutions/.github/master/logos/logo2.jpg"
)]
#![doc = include_str!("../README.md")]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![allow(clippy::type_complexity)]
#[cfg(feature = "serde")]
#[macro_use]
extern crate serde;
extern crate gnss_rs as gnss;
pub mod bias;
pub mod error;
pub mod grid;
pub mod header;
pub mod key;
pub mod linspace;
pub mod mapf;
pub mod production;
pub mod system;
pub mod tec;
pub mod version;
mod cell;
mod coordinates;
mod epoch;
mod ionosphere;
mod quantized;
mod record;
#[cfg(test)]
mod tests;
use std::{
collections::BTreeMap,
fs::File,
io::{BufReader, BufWriter, Read, Write},
path::Path,
str::FromStr,
};
use itertools::Itertools;
use geo::{coord, BoundingRect, Geometry, LineString, Point, Polygon, Rect};
#[cfg(feature = "flate2")]
use flate2::{read::GzDecoder, write::GzEncoder, Compression as GzCompression};
use hifitime::prelude::Epoch;
use crate::{
cell::{MapCell, MapPoint},
coordinates::QuantizedCoordinates,
error::{FormattingError, ParsingError},
header::Header,
key::Key,
production::{ProductionAttributes, Region},
quantized::Quantized,
record::Record,
tec::TEC,
};
pub mod prelude {
pub use crate::{
bias::BiasSource,
cell::MapCell,
error::{FormattingError, ParsingError},
grid::Grid,
header::Header,
ionosphere::IonosphereParameters,
key::Key,
linspace::Linspace,
mapf::MappingFunction,
production::*,
record::Record,
system::ReferenceSystem,
tec::TEC,
version::Version,
Comments, IONEX,
};
pub use geo::{
algorithm::contains::Contains, coord, GeodesicArea, Geometry, LineString, Point, Polygon,
Rect,
};
pub use gnss::prelude::{Constellation, SV};
pub use hifitime::{Duration, Epoch, TimeScale, TimeSeries, Unit};
}
pub type Comments = Vec<String>;
pub(crate) fn fmt_ionex(content: &str, marker: &str) -> String {
if content.len() < 60 {
format!("{:<padding$}{}", content, marker, padding = 60)
} else {
let mut string = String::new();
let nb_lines = num_integer::div_ceil(content.len(), 60);
for i in 0..nb_lines {
let start_off = i * 60;
let end_off = std::cmp::min(start_off + 60, content.len());
let chunk = &content[start_off..end_off];
string.push_str(&format!("{:<padding$}{}", chunk, marker, padding = 60));
if i < nb_lines - 1 {
string.push('\n');
}
}
string
}
}
pub(crate) fn fmt_comment(content: &str) -> String {
fmt_ionex(content, "COMMENT")
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct IONEX {
pub header: Header,
pub record: Record,
pub comments: Comments,
pub production: Option<ProductionAttributes>,
}
impl IONEX {
pub fn new(header: Header, record: Record) -> Self {
Self {
header,
record,
production: None,
comments: Default::default(),
}
}
pub fn with_header(&self, header: Header) -> Self {
Self {
header,
record: self.record.clone(),
comments: self.comments.clone(),
production: self.production.clone(),
}
}
pub fn replace_header(&mut self, header: Header) {
self.header = header.clone();
}
pub fn with_record(&self, record: Record) -> Self {
IONEX {
record,
header: self.header.clone(),
comments: self.comments.clone(),
production: self.production.clone(),
}
}
pub fn replace_record(&mut self, record: Record) {
self.record = record.clone();
}
pub fn is_2d(&self) -> bool {
self.header.map_dimension == 2
}
pub fn is_3d(&self) -> bool {
!self.is_2d()
}
pub fn altitude_width_km(&self) -> f64 {
self.header.grid.altitude.width()
}
pub fn standardized_filename(&self) -> String {
let (agency, region, year, doy) = if let Some(production) = &self.production {
(
production.agency.clone(),
production.region,
production.year - 2000,
production.doy,
)
} else {
("XXX".to_string(), Region::default(), 0, 0)
};
let extension = if let Some(production) = &self.production {
#[cfg(feature = "flate2")]
if production.gzip_compressed {
".gz"
} else {
""
}
} else {
""
};
format!("{}{}{:03}0.{:02}I{}", agency, region, doy, year, extension)
}
pub fn guess_production_attributes(&self, agency: &str) -> Option<ProductionAttributes> {
if agency.len() < 3 {
return None;
}
let first_epoch = self.record.first_epoch()?;
let year = first_epoch.year();
let doy = first_epoch.day_of_year().round() as u32;
let region = Region::Global;
Some(ProductionAttributes {
doy,
region,
year: year as u32,
agency: agency.to_string(),
#[cfg(feature = "flate2")]
gzip_compressed: if let Some(attributes) = &self.production {
attributes.gzip_compressed
} else {
false
},
})
}
pub fn parse<R: Read>(reader: &mut BufReader<R>) -> Result<Self, ParsingError> {
let mut header = Header::parse(reader)?;
let (record, comments) = Record::parse(&mut header, reader)?;
Ok(Self {
header,
record,
comments,
production: Default::default(),
})
}
pub fn format<W: Write>(&self, writer: &mut BufWriter<W>) -> Result<(), FormattingError> {
self.header.format(writer)?;
for comment in self.comments.iter() {
writeln!(writer, "{}", fmt_comment(comment))?;
}
self.record.format(&self.header, writer)?;
writer.flush()?;
Ok(())
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<IONEX, ParsingError> {
let path = path.as_ref();
let file_attributes = match path.file_name() {
Some(filename) => {
let filename = filename.to_string_lossy().to_string();
if let Ok(prod) = ProductionAttributes::from_str(&filename) {
Some(prod)
} else {
None
}
},
_ => None,
};
let fd = File::open(path)?;
let mut reader = BufReader::new(fd);
let mut ionex = Self::parse(&mut reader)?;
ionex.production = file_attributes;
Ok(ionex)
}
pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), FormattingError> {
let fd = File::create(path)?;
let mut writer = BufWriter::new(fd);
self.format(&mut writer)?;
Ok(())
}
#[cfg(feature = "flate2")]
#[cfg_attr(docsrs, doc(cfg(feature = "flate2")))]
pub fn from_gzip_file<P: AsRef<Path>>(path: P) -> Result<IONEX, ParsingError> {
let path = path.as_ref();
let file_attributes = match path.file_name() {
Some(filename) => {
let filename = filename.to_string_lossy().to_string();
if let Ok(prod) = ProductionAttributes::from_str(&filename) {
Some(prod)
} else {
None
}
},
_ => None,
};
let fd = File::open(path)?;
let reader = GzDecoder::new(fd);
let mut reader = BufReader::new(reader);
let mut ionex = Self::parse(&mut reader)?;
ionex.production = file_attributes;
Ok(ionex)
}
#[cfg(feature = "flate2")]
#[cfg_attr(docsrs, doc(cfg(feature = "flate2")))]
pub fn to_gzip_file<P: AsRef<Path>>(&self, path: P) -> Result<(), FormattingError> {
let fd = File::create(path)?;
let compression = GzCompression::new(5);
let mut writer = BufWriter::new(GzEncoder::new(fd, compression));
self.format(&mut writer)?;
Ok(())
}
pub fn is_merged(&self) -> bool {
for comment in self.header.comments.iter() {
if comment.contains("FILE MERGE") {
return true;
}
}
false
}
pub fn map_borders_degrees(&self) -> Rect {
Rect::new(
coord!( x: self.header.grid.longitude.start, y: self.header.grid.latitude.start ),
coord!( x: self.header.grid.longitude.end, y: self.header.grid.latitude.end),
)
}
pub fn to_worldwide_ionex(&self) -> IONEX {
let mut ionex = self.clone();
if let Some(production) = &mut ionex.production {
production.region = Region::Global;
}
ionex.header.grid.latitude.start = -87.5;
ionex.header.grid.latitude.end = 87.5;
ionex.header.grid.longitude.start = -180.0;
ionex.header.grid.longitude.end = 180.0;
ionex
}
pub fn to_regional_ionex(&self, region: Polygon) -> Option<IONEX> {
let mut ionex = IONEX::default();
let bounding_rect = region.bounding_rect()?;
let (min_long, min_lat) = (bounding_rect.min().x, bounding_rect.min().y);
let (max_long, max_lat) = (bounding_rect.max().x, bounding_rect.max().y);
ionex.production = self.production.clone();
if let Some(production) = &mut ionex.production {
production.region = Region::Regional;
}
ionex.header = self.header.clone();
ionex.header.grid.latitude.start = max_lat;
ionex.header.grid.latitude.end = min_lat;
ionex.header.grid.longitude.start = min_long;
ionex.header.grid.longitude.end = max_long;
let region = Geometry::Polygon(region);
let cells = self
.map_cell_iter()
.filter(|cell| cell.contains(®ion))
.collect::<Vec<_>>();
ionex.record = Record::from_map_cells(
self.header.grid.altitude.start,
min_lat,
max_lat,
min_long,
max_long,
&cells,
);
Some(ionex)
}
pub fn grid_precision_stretch(&self, stretch_factor: f64) -> IONEX {
let mut s = self.clone();
s.grid_precision_stretch_mut(stretch_factor);
s
}
pub fn grid_precision_stretch_mut(&mut self, stretch_factor: f64) {
self.header.grid.latitude.start *= stretch_factor;
self.header.grid.latitude.end *= stretch_factor;
self.header.grid.longitude.start *= stretch_factor;
self.header.grid.longitude.end *= stretch_factor;
}
pub fn map_cell_iter(&self) -> Box<dyn Iterator<Item = MapCell> + '_> {
let timeseries = self.header.timeseries();
let lat_pairs = self.header.grid.latitude.quantize().tuple_windows();
let long_pairs = self.header.grid.longitude.quantize().tuple_windows();
let fixed_altitude_km = self.header.grid.altitude.start;
let fixed_altitude_q = Quantized::auto_scaled(fixed_altitude_km);
Box::new(
timeseries
.cartesian_product(lat_pairs.cartesian_product(long_pairs))
.filter_map(move |(epoch, ((lat1, lat2), (long1, long2)))| {
let northeast = Key {
epoch,
coordinates: QuantizedCoordinates::from_quantized(
lat1,
long1,
fixed_altitude_q,
),
};
let northeast = self.record.get(&northeast)?;
let northwest = Key {
epoch,
coordinates: QuantizedCoordinates::from_quantized(
lat1,
long2,
fixed_altitude_q,
),
};
let northwest = self.record.get(&northwest)?;
let southeast = Key {
epoch,
coordinates: QuantizedCoordinates::from_quantized(
lat2,
long2,
fixed_altitude_q,
),
};
let southeast = self.record.get(&southeast)?;
let southwest = Key {
epoch,
coordinates: QuantizedCoordinates::from_quantized(
lat2,
long2,
fixed_altitude_q,
),
};
let southwest = self.record.get(&southwest)?;
Some(MapCell {
epoch,
north_east: MapPoint {
tec: *northeast,
point: Point::new(long1.real_value(), lat1.real_value()),
},
north_west: MapPoint {
tec: *northwest,
point: Point::new(long2.real_value(), lat1.real_value()),
},
south_east: MapPoint {
tec: *southeast,
point: Point::new(long1.real_value(), lat2.real_value()),
},
south_west: MapPoint {
tec: *southwest,
point: Point::new(long2.real_value(), lat2.real_value()),
},
})
}),
)
}
pub fn wrapping_map_cell(&self, epoch: Epoch, geometry: &Geometry<f64>) -> Option<MapCell> {
for cell in self.synchronous_map_cell_iter(epoch) {
if cell.contains(&geometry) {
return Some(cell);
}
}
None
}
pub fn synchronous_map_cell_iter(
&self,
epoch: Epoch,
) -> Box<dyn Iterator<Item = MapCell> + '_> {
Box::new(
self.map_cell_iter()
.filter_map(move |v| if v.epoch == epoch { Some(v) } else { None }),
)
}
pub fn spatial_area_interpolation(
&self,
area: &LineString,
epoch: Epoch,
) -> BTreeMap<Key, TEC> {
let mut values = BTreeMap::new();
let fixed_altitude_km = self.header.grid.altitude.start;
for point in area.points() {
let geo = Geometry::Point(point);
let key = Key::from_decimal_degrees_km(epoch, point.y(), point.x(), fixed_altitude_km);
for cell in self.synchronous_map_cell_iter(epoch) {
if cell.contains(&geo) {
let tec = cell.spatial_interpolation(point);
values.insert(key, tec);
}
}
}
values
}
pub fn temporal_spatial_area_interpolation(
&self,
area: &LineString,
epoch: Epoch,
) -> BTreeMap<Key, TEC> {
let mut values = BTreeMap::new();
let (min_t, max_t) = (
(epoch - self.header.sampling_period),
(epoch + self.header.sampling_period),
);
let (min_t, max_t) = (
min_t.round(self.header.sampling_period),
max_t.round(self.header.sampling_period),
);
for point in area.points() {
let geo = Geometry::Point(point);
for cell_t0 in self.synchronous_map_cell_iter(min_t) {
if cell_t0.contains(&geo) {
for cell_t1 in self.synchronous_map_cell_iter(max_t) {
if cell_t1.contains(&geo) {
if let Some(interpolated) =
cell_t0.temporal_spatial_interpolation(epoch, point, &cell_t1)
{
let key = Key {
epoch,
coordinates: QuantizedCoordinates::from_decimal_degrees(
point.y(),
point.x(),
self.header.grid.altitude.start,
),
};
values.insert(key, interpolated);
}
}
}
}
}
}
values
}
}
#[cfg(test)]
mod test {
use crate::fmt_comment;
use crate::prelude::*;
#[test]
fn fmt_comments_singleline() {
for desc in [
"test",
"just a basic comment",
"just another lengthy comment blahblabblah",
] {
let comment = fmt_comment(desc);
assert!(
comment.len() >= 60,
"comments should be at least 60 byte long"
);
assert_eq!(
comment.find("COMMENT"),
Some(60),
"comment marker should located @ 60"
);
}
}
#[test]
fn fmt_wrapped_comments() {
for desc in ["just trying to form a very lengthy comment that will overflow since it does not fit in a single line",
"just trying to form a very very lengthy comment that will overflow since it does fit on three very meaningful lines. Imazdmazdpoakzdpoakzpdokpokddddddddddddddddddaaaaaaaaaaaaaaaaaaaaaaa"] {
let nb_lines = num_integer::div_ceil(desc.len(), 60);
let comments = fmt_comment(desc);
assert_eq!(comments.lines().count(), nb_lines);
for line in comments.lines() {
assert!(line.len() >= 60, "comment line should be at least 60 byte long");
assert_eq!(line.find("COMMENT"), Some(60), "comment marker should located @ 60");
}
}
}
#[test]
#[ignore]
fn regional_ionex() {
let ionex = IONEX::from_gzip_file("data/IONEX/V1/CKMG0020.22I.gz").unwrap_or_else(|e| {
panic!("Failed to parse CKMG0020: {}", e);
});
let roi = Rect::new(coord!(x: -180.0, y: -85.0), coord!(x: 180.0, y: -82.5));
let regional = ionex.to_regional_ionex(roi.into()).unwrap();
regional.to_file("region.txt").unwrap();
let parsed = IONEX::from_file("region.txt").unwrap_or_else(|e| {
panic!("Failed to parse region.txt: {}", e);
});
}
}