diff --git a/.gitignore b/.gitignore index 40ac12c68..43c3d790c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,6 @@ Cargo.lock rinex/merge.rnx rinex/test.crx +rinex/test.rnx DATA/ WORKSPACE/ diff --git a/crx2rnx/Cargo.toml b/crx2rnx/Cargo.toml index 3a06f681b..7d0c57600 100644 --- a/crx2rnx/Cargo.toml +++ b/crx2rnx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crx2rnx" -version = "2.2.1" +version = "2.3.0" license = "MIT OR Apache-2.0" authors = ["Guillaume W. Bres "] description = "RINEX data decompressor" @@ -12,4 +12,4 @@ readme = "README.md" [dependencies] clap = { version = "4.4.10", features = ["derive", "color"] } -rinex = { path = "../rinex", version = "=0.15.3", features = ["serde"] } +rinex = { path = "../rinex", version = "=0.15.4", features = ["serde"] } diff --git a/crx2rnx/src/cli.rs b/crx2rnx/src/cli.rs index 097dd9df1..cd8ad7d29 100644 --- a/crx2rnx/src/cli.rs +++ b/crx2rnx/src/cli.rs @@ -1,4 +1,4 @@ -use clap::{Arg, ArgMatches, ColorChoice, Command}; +use clap::{Arg, ArgAction, ArgMatches, ColorChoice, Command}; use std::path::{Path, PathBuf}; pub struct Cli { @@ -12,7 +12,7 @@ impl Cli { matches: { Command::new("crx2rnx") .author("Guillaume W. Bres ") - .version("2.0") + .version(env!("CARGO_PKG_VERSION")) .about("Compact RINEX decompression tool") .arg_required_else_help(true) .color(ColorChoice::Always) @@ -23,10 +23,24 @@ impl Cli { .help("Input RINEX file") .required(true), ) + .arg( + Arg::new("short") + .short('s') + .long("short") + .action(ArgAction::SetTrue) + .conflicts_with("output") + .help( + "Prefer shortened filename convention. +Otherwise, we default to modern (V3+) long filenames. +Both will not work well if your input does not follow standard conventions at all.", + ), + ) .arg( Arg::new("output") .short('o') .long("output") + .action(ArgAction::Set) + .conflicts_with_all(["short"]) .help("Custom output file name"), ) .arg( diff --git a/crx2rnx/src/main.rs b/crx2rnx/src/main.rs index a96c1093a..58d28c0bd 100644 --- a/crx2rnx/src/main.rs +++ b/crx2rnx/src/main.rs @@ -38,29 +38,6 @@ fn input_name(path: &PathBuf) -> String { } } -// deduce output name, from input name -fn output_filename(stem: &str, path: &PathBuf) -> String { - let filename = path - .file_name() - .expect("failed to determine input file name") - .to_str() - .expect("failed to determine input file name"); - - if filename.ends_with("gz") { - filename - .strip_suffix(".gz") - .expect("failed to determine output file name") - .replace("crx", "rnx") - .to_string() - } else if filename.ends_with('d') { - filename.replace('d', "o").to_string() - } else if filename.ends_with('D') { - filename.replace('D', "O").to_string() - } else { - format!("{}.rnx", stem) - } -} - fn main() -> Result<(), rinex::Error> { let cli = Cli::new(); @@ -72,16 +49,23 @@ fn main() -> Result<(), rinex::Error> { create_workspace(&workspace_path); - let output_name = match cli.output_name() { - Some(name) => name.clone(), - _ => output_filename(&input_name, &input_path), - }; - let filepath = input_path.to_string_lossy(); let mut rinex = Rinex::from_file(&filepath)?; rinex.crnx2rnx_mut(); // convert to RINEX + // if input was gzip'ed: preserve it + let suffix = if input_name.ends_with(".gz") { + Some(".gz") + } else { + None + }; + + let output_name = match cli.output_name() { + Some(name) => name.clone(), + _ => rinex.standard_filename(cli.matches.get_flag("short"), suffix, None), + }; + let outputpath = format!("{}/{}", workspace_path.to_string_lossy(), output_name); rinex.to_file(&outputpath)?; // dump diff --git a/rinex-cli/Cargo.toml b/rinex-cli/Cargo.toml index 3a11a8129..47dbf74bf 100644 --- a/rinex-cli/Cargo.toml +++ b/rinex-cli/Cargo.toml @@ -31,7 +31,7 @@ horrorshow = "0.8" clap = { version = "4.4.11", features = ["derive", "color"] } hifitime = { version = "3.8.4", features = ["serde", "std"] } gnss-rs = { version = "2.1.2" , features = ["serde"] } -rinex = { path = "../rinex", version = "=0.15.3", features = ["full"] } +rinex = { path = "../rinex", version = "=0.15.4", features = ["full"] } rinex-qc = { path = "../rinex-qc", version = "=0.1.8", features = ["serde"] } sp3 = { path = "../sp3", version = "=1.0.6", features = ["serde", "flate2"] } serde = { version = "1.0", default-features = false, features = ["derive"] } diff --git a/rinex-cli/src/cli/graph.rs b/rinex-cli/src/cli/graph.rs index 1bb2beb47..9f85cb500 100644 --- a/rinex-cli/src/cli/graph.rs +++ b/rinex-cli/src/cli/graph.rs @@ -6,7 +6,13 @@ pub fn subcommand() -> Command { .long_flag("graph") .arg_required_else_help(true) .about( - "RINEX dataset visualization (signals, orbits..), rendered as HTML in the workspace.", + "RINEX data visualization (signals, orbits..), rendered as HTML or CSV in the workspace.", + ) + .arg( + Arg::new("csv") + .long("csv") + .action(ArgAction::SetTrue) + .help("Generate CSV files along HTML plots.") ) .next_help_heading( "RINEX dependent visualizations. diff --git a/rinex-cli/src/cli/mod.rs b/rinex-cli/src/cli/mod.rs index be58308be..013ffd621 100644 --- a/rinex-cli/src/cli/mod.rs +++ b/rinex-cli/src/cli/mod.rs @@ -1,13 +1,18 @@ -use clap::{value_parser, Arg, ArgAction, ArgMatches, ColorChoice, Command}; use log::info; -use map_3d::{ecef2geodetic, geodetic2ecef, Ellipsoid}; -use std::path::{Path, PathBuf}; -use std::str::FromStr; +use std::{ + fs::create_dir_all, + io::Write, + path::{Path, PathBuf}, + str::FromStr, +}; -use crate::Error; +use clap::{value_parser, Arg, ArgAction, ArgMatches, ColorChoice, Command}; +use map_3d::{ecef2geodetic, geodetic2ecef, rad2deg, Ellipsoid}; use rinex::prelude::*; use walkdir::WalkDir; +use crate::{fops::open_with_web_browser, Error}; + // identification mode mod identify; // graph mode @@ -74,6 +79,36 @@ impl Context { let primary_stem: Vec<&str> = ctx_major_stem.split('.').collect(); primary_stem[0].to_string() } + /* + * Utility to prepare subdirectories in the session workspace + */ + pub fn create_subdir(&self, suffix: &str) { + create_dir_all(self.workspace.join(suffix)) + .unwrap_or_else(|e| panic!("failed to generate session dir {}: {:?}", suffix, e)); + } + /* + * Utility to create a file in this session + */ + fn create_file(&self, path: &Path) -> std::fs::File { + std::fs::File::create(path).unwrap_or_else(|e| { + panic!("failed to create {}: {:?}", path.display(), e); + }) + } + /* + * Save HTML content, auto opens it if quiet (-q) is not turned on + */ + pub fn render_html(&self, filename: &str, html: String) { + let path = self.workspace.join(filename); + let mut fd = self.create_file(&path); + write!(fd, "{}", html).unwrap_or_else(|e| { + panic!("failed to render HTML content: {:?}", e); + }); + info!("html rendered in \"{}\"", path.display()); + + if !self.quiet { + open_with_web_browser(path.to_string_lossy().as_ref()); + } + } /* * Creates File/Data context defined by user. * Regroups all provided files/folders, @@ -90,17 +125,21 @@ impl Context { let walkdir = WalkDir::new(dir).max_depth(max_depth); for entry in walkdir.into_iter().filter_map(|e| e.ok()) { if !entry.path().is_dir() { - let filepath = entry.path().to_string_lossy().to_string(); - let ret = data.load(&filepath); + let path = entry.path(); + let ret = data.load(&path.to_path_buf()); if ret.is_err() { - warn!("failed to load \"{}\": {}", filepath, ret.err().unwrap()); + warn!( + "failed to load \"{}\": {}", + path.display(), + ret.err().unwrap() + ); } } } } // load individual files, if any for filepath in cli.input_files() { - let ret = data.load(filepath); + let ret = data.load(&Path::new(filepath).to_path_buf()); if ret.is_err() { warn!("failed to load \"{}\": {}", filepath, ret.err().unwrap()); } @@ -119,10 +158,11 @@ impl Context { }, }; // make sure the workspace is viable and exists, otherwise panic - std::fs::create_dir_all(&path).unwrap_or_else(|_| { + create_dir_all(&path).unwrap_or_else(|e| { panic!( - "failed to create session workspace \"{}\": permission denied!", - path.to_string_lossy() + "failed to create session workspace \"{}\": {:?}", + path.display(), + e ) }); info!("session workspace is \"{}\"", path.to_string_lossy()); @@ -131,7 +171,9 @@ impl Context { rx_ecef: { match cli.manual_position() { Some((x, y, z)) => { - let (lat, lon, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + let (mut lat, mut lon, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + lat = rad2deg(lat); + lon = rad2deg(lon); info!( "using manually defined position: {:?} [ECEF] (lat={:.5}°, lon={:.5}°", (x, y, z), @@ -143,7 +185,9 @@ impl Context { None => { if let Some(data_pos) = data_position { let (x, y, z) = data_pos.to_ecef_wgs84(); - let (lat, lon, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + let (mut lat, mut lon, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + lat = rad2deg(lat); + lon = rad2deg(lon); info!( "position defined in dataset: {:?} [ECEF] (lat={:.5}°, lon={:.5}°", (x, y, z), diff --git a/rinex-cli/src/fops.rs b/rinex-cli/src/fops.rs index 90287ca7d..fb2be526c 100644 --- a/rinex-cli/src/fops.rs +++ b/rinex-cli/src/fops.rs @@ -248,7 +248,7 @@ pub fn substract(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { .crnx2rnx() //TODO remove this in future please .substract( &rinex_b.crnx2rnx(), //TODO: remove this in future please - )? + ) }, t => panic!("operation not feasible for {}", t), }; diff --git a/rinex-cli/src/graph/csv.rs b/rinex-cli/src/graph/csv.rs new file mode 100644 index 000000000..c505df68e --- /dev/null +++ b/rinex-cli/src/graph/csv.rs @@ -0,0 +1,41 @@ +//! helpers to export to CSV if desired, +//! and not only generate HTML plots. + +use hifitime::Epoch; +use std::fs::File; +use std::io::Write; +use std::path::Path; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("failed to write csv file")] + IoError(#[from] std::io::Error), +} + +/* + * Use this to export Time domain plots (most widely used plot type) + */ +pub fn csv_export_timedomain( + path: &Path, + title: &str, + labels: &str, + x: &Vec, + y: &Vec, +) -> Result<(), Error> { + let mut fd = File::create(path)?; + writeln!(fd, "================================================")?; + writeln!(fd, "title : {}", title)?; + writeln!(fd, "labels : {}", labels)?; + writeln!( + fd, + "version: rinex-cli v{} - https://georust.org", + env!("CARGO_PKG_VERSION") + )?; + writeln!(fd, "================================================")?; + for (x, y) in x.iter().zip(y.iter()) { + writeln!(fd, "{:?}, {:.6E}", x, y)?; + } + writeln!(fd, "================================================")?; + Ok(()) +} diff --git a/rinex-cli/src/graph/mod.rs b/rinex-cli/src/graph/mod.rs index 062af5f68..7b3b96839 100644 --- a/rinex-cli/src/graph/mod.rs +++ b/rinex-cli/src/graph/mod.rs @@ -1,8 +1,6 @@ -use crate::{cli::Context, fops::open_with_web_browser, Error}; +use crate::{cli::Context, Error}; use clap::ArgMatches; use rinex::observation::{Combination, Combine, Dcb}; -use std::fs::File; -use std::io::Write; use plotly::{ common::{ @@ -41,6 +39,9 @@ mod naviplot; mod combination; use combination::{plot_gnss_code_mp, plot_gnss_combination, plot_gnss_dcb}; +mod csv; // export to CSV instead of plotting +pub use csv::csv_export_timedomain; + /* * Generates N marker symbols to be used * to differentiate data @@ -522,30 +523,29 @@ fn atmosphere_plot(matches: &ArgMatches) -> bool { } pub fn graph_opmode(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { + /* + * Prepare session: + * + HTML: (default) in this session directly + * + CSV: (option): generate a subdir + */ + let csv_export = matches.get_flag("csv"); + if csv_export { + ctx.create_subdir("CSV"); + } /* * Observations graphs */ if matches.get_flag("obs") { let mut plot_ctx = PlotContext::new(); if ctx.data.has_observation_data() { - record::plot_observations(&ctx.data, &mut plot_ctx); + record::plot_observations(ctx, &mut plot_ctx, csv_export); } - if let Some(data) = ctx.data.meteo_data() { - record::plot_meteo_observations(data, &mut plot_ctx); + if ctx.data.has_meteo_data() { + record::plot_meteo_observations(ctx, &mut plot_ctx, csv_export); } - /* save observations (HTML) */ - let path = ctx.workspace.join("observations.html"); - let mut fd = - File::create(&path).expect("failed to render observations (HTML): permission denied"); - - write!(fd, "{}", plot_ctx.to_html()) - .expect("failed to render observations (HTML): permission denied"); - - info!("observations rendered in \"{}\"", path.display()); - if !ctx.quiet { - open_with_web_browser(path.to_string_lossy().as_ref()); - } + /* save observations */ + ctx.render_html("OBSERVATIONS.html", plot_ctx.to_html()); } /* * GNSS combinations graphs @@ -600,17 +600,8 @@ pub fn graph_opmode(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { ); } - /* save combinations (HTML) */ - let path = ctx.workspace.join("combinations.html"); - let mut fd = File::create(&path) - .expect("failed to render gnss combinations (HTML): permission denied"); - - write!(fd, "{}", plot_ctx.to_html()) - .expect("failed to render gnss combinations (HTML): permission denied"); - info!("gnss combinations rendered in \"{}\"", path.display()); - if !ctx.quiet { - open_with_web_browser(path.to_string_lossy().as_ref()); - } + /* save combinations */ + ctx.render_html("COMBINATIONS.html", plot_ctx.to_html()); } /* * DCB visualization @@ -627,16 +618,8 @@ pub fn graph_opmode(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { "Differential Code Bias [s]", ); - /* save dcb (HTML) */ - let path = ctx.workspace.join("dcb.html"); - let mut fd = File::create(&path).expect("failed to render dcb (HTML): permission denied"); - - write!(fd, "{}", plot_ctx.to_html()) - .expect("failed to render dcb (HTML): permission denied"); - info!("dcb graph rendered in \"{}\"", path.display()); - if !ctx.quiet { - open_with_web_browser(path.to_string_lossy().as_ref()); - } + /* save DCB */ + ctx.render_html("DCB.html", plot_ctx.to_html()); } if matches.get_flag("mp") { let data = ctx.data.obs_data().ok_or(Error::MissingObservationRinex)?; @@ -645,17 +628,8 @@ pub fn graph_opmode(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { let data = data.code_multipath(); plot_gnss_code_mp(&data, &mut plot_ctx, "Code Multipath", "Meters of delay"); - /* save multipath (HTML) */ - let path = ctx.workspace.join("multipath.html"); - let mut fd = - File::create(&path).expect("failed to render multipath (HTML): permission denied"); - - write!(fd, "{}", plot_ctx.to_html()) - .expect("failed to render multiath (HTML): permission denied"); - info!("code multipath rendered in \"{}\"", path.display()); - if !ctx.quiet { - open_with_web_browser(path.to_string_lossy().as_ref()); - } + /* save MP */ + ctx.render_html("MULTIPATH.html", plot_ctx.to_html()); } if navigation_plot(matches) { let mut plot_ctx = PlotContext::new(); @@ -678,47 +652,22 @@ pub fn graph_opmode(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { } plot_residual_ephemeris(&ctx.data, &mut plot_ctx); } - /* save navigation (HTML) */ - let path = ctx.workspace.join("navigation.html"); - let mut fd = - File::create(&path).expect("failed to render navigation (HTML): permission denied"); - - write!(fd, "{}", plot_ctx.to_html()) - .expect("failed to render navigation (HTML): permission denied"); - info!("code multipath rendered in \"{}\"", path.display()); - if !ctx.quiet { - open_with_web_browser(path.to_string_lossy().as_ref()); - } + /* save NAV */ + ctx.render_html("NAVIGATION.html", plot_ctx.to_html()); } if matches.get_flag("sv-clock") { let mut plot_ctx = PlotContext::new(); plot_sv_nav_clock(&ctx.data, &mut plot_ctx); - /* save clock states (HTML) */ - let path = ctx.workspace.join("clocks.html"); - let mut fd = - File::create(&path).expect("failed to render clock states (HTML): permission denied"); - - write!(fd, "{}", plot_ctx.to_html()) - .expect("failed to render clock states (HTML): permission denied"); - info!("clock graphs rendered in \"{}\"", path.display()); - if !ctx.quiet { - open_with_web_browser(path.to_string_lossy().as_ref()); - } + + /* save CLK */ + ctx.render_html("CLOCKS.html", plot_ctx.to_html()); } if atmosphere_plot(matches) { let mut plot_ctx = PlotContext::new(); plot_atmosphere_conditions(ctx, &mut plot_ctx, matches); - /* save (HTML) */ - let path = ctx.workspace.join("atmosphere.html"); - let mut fd = File::create(&path) - .expect("failed to render atmosphere graphs (HTML): permission denied"); - - write!(fd, "{}", plot_ctx.to_html()) - .expect("failed to render atmosphere graphs (HTML): permission denied"); - info!("atmosphere graphs rendered in \"{}\"", path.display()); - if !ctx.quiet { - open_with_web_browser(path.to_string_lossy().as_ref()); - } + + /* save ATMOSPHERE */ + ctx.render_html("ATMOSPHERE.html", plot_ctx.to_html()); } Ok(()) } diff --git a/rinex-cli/src/graph/record/meteo.rs b/rinex-cli/src/graph/record/meteo.rs index 0102f0d41..ecf0d9747 100644 --- a/rinex-cli/src/graph/record/meteo.rs +++ b/rinex-cli/src/graph/record/meteo.rs @@ -1,16 +1,18 @@ -use crate::graph::{build_chart_epoch_axis, PlotContext}; //generate_markers}; +use crate::cli::Context; +use crate::graph::{build_chart_epoch_axis, csv_export_timedomain, PlotContext}; //generate_markers}; use plotly::common::{Marker, MarkerSymbol, Mode}; use plotly::ScatterPolar; -use rinex::prelude::*; +use rinex::prelude::Observable; use statrs::statistics::Statistics; /* * Plots Meteo observations */ -pub fn plot_meteo_observations(rnx: &Rinex, plot_context: &mut PlotContext) { - /* - * 1 plot per physics - */ +pub fn plot_meteo_observations(ctx: &Context, plot_context: &mut PlotContext, csv_export: bool) { + let rnx = ctx.data.meteo_data().unwrap(); // infaillible + /* + * 1 plot per physics + */ for observable in rnx.observable() { let unit = match observable { Observable::Pressure => "hPa", @@ -58,10 +60,30 @@ pub fn plot_meteo_observations(rnx: &Rinex, plot_context: &mut PlotContext) { }) }) .collect(); - let trace = - build_chart_epoch_axis(&observable.to_string(), Mode::LinesMarkers, data_x, data_y) - .marker(Marker::new().symbol(MarkerSymbol::TriangleUp)); + let trace = build_chart_epoch_axis( + &observable.to_string(), + Mode::LinesMarkers, + data_x.clone(), + data_y.clone(), + ) + .marker(Marker::new().symbol(MarkerSymbol::TriangleUp)); plot_context.add_trace(trace); + if csv_export { + let fullpath = ctx + .workspace + .join("CSV") + .join(&format!("{}.csv", observable)); + + let title = format!("{} observations", observable); + csv_export_timedomain( + &fullpath, + &title, + &format!("Epoch, {} [{}]", observable, unit), + &data_x, + &data_y, + ) + .expect("failed to render data as CSV"); + } } /* * Plot Wind Direction diff --git a/rinex-cli/src/graph/record/observation.rs b/rinex-cli/src/graph/record/observation.rs index fbe804c9f..b74e2f177 100644 --- a/rinex-cli/src/graph/record/observation.rs +++ b/rinex-cli/src/graph/record/observation.rs @@ -1,6 +1,6 @@ -use crate::graph::{build_chart_epoch_axis, generate_markers, PlotContext}; +use crate::cli::Context; +use crate::graph::{build_chart_epoch_axis, csv_export_timedomain, generate_markers, PlotContext}; use plotly::common::{Marker, MarkerSymbol, Mode, Visible}; -use rinex::prelude::RnxContext; use rinex::{observation::*, prelude::*}; use std::collections::HashMap; @@ -19,13 +19,12 @@ fn observable_to_physics(observable: &Observable) -> String { /* * Plots given Observation RINEX content */ -pub fn plot_observations(ctx: &RnxContext, plot_context: &mut PlotContext) { - let record = ctx - .obs_data() - .unwrap() // infaillible - .record - .as_obs() - .unwrap(); // infaillible +pub fn plot_observations(ctx: &Context, plot_context: &mut PlotContext, csv_export: bool) { + let obs_data = ctx.data.obs_data().unwrap(); // infaillible + + let header = &obs_data.header; + + let record = obs_data.record.as_obs().unwrap(); // infaillible let mut clk_offset: Vec<(Epoch, f64)> = Vec::new(); // dataset @@ -81,9 +80,34 @@ pub fn plot_observations(ctx: &RnxContext, plot_context: &mut PlotContext) { plot_context.add_timedomain_plot("Receiver Clock Offset", "Clock Offset [s]"); let data_x: Vec = clk_offset.iter().map(|(k, _)| *k).collect(); let data_y: Vec = clk_offset.iter().map(|(_, v)| *v).collect(); - let trace = build_chart_epoch_axis("Clk Offset", Mode::LinesMarkers, data_x, data_y) - .marker(Marker::new().symbol(MarkerSymbol::TriangleUp)); + let trace = build_chart_epoch_axis( + "Clk Offset", + Mode::LinesMarkers, + data_x.clone(), + data_y.clone(), + ) + .marker(Marker::new().symbol(MarkerSymbol::TriangleUp)); plot_context.add_trace(trace); + + if csv_export { + let fullpath = ctx.workspace.join("CSV").join("clock-offset.csv"); + + let title = match header.rcvr.as_ref() { + Some(rcvr) => { + format!("{} (#{}) Clock Offset", rcvr.model, rcvr.sn) + }, + _ => "Receiver Clock Offset".to_string(), + }; + csv_export_timedomain( + &fullpath, + &title, + "Epoch, Clock Offset [s]", + &data_x, + &data_y, + ) + .expect("failed to render data as CSV"); + } + trace!("receiver clock offsets"); } /* @@ -98,7 +122,7 @@ pub fn plot_observations(ctx: &RnxContext, plot_context: &mut PlotContext) { _ => unreachable!(), }; - if ctx.has_navigation_data() { + if ctx.data.has_navigation_data() { // Augmented context, we plot data on two Y axes // one for physical observation, one for sat elevation plot_context.add_timedomain_2y_plot( @@ -120,8 +144,8 @@ pub fn plot_observations(ctx: &RnxContext, plot_context: &mut PlotContext) { let trace = build_chart_epoch_axis( &format!("{:X}({})", sv, observable), Mode::Markers, - data_x, - data_y, + data_x.clone(), + data_y.clone(), ) .marker(Marker::new().symbol(markers[index].clone())) //.web_gl_mode(true) @@ -134,13 +158,28 @@ pub fn plot_observations(ctx: &RnxContext, plot_context: &mut PlotContext) { }); plot_context.add_trace(trace); + if csv_export { + let fullpath = ctx + .workspace + .join("CSV") + .join(&format!("{}-{}.csv", sv, observable)); + csv_export_timedomain( + &fullpath, + &format!("{} observations", observable), + "Epoch, Observation", + &data_x.clone(), + &data_y.clone(), + ) + .expect("failed to render data as CSV"); + } + if index == 0 && physics == "Signal Strength" { // 1st Carrier encountered: plot SV only once // we also only augment the SSI plot when NAV context is provided - if let Some(nav) = &ctx.nav_data() { + if let Some(nav) = &ctx.data.nav_data() { // grab elevation angle let data: Vec<(Epoch, f64)> = nav - .sv_elevation_azimuth(ctx.ground_position()) + .sv_elevation_azimuth(ctx.data.ground_position()) .map(|(epoch, _sv, (elev, _a))| (epoch, elev)) .collect(); // plot (Epoch, Elev) diff --git a/rinex-cli/src/positioning/mod.rs b/rinex-cli/src/positioning/mod.rs index 6b4449f01..02f68c21c 100644 --- a/rinex-cli/src/positioning/mod.rs +++ b/rinex-cli/src/positioning/mod.rs @@ -19,7 +19,7 @@ use rtk::prelude::{ NgModel, Solver, Vector3, }; -use map_3d::{ecef2geodetic, Ellipsoid}; +use map_3d::{ecef2geodetic, rad2deg, Ellipsoid}; use thiserror::Error; #[derive(Debug, Error)] @@ -47,6 +47,7 @@ pub fn tropo_components(meteo: Option<&Rinex>, t: Epoch, lat_ddeg: f64) -> Optio Observable::ZenithDryDelay => { let (x, y, z, _) = s.position?; let (lat, _, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + let lat = rad2deg(lat); if (lat - lat_ddeg).abs() < MAX_LATDDEG_DELTA { let value = rnx .zenith_dry_delay() @@ -61,7 +62,8 @@ pub fn tropo_components(meteo: Option<&Rinex>, t: Epoch, lat_ddeg: f64) -> Optio }, Observable::ZenithWetDelay => { let (x, y, z, _) = s.position?; - let (lat, _, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + let (mut lat, _, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + lat = rad2deg(lat); if (lat - lat_ddeg).abs() < MAX_LATDDEG_DELTA { let value = rnx .zenith_wet_delay() diff --git a/rinex-cli/src/positioning/ppp/post_process.rs b/rinex-cli/src/positioning/ppp/post_process.rs index 8e10783dd..c90594302 100644 --- a/rinex-cli/src/positioning/ppp/post_process.rs +++ b/rinex-cli/src/positioning/ppp/post_process.rs @@ -49,7 +49,9 @@ pub fn post_process( let (x, y, z) = ctx.rx_ecef.unwrap(); // cannot fail at this point - let (lat_ddeg, lon_ddeg, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + let (lat_rad, lon_rad, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + let lat_ddeg = rad2deg(lat_rad); + let lon_ddeg = rad2deg(lon_rad); let epochs = results.keys().copied().collect::>(); diff --git a/rinex-qc/Cargo.toml b/rinex-qc/Cargo.toml index 73ad2f363..26245d931 100644 --- a/rinex-qc/Cargo.toml +++ b/rinex-qc/Cargo.toml @@ -28,7 +28,7 @@ itertools = "0.12.0" statrs = "0.16" sp3 = { path = "../sp3", version = "=1.0.6", features = ["serde"] } rinex-qc-traits = { path = "../qc-traits", version = "=0.1.1" } -rinex = { path = "../rinex", version = "=0.15.3", features = ["full"] } +rinex = { path = "../rinex", version = "=0.15.4", features = ["full"] } gnss-rs = { version = "2.1.2", features = ["serde"] } [dev-dependencies] diff --git a/rinex/Cargo.toml b/rinex/Cargo.toml index 9596dc383..2fc7dc023 100644 --- a/rinex/Cargo.toml +++ b/rinex/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rinex" -version = "0.15.3" +version = "0.15.4" license = "MIT OR Apache-2.0" authors = ["Guillaume W. Bres "] description = "Package to parse and analyze RINEX data" diff --git a/rinex/src/context/mod.rs b/rinex/src/context/mod.rs index 33452ef70..d07010f87 100644 --- a/rinex/src/context/mod.rs +++ b/rinex/src/context/mod.rs @@ -1,5 +1,5 @@ //! RINEX post processing context -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use thiserror::Error; use walkdir::WalkDir; @@ -26,6 +26,8 @@ use rinex_qc_traits::HtmlReport; pub enum Error { #[error("can only form a RINEX context from a directory, not a single file")] NotADirectory, + #[error("failed to determine filename")] + FileNameDetermination, #[error("parsing error")] RinexError(#[from] crate::Error), #[error("invalid file type")] @@ -86,47 +88,55 @@ pub struct RnxContext { impl RnxContext { /// Build a RINEX post processing context from either a directory or a single file - pub fn new(path: PathBuf) -> Result { + pub fn new(path: &PathBuf) -> Result { if path.is_dir() { /* recursive builder */ Self::from_directory(path) } else { /* load a single file */ - Self::from_file(path) + Self::from_path(path) } } /* * Builds Self from a single file */ - fn from_file(path: PathBuf) -> Result { + fn from_path(path: &PathBuf) -> Result { let mut ctx = Self::default(); - ctx.load(path.to_string_lossy().as_ref())?; + ctx.load(path)?; Ok(ctx) } /* * Builds Self by recursive browsing */ - fn from_directory(path: PathBuf) -> Result { + fn from_directory(path: &PathBuf) -> Result { let mut ret = RnxContext::default(); let walkdir = WalkDir::new(path.to_string_lossy().to_string()).max_depth(5); for entry in walkdir.into_iter().filter_map(|e| e.ok()) { - if !entry.path().is_dir() { - let fullpath = entry.path().to_string_lossy().to_string(); - match ret.load(&fullpath) { - Ok(_) => trace!( - "loaded \"{}\"", - entry.path().file_name().unwrap().to_string_lossy() - ), - Err(e) => error!("failed to load \"{}\", {:?}", fullpath, e), + let path = entry.path().to_path_buf(); + let filename = path + .file_name() + .ok_or(Error::FileNameDetermination)? + .to_string_lossy() + .to_string(); + + if !path.is_dir() { + match ret.load(&path) { + Ok(_) => trace!("loaded \"{}\"", filename), + Err(e) => error!("failed to load \"{}\", {:?}", path.display(), e), } } } Ok(ret) } /// Load individual file into Context - pub fn load(&mut self, filename: &str) -> Result<(), Error> { - if let Ok(rnx) = Rinex::from_file(filename) { - let path = Path::new(filename); + pub fn load(&mut self, path: &PathBuf) -> Result<(), Error> { + let fullpath = path.to_string_lossy().to_string(); + let filename = path + .file_name() + .ok_or(Error::FileNameDetermination)? + .to_string_lossy() + .to_string(); + if let Ok(rnx) = Rinex::from_path(path) { if rnx.is_observation_rinex() { self.load_obs(path, &rnx)?; trace!("loaded observations \"{}\"", filename); @@ -145,8 +155,7 @@ impl RnxContext { } else { return Err(Error::NonSupportedType); } - } else if let Ok(sp3) = SP3::from_file(filename) { - let path = Path::new(filename); + } else if let Ok(sp3) = SP3::from_file(&fullpath) { self.load_sp3(path, &sp3)?; trace!("loaded sp3 \"{}\"", filename); } @@ -373,7 +382,7 @@ impl RnxContext { } None } - fn load_obs(&mut self, path: &Path, rnx: &Rinex) -> Result<(), Error> { + fn load_obs(&mut self, path: &PathBuf, rnx: &Rinex) -> Result<(), Error> { if let Some(obs) = &mut self.obs { obs.data.merge_mut(rnx)?; obs.paths.push(path.to_path_buf()); @@ -385,7 +394,7 @@ impl RnxContext { } Ok(()) } - fn load_nav(&mut self, path: &Path, rnx: &Rinex) -> Result<(), Error> { + fn load_nav(&mut self, path: &PathBuf, rnx: &Rinex) -> Result<(), Error> { if let Some(nav) = &mut self.nav { nav.data.merge_mut(rnx)?; nav.paths.push(path.to_path_buf()); @@ -397,7 +406,7 @@ impl RnxContext { } Ok(()) } - fn load_meteo(&mut self, path: &Path, rnx: &Rinex) -> Result<(), Error> { + fn load_meteo(&mut self, path: &PathBuf, rnx: &Rinex) -> Result<(), Error> { if let Some(meteo) = &mut self.meteo { meteo.data.merge_mut(rnx)?; meteo.paths.push(path.to_path_buf()); @@ -409,7 +418,7 @@ impl RnxContext { } Ok(()) } - fn load_ionex(&mut self, path: &Path, rnx: &Rinex) -> Result<(), Error> { + fn load_ionex(&mut self, path: &PathBuf, rnx: &Rinex) -> Result<(), Error> { if let Some(ionex) = &mut self.ionex { ionex.data.merge_mut(rnx)?; ionex.paths.push(path.to_path_buf()); @@ -421,7 +430,7 @@ impl RnxContext { } Ok(()) } - fn load_antex(&mut self, path: &Path, rnx: &Rinex) -> Result<(), Error> { + fn load_antex(&mut self, path: &PathBuf, rnx: &Rinex) -> Result<(), Error> { if let Some(atx) = &mut self.atx { atx.data.merge_mut(rnx)?; atx.paths.push(path.to_path_buf()); @@ -433,7 +442,7 @@ impl RnxContext { } Ok(()) } - fn load_sp3(&mut self, path: &Path, sp3: &SP3) -> Result<(), Error> { + fn load_sp3(&mut self, path: &PathBuf, sp3: &SP3) -> Result<(), Error> { if let Some(data) = &mut self.sp3 { /* extend existing context */ data.data.merge_mut(sp3)?; diff --git a/rinex/src/header.rs b/rinex/src/header.rs index 2b2d892f8..1615d6c0f 100644 --- a/rinex/src/header.rs +++ b/rinex/src/header.rs @@ -21,62 +21,15 @@ use crate::{ use hifitime::Epoch; use std::io::prelude::*; use std::str::FromStr; -use strum_macros::EnumString; use thiserror::Error; +use crate::marker::{GeodeticMarker, MarkerType}; + use crate::{fmt_comment, fmt_rinex}; #[cfg(feature = "serde")] use serde::Serialize; -#[derive(Default, Clone, Debug, PartialEq, Eq, EnumString)] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -pub enum MarkerType { - /// Earth fixed & high precision - #[strum(serialize = "GEODETIC", serialize = "Geodetic")] - #[default] - Geodetic, - /// Earth fixed & low precision - #[strum(serialize = "NON GEODETIC", serialize = "NonGeodetic")] - NonGeodetic, - /// Generated from network - #[strum(serialize = "NON PHYSICAL", serialize = "NonPhysical")] - NonPhysical, - /// Orbiting space vehicle - #[strum(serialize = "SPACE BORNE", serialize = "Spaceborne")] - Spaceborne, - /// Aircraft, balloon.. - #[strum(serialize = "AIR BORNE", serialize = "Airborne")] - Airborne, - /// Mobile water craft - #[strum(serialize = "WATER CRAFT", serialize = "Watercraft")] - Watercraft, - /// Mobile terrestrial vehicle - #[strum(serialize = "GROUND CRAFT", serialize = "Groundcraft")] - Groundcraft, - /// Fixed on water surface - #[strum(serialize = "FIXED BUOY", serialize = "FixedBuoy")] - FixedBuoy, - /// Floating on water surface - #[strum(serialize = "FLOATING BUOY", serialize = "FloatingBuoy")] - FloatingBuoy, - /// Floating on ice - #[strum(serialize = "FLOATING ICE", serialize = "FloatingIce")] - FloatingIce, - /// Fixed on glacier - #[strum(serialize = "GLACIER", serialize = "Glacier")] - Glacier, - /// Rockets, shells, etc.. - #[strum(serialize = "BALLISTIC", serialize = "Ballistic")] - Ballistic, - /// Animal carrying a receiver - #[strum(serialize = "ANIMAL", serialize = "Animal")] - Animal, - /// Human being carrying a receiver - #[strum(serialize = "HUMAN", serialize = "Human")] - Human, -} - /// DCB compensation description #[derive(Debug, Clone, Default, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -120,18 +73,14 @@ pub struct Header { pub run_by: String, /// program's `date` pub date: String, - /// station label - pub station: String, - /// station identifier - pub station_id: String, - /// optionnal station URL + /// optionnal station/marker/agency URL pub station_url: String, /// name of observer pub observer: String, /// name of production agency pub agency: String, - /// optionnal receiver placement infos - pub marker_type: Option, + /// optionnal [GeodeticMarker] + pub geodetic_marker: Option, /// Glonass FDMA channels pub glo_channels: HashMap, /// optionnal leap seconds infos @@ -195,8 +144,6 @@ pub enum ParsingError { VersionNotSupported(String), #[error("unknown RINEX type \"{0}\"")] TypeParsing(String), - #[error("unknown marker type \"{0}\"")] - MarkerType(String), #[error("failed to parse observable")] ObservableParsing(#[from] observable::ParsingError), #[error("constellation parsing error")] @@ -287,14 +234,12 @@ impl Header { let mut program = String::new(); let mut run_by = String::new(); let mut date = String::new(); - let mut station = String::new(); - let mut station_id = String::new(); let mut observer = String::new(); let mut agency = String::new(); let mut license: Option = None; let mut doi: Option = None; let mut station_url = String::new(); - let mut marker_type: Option = None; + let mut geodetic_marker = Option::::None; let mut glo_channels: HashMap = HashMap::new(); let mut rcvr: Option = None; let mut rcvr_antenna: Option = None; @@ -531,13 +476,19 @@ impl Header { let (date_str, _) = rem.split_at(20); date = date_str.trim().to_string(); } else if marker.contains("MARKER NAME") { - station = content.split_at(20).0.trim().to_string() + let name = content.split_at(20).0.trim(); + geodetic_marker = Some(GeodeticMarker::default().with_name(name)); } else if marker.contains("MARKER NUMBER") { - station_id = content.split_at(20).0.trim().to_string() + let number = content.split_at(20).0.trim(); + if let Some(ref mut marker) = geodetic_marker { + *marker = marker.with_number(number); + } } else if marker.contains("MARKER TYPE") { let code = content.split_at(20).0.trim(); - if let Ok(marker) = MarkerType::from_str(code) { - marker_type = Some(marker); + if let Ok(mtype) = MarkerType::from_str(code) { + if let Some(ref mut marker) = geodetic_marker { + marker.marker_type = Some(mtype); + } } } else if marker.contains("OBSERVER / AGENCY") { let (obs, ag) = content.split_at(20); @@ -1157,14 +1108,12 @@ impl Header { program, run_by, date, - station, - station_id, + geodetic_marker, agency, observer, license, doi, station_url, - marker_type, rcvr, glo_channels, leap, @@ -1724,8 +1673,12 @@ impl std::fmt::Display for Header { ) )?; - writeln!(f, "{}", fmt_rinex(&self.station, "MARKER NAME"))?; - writeln!(f, "{}", fmt_rinex(&self.station_id, "MARKER NUMBER"))?; + if let Some(marker) = &self.geodetic_marker { + writeln!(f, "{}", fmt_rinex(&marker.name, "MARKER NAME"))?; + if let Some(number) = marker.number() { + writeln!(f, "{}", fmt_rinex(&number, "MARKER NUMBER"))?; + } + } // APRIORI POS if let Some(position) = self.ground_position { @@ -1884,7 +1837,7 @@ impl Merge for Header { } merge::merge_mut_vec(&mut self.comments, &rhs.comments); - merge::merge_mut_option(&mut self.marker_type, &rhs.marker_type); + merge::merge_mut_option(&mut self.geodetic_marker, &rhs.geodetic_marker); merge::merge_mut_option(&mut self.license, &rhs.license); merge::merge_mut_option(&mut self.data_scaling, &rhs.data_scaling); merge::merge_mut_option(&mut self.doi, &rhs.doi); diff --git a/rinex/src/lib.rs b/rinex/src/lib.rs index b2618bbbf..702d79a88 100644 --- a/rinex/src/lib.rs +++ b/rinex/src/lib.rs @@ -14,6 +14,7 @@ pub mod hardware; pub mod hatanaka; pub mod header; pub mod ionex; +pub mod marker; pub mod merge; pub mod meteo; pub mod navigation; @@ -25,9 +26,10 @@ pub mod version; mod bibliography; mod ground_position; -mod leap; -mod linspace; +mod leap; // leap second +mod linspace; // grid and linear spacing mod observable; +mod production; // RINEX production infrastructure // physical observations #[cfg(test)] mod tests; @@ -45,21 +47,28 @@ extern crate lazy_static; pub mod reader; use reader::BufferedReader; -use std::io::Write; //, Read}; pub mod writer; use writer::BufferedWriter; use std::collections::{BTreeMap, HashMap}; +use std::io::Write; //, Read}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + use thiserror::Error; use antex::{Antenna, AntennaSpecific, FrequencyDependentData}; -use hifitime::{Duration, Unit}; use ionex::TECPlane; use observable::Observable; use observation::Crinex; use version::Version; +use production::{ProductionAttributes, FFU, PPU}; + +use hifitime::Unit; +//use hifitime::{efmt::Format as EpochFormat, efmt::Formatter as EpochFormatter, Duration, Unit}; + /// Package to include all basic structures pub mod prelude { #[cfg(feature = "antex")] @@ -105,25 +114,6 @@ pub use split::Split; #[macro_use] extern crate serde; -/// File creation helper. -/// Returns `str` description, as one letter -/// lowercase, used in RINEX file name to describe -/// the sampling period. RINEX specifications: -/// “a” = 00:00:00 - 00:59:59 -/// “b” = 01:00:00 - 01:59:59 -/// [...] -/// "x" = 23:00:00 - 23:59:59 -macro_rules! hourly_session { - ($hour: expr) => { - if $hour == 23 { - "x".to_string() - } else { - let c: char = ($hour + 97).into(); - String::from(c) - } - }; -} - #[cfg(docrs)] pub use bibliography::Bibliography; @@ -193,7 +183,11 @@ pub(crate) fn fmt_comment(content: &str) -> String { /// // value, but that will soon change /// assert_eq!(rnx.header.date, "20210102 00:01:40UTC"); /// assert_eq!(rnx.header.observer, "H. VAN DER MAREL"); -/// assert_eq!(rnx.header.station_id, "13502M004"); +/// +/// let marker = rnx.header.geodetic_marker +/// .as_ref() +/// .unwrap(); +/// assert_eq!(marker.number(), Some("13502M004".to_string())); /// /// // Constellation describes which kind of vehicles /// // are to be encountered in the record, or which @@ -235,6 +229,11 @@ pub struct Rinex { /// `record` contains `RINEX` file body /// and is type and constellation dependent pub record: record::Record, + /* + * File Production attributes, attached to Self + * parsed from files that follow stadard naming conventions + */ + prod_attr: Option, } #[derive(Error, Debug)] @@ -255,37 +254,35 @@ impl Rinex { header, record, comments: record::Comments::new(), + prod_attr: None, } } - /// Returns a copy of self with given header attributes. pub fn with_header(&self, header: Header) -> Self { - Rinex { + Self { header, record: self.record.clone(), comments: self.comments.clone(), + prod_attr: self.prod_attr.clone(), } } - /// Replaces header section. pub fn replace_header(&mut self, header: Header) { self.header = header.clone(); } - /// Returns a copy of self with given internal record. pub fn with_record(&self, record: record::Record) -> Self { Rinex { header: self.header.clone(), comments: self.comments.clone(), record, + prod_attr: self.prod_attr.clone(), } } - /// Replaces internal record. pub fn replace_record(&mut self, record: record::Record) { self.record = record.clone(); } - /// Converts self to CRINEX (compressed RINEX) format. /// If current revision is < 3 then file gets converted to CRINEX1 /// format, otherwise, modern Observations are converted to CRINEX3. @@ -298,14 +295,13 @@ impl Rinex { /// /// // convert to CRINEX /// let crinex = rinex.rnx2crnx(); - /// assert!(crinex.to_file("test.crx").is_ok(), "failed to generate file"); + /// assert!(crinex.to_file("test.crx").is_ok()); /// ``` pub fn rnx2crnx(&self) -> Self { let mut s = self.clone(); s.rnx2crnx_mut(); s } - /// [`Self::rnx2crnx`] mutable implementation pub fn rnx2crnx_mut(&mut self) { if self.is_observation_rinex() { @@ -382,159 +378,314 @@ impl Rinex { }); } } - - /// IONEX specific filename convention - fn ionex_filename(&self) -> String { - let mut ret: String = "ccc".to_string(); // 3 figue Analysis center - ret.push('e'); // extension or region code "G" for global ionosphere maps - ret.push_str("ddd"); // day of the year of first record - ret.push('h'); // file sequence number (1,2,...) or hour (A, B.., Z) within day - ret.push_str("yy"); // 2 digit year - ret.push('I'); // ionex - //ret.to_uppercase(); //TODO - ret - } - - /// File creation helper, returns a filename that would respect - /// naming conventions, based on self attributes. - pub fn filename(&self) -> String { - if self.is_ionex() { - return self.ionex_filename(); - } + /// Returns a filename that would describe Self according to naming conventions. + /// For this information to be 100% complete, Self must come from a file + /// that follows these conventions itself. + /// Otherwise you must provide [ProductionAttributes] yourself with "custom". + /// In any case, this method is infaillible. You will just lack more or + /// less information, depending on current context. + /// If you're working with Observation, Navigation or Meteo data, + /// and prefered shorter filenames (V2 like format): force short to "true". + /// Otherwse, we will prefer modern V3 like formats. + /// Use "suffix" to append a custom suffix like ".gz" for example. + /// NB this will only output uppercase filenames (as per standard specs). + pub fn standard_filename( + &self, + short: bool, + suffix: Option<&str>, + custom: Option, + ) -> String { let header = &self.header; - let rtype = header.rinex_type; - let nnnn = header.station.as_str()[0..4].to_lowercase(); - //TODO: - //self.header.date should be a datetime object - //but it is complex to parse.. - let ddd = String::from("DDD"); - let epoch: Epoch = match rtype { - types::Type::ObservationData - | types::Type::NavigationData - | types::Type::MeteoData - | types::Type::ClockData => self.epoch().next().unwrap(), - _ => todo!(), // other files require a dedicated procedure - }; - if header.version.major < 3 { - //TODO - let (_, _, _, h, _, _, _) = epoch.to_gregorian_utc(); - let s = hourly_session!(h); - let yy = "YY"; - //let yy = format!("{:02}", epoch.date.year()); - let t: String = match rtype { - types::Type::ObservationData => { - if header.is_crinex() { - String::from("d") - } else { - String::from("o") - } - }, - types::Type::NavigationData => { - if let Some(c) = header.constellation { - if c == Constellation::Glonass { - String::from("g") + let rinextype = header.rinex_type; + let is_crinex = header.is_crinex(); + let constellation = header.constellation; + + let mut filename = match rinextype { + RinexType::IonosphereMaps => { + let name = match custom { + Some(ref custom) => custom.name.clone(), + None => { + if let Some(attr) = &self.prod_attr { + attr.name.clone() + } else { + "XXX".to_string() + } + }, + }; + let region = match &custom { + Some(ref custom) => custom.region.unwrap_or('G'), + None => { + if let Some(attr) = &self.prod_attr { + attr.region.unwrap_or('G') + } else { + 'G' + } + }, + }; + let ddd = match &custom { + Some(ref custom) => format!("{:03}", custom.doy), + None => { + if let Some(epoch) = self.first_epoch() { + //FIXME: hifitime release (DOY+GNSS) + let mut ddd = Epoch::from_duration( + epoch.to_duration_in_time_scale(TimeScale::UTC), + TimeScale::UTC, + ) + .day_of_year() + .round() as u32 + + 1; + ddd %= 365; + format!("{:03}", ddd) + } else { + "DDD".to_string() + } + }, + }; + let yy = match &custom { + Some(ref custom) => format!("{:02}", custom.year - 2_000), + None => { + if let Some(epoch) = self.first_epoch() { + format!("{:02}", epoch.to_gregorian_utc().0 - 2_000) + } else { + "YY".to_string() + } + }, + }; + ProductionAttributes::ionex_format(&name, region, &ddd, &yy) + }, + RinexType::ObservationData | RinexType::MeteoData | RinexType::NavigationData => { + let name = match custom { + Some(ref custom) => custom.name.clone(), + None => { + if let Some(attr) = &self.prod_attr { + attr.name.clone() + } else { + "XXXX".to_string() + } + }, + }; + let ddd = match &custom { + Some(ref custom) => format!("{:03}", custom.doy), + None => { + if let Some(epoch) = self.first_epoch() { + //FIXME: hifitime release (DOY+GNSS) + let mut ddd = Epoch::from_duration( + epoch.to_duration_in_time_scale(TimeScale::UTC), + TimeScale::UTC, + ) + .day_of_year() + .round() as u32 + + 1; + ddd %= 365; + format!("{:03}", ddd) + } else { + "DDD".to_string() + } + }, + }; + if short { + let yy = match &custom { + Some(ref custom) => format!("{:02}", custom.year - 2_000), + None => { + if let Some(epoch) = self.first_epoch() { + format!("{:02}", epoch.to_gregorian_utc().0 - 2_000) + } else { + "YY".to_string() + } + }, + }; + let ext = match rinextype { + RinexType::ObservationData => { + if is_crinex { + 'D' + } else { + 'O' + } + }, + RinexType::MeteoData => 'M', + RinexType::NavigationData => match constellation { + Some(Constellation::Glonass) => 'G', + _ => 'N', + }, + _ => unreachable!("unreachable"), + }; + ProductionAttributes::rinex_short_format(&name, &ddd, &yy, ext) + } else { + /* long /V3 like format */ + let country = match &custom { + Some(ref custom) => { + if let Some(details) = &custom.details { + details.country.to_string() + } else { + "CCC".to_string() + } + }, + None => { + if let Some(attr) = &self.prod_attr { + if let Some(details) = &attr.details { + details.country.to_string() + } else { + "CCC".to_string() + } + } else { + "CCC".to_string() + } + }, + }; + let src = match &header.rcvr { + Some(_) => 'R', // means GNSS rcvr + None => { + if let Some(attr) = &self.prod_attr { + if let Some(details) = &attr.details { + details.data_src.to_char() + } else { + 'U' // means unspecified + } + } else { + 'U' // means unspecified + } + }, + }; + let yyyy = match &custom { + Some(ref custom) => format!("{:04}", custom.year), + None => { + if let Some(epoch) = self.first_epoch() { + format!("{:04}", epoch.to_gregorian_utc().0) + } else { + "YYYY".to_string() + } + }, + }; + let (hh, mm) = match &custom { + Some(ref custom) => { + if let Some(details) = &custom.details { + (format!("{:02}", details.hh), format!("{:02}", details.mm)) + } else { + ("HH".to_string(), "MM".to_string()) + } + }, + None => { + if let Some(epoch) = self.first_epoch() { + let (_, _, _, hh, mm, _, _) = epoch.to_gregorian_utc(); + (format!("{:02}", hh), format!("{:02}", mm)) + } else { + ("HH".to_string(), "MM".to_string()) + } + }, + }; + // FFU sampling rate + let ffu = match self.dominant_sample_rate() { + Some(duration) => FFU::from(duration).to_string(), + None => { + if let Some(ref custom) = custom { + if let Some(details) = &custom.details { + if let Some(ffu) = details.ffu { + ffu.to_string() + } else { + "XXX".to_string() + } + } else { + "XXX".to_string() + } + } else { + "XXX".to_string() + } + }, + }; + // ffu only in OBS file names + let ffu = match rinextype { + RinexType::ObservationData => Some(ffu), + _ => None, + }; + // PPU periodicity + let ppu = if let Some(ref custom) = custom { + if let Some(details) = &custom.details { + details.ppu + } else { + PPU::Unspecified + } + } else if let Some(ref attr) = self.prod_attr { + if let Some(details) = &attr.details { + details.ppu } else { - String::from("n") + PPU::Unspecified } } else { - String::from("x") - } - }, - types::Type::MeteoData => String::from("m"), - _ => todo!(), - }; - format!("{}{}{}.{}{}", nnnn, ddd, s, yy, t) - } else { - let m = String::from("0"); - let r = String::from("0"); - //TODO: 3 letter contry code, example: "GBR" - let ccc = String::from("CCC"); - //TODO: data source - // R: Receiver (hw) - // S: Stream - // U: Unknown - let s = String::from("R"); - let yyyy = "YYYY"; //TODO - let hh = "HH"; //TODO - let mm = "MM"; //TODO - //let yyyy = format!("{:04}", epoch.date.year()); - //let hh = format!("{:02}", epoch.date.hour()); - //let mm = format!("{:02}", epoch.date.minute()); - let pp = String::from("00"); //TODO 02d file period, interval ? - let up = String::from("H"); //TODO: file period unit - let ff = String::from("00"); //TODO: 02d observation frequency 02d - //TODO - //Units of frequency FF. “C” = 100Hz; “Z” = Hz; “S” = sec; “M” = min; - //“H” = hour; “D” = day; “U” = unspecified - //NB - _FFU is omitted for files containing navigation data - let uf = String::from("Z"); - let c: String = match header.constellation { - Some(c) => format!("{:x}", c).to_uppercase(), - _ => String::from("X"), - }; - let t: String = match rtype { - types::Type::ObservationData => String::from("O"), - types::Type::NavigationData => String::from("N"), - types::Type::MeteoData => String::from("M"), - types::Type::ClockData => todo!(), - types::Type::AntennaData => todo!(), - types::Type::IonosphereMaps => todo!(), - }; - let fmt = match header.is_crinex() { - true => String::from("crx"), - false => String::from("rnx"), - }; - format!( - "{}{}{}{}_{}_{}{}{}{}_{}{}_{}{}_{}{}.{}", - nnnn, m, r, ccc, s, yyyy, ddd, hh, mm, pp, up, ff, uf, c, t, fmt - ) + PPU::Unspecified + }; + let fmt = match rinextype { + RinexType::ObservationData => "MO".to_string(), + RinexType::MeteoData => "MM".to_string(), + RinexType::NavigationData => match constellation { + Some(constell) => format!("M{:x}", constell), + Some(Constellation::Mixed) | None => "MN".to_string(), + }, + _ => unreachable!("unreachable fmt"), + }; + let ext = if is_crinex { "crx" } else { "rnx" }; + ProductionAttributes::rinex_long_format( + &name, + &country, + src, + &yyyy, + &ddd, + &hh, + &mm, + &ppu.to_string(), + ffu.as_deref(), + &fmt, + ext, + ) + } + }, + rinex => unimplemented!("{} format", rinex), + }; + if let Some(suffix) = suffix { + filename.push_str(suffix); } + filename } - - /// Builds a `RINEX` from given file. + /// Builds a `RINEX` from given file fullpath. /// Header section must respect labelization standards, /// some are mandatory. /// Parses record (file body) for supported `RINEX` types. - pub fn from_file(path: &str) -> Result { - /* This will be required if we ever make the BufferedReader Hatanaka compliant - // Grab first 80 bytes to fully determine the BufferedReader attributes. - // We use the `BufferedReader` wrapper for efficient file browsing (.lines()) - // and builtin CRINEX decompression - let mut reader = BufferedReader::new(path)?; - let mut buffer = [0; 80]; // 1st line mandatory size - let mut line = String::new(); // first line - if let Ok(n) = reader.read(&mut buffer[..]) { - if n < 80 { - panic!("corrupt header 1st line") - } - if let Ok(s) = String::from_utf8(buffer.to_vec()) { - line = s.clone() - } else { - panic!("header 1st line is not valid Utf8 encoding") - } - }*/ + pub fn from_file(fullpath: &str) -> Result { + Self::from_path(&Path::new(fullpath).to_path_buf()) + } + + /// See [Self::from_file] + pub fn from_path(path: &PathBuf) -> Result { + let fullpath = path.to_string_lossy().to_string(); - /* - * deflate (.gzip) fd pointer does not work / is not fully supported - * at the moment. Let's recreate a new object, it's a little bit - * silly, because we actually analyze the 1st line twice, - * but Header builder already deduces several things from this line. - - reader.seek(SeekFrom::Start(0)) - .unwrap(); - */ // create buffered reader - let mut reader = BufferedReader::new(path)?; - // --> parse header fields + let mut reader = BufferedReader::new(&fullpath)?; + + // Parse header fields let mut header = Header::new(&mut reader)?; - // --> parse record (file body) - // we also grab encountered comments, - // they might serve some fileops like `splice` / `merge` + + // Parse file body (record content) + // Comments might serve some fileops like "splice". let (record, comments) = record::parse_record(&mut reader, &mut header)?; + + // Parse / identify production attributes + // that only exist in the filename. + let prod_attr = match path.file_name() { + Some(filename) => { + let filename = filename.to_string_lossy().to_string(); + if let Ok(attrs) = ProductionAttributes::from_str(&filename) { + Some(attrs) + } else { + None + } + }, + _ => None, + }; + Ok(Rinex { header, record, comments, + prod_attr, }) } @@ -593,20 +744,20 @@ impl Rinex { /// This operation is typically used to compare two GNSS receivers. /// Both RINEX formats must match otherwise this will panic. /// This is only available to Observation RINEX files. - pub fn substract(&self, rhs: &Self) -> Result { + pub fn substract(&self, rhs: &Self) -> Self { let mut record = observation::Record::default(); let lhs_rec = self .record .as_obs() - .expect("can't substract other rinex format"); + .expect("can only substract observation data"); let rhs_rec = rhs .record .as_obs() - .expect("can't substract other rinex format"); + .expect("can only substract observation data"); - for ((epoch, flag), (_, svnn)) in lhs_rec { - if let Some((_, ref_svnn)) = rhs_rec.get(&(*epoch, *flag)) { + for ((epoch, flag), (clk, svnn)) in lhs_rec { + if let Some((ref_clk, ref_svnn)) = rhs_rec.get(&(*epoch, *flag)) { for (sv, observables) in svnn { if let Some(ref_observables) = ref_svnn.get(sv) { for (observable, observation) in observables { @@ -625,17 +776,38 @@ impl Rinex { // new observable let mut inner = HashMap::::new(); - inner.insert(observable.clone(), *observation); + let observation = ObservationData { + obs: observation.obs - ref_observation.obs, + lli: None, + snr: None, + }; + inner.insert(observable.clone(), observation); c_svnn.insert(*sv, inner); } } else { // new epoch let mut map = HashMap::::new(); - map.insert(observable.clone(), *observation); + let observation = ObservationData { + obs: observation.obs - ref_observation.obs, + lli: None, + snr: None, + }; + map.insert(observable.clone(), observation); let mut inner = BTreeMap::>::new(); inner.insert(*sv, map); - record.insert((*epoch, *flag), (None, inner)); + if let Some(clk) = clk { + if let Some(refclk) = ref_clk { + record.insert( + (*epoch, *flag), + (Some(clk - refclk), inner), + ); + } else { + record.insert((*epoch, *flag), (None, inner)); + } + } else { + record.insert((*epoch, *flag), (None, inner)); + } } } } @@ -644,8 +816,7 @@ impl Rinex { } } - let rinex = Rinex::new(self.header.clone(), record::Record::ObsRecord(record)); - Ok(rinex) + Rinex::new(self.header.clone(), record::Record::ObsRecord(record)) } /// Returns true if Differential Code Biases (DCBs) @@ -1125,7 +1296,16 @@ impl Rinex { } /// Writes self into given file. /// Both header + record will strictly follow RINEX standards. - /// Record: refer to supported RINEX types + /// Record: refer to supported RINEX types. + /// ``` + /// // Read a RINEX and dump it without any modifications + /// use rinex::prelude::*; + /// let rnx = Rinex::from_file("../test_resources/OBS/V3/DUTH0630.22O") + /// .unwrap(); + /// assert!(rnx.to_file("test.rnx").is_ok()); + /// ``` + /// Other useful links are: + /// * our Production settings customization infrastructure [Self:: pub fn to_file(&self, path: &str) -> Result<(), Error> { let mut writer = BufferedWriter::new(path)?; write!(writer, "{}", self.header)?; @@ -3016,11 +3196,13 @@ impl Split for Rinex { header: self.header.clone(), comments: self.comments.clone(), record: r0, + prod_attr: self.prod_attr.clone(), }, Self { header: self.header.clone(), comments: self.comments.clone(), record: r1, + prod_attr: self.prod_attr.clone(), }, )) } @@ -3434,16 +3616,6 @@ mod test { let _ = filter!("GPS"); let _ = filter!("G08, G09"); } - #[test] - fn test_hourly_session() { - assert_eq!(hourly_session!(0), "a"); - assert_eq!(hourly_session!(1), "b"); - assert_eq!(hourly_session!(2), "c"); - assert_eq!(hourly_session!(3), "d"); - assert_eq!(hourly_session!(4), "e"); - assert_eq!(hourly_session!(5), "f"); - assert_eq!(hourly_session!(23), "x"); - } use crate::{fmt_comment, is_rinex_comment}; #[test] fn fmt_comments_singleline() { diff --git a/rinex/src/marker.rs b/rinex/src/marker.rs new file mode 100644 index 000000000..9e7852321 --- /dev/null +++ b/rinex/src/marker.rs @@ -0,0 +1,106 @@ +//! Geodetic marker description +use strum_macros::EnumString; + +#[cfg(feature = "serde")] +use serde::Serialize; + +#[derive(Default, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct GeodeticMarker { + /// Marker name + pub name: String, + /// Marker type + pub marker_type: Option, + /// Marker/monument ID + identification: Option, + /// Marker/monument number. + /// Probably if agency has more than one of them. + monument: Option, +} + +#[derive(Default, Copy, Clone, Debug, PartialEq, Eq, EnumString)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum MarkerType { + /// Earth fixed & high precision + #[strum(serialize = "GEODETIC", serialize = "Geodetic")] + #[default] + Geodetic, + /// Earth fixed & low precision + #[strum(serialize = "NON GEODETIC", serialize = "NonGeodetic")] + NonGeodetic, + /// Generated from network + #[strum(serialize = "NON PHYSICAL", serialize = "NonPhysical")] + NonPhysical, + /// Orbiting space vehicle + #[strum(serialize = "SPACE BORNE", serialize = "Spaceborne")] + Spaceborne, + /// Aircraft, balloon.. + #[strum(serialize = "AIR BORNE", serialize = "Airborne")] + Airborne, + /// Mobile water craft + #[strum(serialize = "WATER CRAFT", serialize = "Watercraft")] + Watercraft, + /// Mobile terrestrial vehicle + #[strum(serialize = "GROUND CRAFT", serialize = "Groundcraft")] + Groundcraft, + /// Fixed on water surface + #[strum(serialize = "FIXED BUOY", serialize = "FixedBuoy")] + FixedBuoy, + /// Floating on water surface + #[strum(serialize = "FLOATING BUOY", serialize = "FloatingBuoy")] + FloatingBuoy, + /// Floating on ice + #[strum(serialize = "FLOATING ICE", serialize = "FloatingIce")] + FloatingIce, + /// Fixed on glacier + #[strum(serialize = "GLACIER", serialize = "Glacier")] + Glacier, + /// Rockets, shells, etc.. + #[strum(serialize = "BALLISTIC", serialize = "Ballistic")] + Ballistic, + /// Animal carrying a receiver + #[strum(serialize = "ANIMAL", serialize = "Animal")] + Animal, + /// Human being carrying a receiver + #[strum(serialize = "HUMAN", serialize = "Human")] + Human, +} + +impl GeodeticMarker { + /// Returns a GeodeticMarker with given "name". + pub fn with_name(&self, name: &str) -> Self { + let mut s = self.clone(); + s.name = name.to_string(); + s + } + /// Returns marker "number" in standardized format + pub fn number(&self) -> Option { + let id = self.identification?; + let monument = self.monument?; + Some(format!("{:05}M{:03}", id, monument)) + } + /// Returns a GeodeticMarker with "number" only if it matches standardized format. + pub fn with_number(&self, content: &str) -> Self { + let mut s = self.clone(); + if content.len() == 9 && content.chars().nth(5) == Some('M') { + if let Ok(id) = u32::from_str_radix(&content[..5], 10) { + if let Ok(monument) = u16::from_str_radix(&content[7..], 10) { + s.identification = Some(id); + s.monument = Some(monument); + } + } + } + s + } +} + +#[cfg(test)] +mod test { + use super::GeodeticMarker; + #[test] + fn marker_number() { + let marker = GeodeticMarker::default(); + let marker = marker.with_number("10118M001"); + assert_eq!(marker.number(), Some("10118M001".to_string())); + } +} diff --git a/rinex/src/production/ffu.rs b/rinex/src/production/ffu.rs new file mode 100644 index 000000000..ffd29235c --- /dev/null +++ b/rinex/src/production/ffu.rs @@ -0,0 +1,153 @@ +use super::Error; +use hifitime::{Duration, Unit}; + +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct FFU { + /// Sample rate + pub val: u32, + /// Period unit + pub unit: Unit, +} + +impl Default for FFU { + fn default() -> Self { + Self { + val: 30, + unit: Unit::Second, + } + } +} + +impl std::fmt::Display for FFU { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self.unit { + Unit::Minute => write!(f, "{:02}M", self.val), + Unit::Hour => write!(f, "{:02}H", self.val), + Unit::Day => write!(f, "{:02}D", self.val), + Unit::Second | _ => write!(f, "{:02}S", self.val), + } + } +} + +impl std::str::FromStr for FFU { + type Err = Error; + fn from_str(s: &str) -> Result { + if s.len() < 3 { + return Err(Error::InvalidFFU); + } + let val = s[..2].parse::().map_err(|_| Error::InvalidFFU)?; + let unit = match s.chars().nth(2) { + Some('S') => Unit::Second, + Some('M') => Unit::Minute, + Some('H') => Unit::Hour, + Some('D') => Unit::Day, + _ => return Err(Error::InvalidFFU), + }; + Ok(Self { val, unit }) + } +} + +impl From for FFU { + fn from(dur: Duration) -> Self { + let secs = dur.to_seconds().round() as u32; + if secs < 60 { + Self { + val: secs, + unit: Unit::Second, + } + } else if secs < 3_600 { + Self { + val: secs / 60, + unit: Unit::Minute, + } + } else if secs < 86_400 { + Self { + val: secs / 3_600, + unit: Unit::Hour, + } + } else { + Self { + val: secs / 86_400, + unit: Unit::Day, + } + } + } +} + +#[cfg(test)] +mod test { + use super::FFU; + use hifitime::Unit; + use std::str::FromStr; + #[test] + fn ffu_parsing() { + for (desc, expected) in [ + ( + "30S", + FFU { + val: 30, + unit: Unit::Second, + }, + ), + ( + "01M", + FFU { + val: 1, + unit: Unit::Minute, + }, + ), + ( + "15M", + FFU { + val: 15, + unit: Unit::Minute, + }, + ), + ( + "30M", + FFU { + val: 30, + unit: Unit::Minute, + }, + ), + ( + "01H", + FFU { + val: 1, + unit: Unit::Hour, + }, + ), + ( + "04H", + FFU { + val: 4, + unit: Unit::Hour, + }, + ), + ( + "08H", + FFU { + val: 8, + unit: Unit::Hour, + }, + ), + ( + "01D", + FFU { + val: 1, + unit: Unit::Day, + }, + ), + ( + "07D", + FFU { + val: 7, + unit: Unit::Day, + }, + ), + ] { + let ffu = FFU::from_str(desc).unwrap(); + assert_eq!(ffu, expected); + } + } +} diff --git a/rinex/src/production/mod.rs b/rinex/src/production/mod.rs new file mode 100644 index 000000000..5733ab659 --- /dev/null +++ b/rinex/src/production/mod.rs @@ -0,0 +1,360 @@ +/* + * File Production infrastructure. + * File production information are specified in RINEX files that were named + * according to standard specifications. + * + * Two use cases of this module: + * 1. When a RINEX was parsed succesfully, we attach Self + * if we do regocnized a standard name. + * This helps regenerating a filename that still follows the standards. + * If ProductionAttributes are not recognized, it is not that big of a deal. + * It just means it will be difficult to easily regenerate a filename that + * strictly follows the standards, because we will then miss some information + * like the country code. + * + * 2. In our file production API, we can pass ProductionAttributes + * to customize the production of this context. + */ + +use thiserror::Error; + +mod sequence; +pub use sequence::FileSequence; + +mod ppu; +pub use ppu::PPU; + +mod ffu; +pub use ffu::FFU; + +mod source; +pub use source::DataSource; + +#[derive(Error, Debug)] +/// File Production errors +pub enum Error { + #[error("filename does not follow naming conventions")] + NonStandardFileName, + #[error("invalid file sequence")] + InvalidFileSequence, + #[error("invalid ffu format")] + InvalidFFU, +} + +/// File production attributes. Used when generating +/// RINEX data that follows standard naming conventions, +/// or attached to data parsed from such files. +#[derive(Debug, Clone, PartialEq)] +pub struct ProductionAttributes { + /// Name serves several roles which are type dependent. + /// - Non detailed OBS RINEX: this is usually the station name + /// which can be named after a geodetic marker + /// - Non detailed NAV RINEX: station name + /// - CLK RINEX: name of the local clock + /// - IONEX: agency + pub name: String, + /// Year of production + pub year: u32, + /// Production Day of Year (DOY) + pub doy: u32, + /// Detailed production attributes only apply to NAV + OBS RINEX + /// files. They can only be attached from filenames that follow + /// the current standardized long format. + pub details: Option, + /// Optional Regional code present in IONEX file names. + /// 'G' means Global (World wide) TEC map(s). + pub region: Option, +} + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct DetailedProductionAttributes { + /// Agency Country Code + pub country: String, + /// Data source + pub data_src: DataSource, + /// PPU gives information on file production periodicity. + pub ppu: PPU, + /// FFU gives information on Observation sampling rate. + pub ffu: Option, + /// Hour of first symbol (sampling, not publication) + pub hh: u8, + /// Minute of first symbol (sampling, not publication) + pub mm: u8, +} + +impl ProductionAttributes { + /* filename generator */ + pub(crate) fn ionex_format(name: &str, region: char, ddd: &str, yy: &str) -> String { + format!("{}{}{}0.{}I", name, region, ddd, yy,) + } + /* filename generator */ + pub(crate) fn rinex_short_format(name: &str, ddd: &str, yy: &str, ext: char) -> String { + format!("{}{}0.{}{}", &name, ddd, yy, ext,) + } + /* filename generator */ + pub(crate) fn rinex_long_format( + name: &str, + country: &str, + src: char, + yyyy: &str, + ddd: &str, + hh: &str, + mm: &str, + ppu: &str, + ffu: Option<&str>, + fmt: &str, + ext: &str, + ) -> String { + if let Some(ffu) = ffu { + format!( + "{}00{}_{}_{}{}{}{}_{}_{}_{}.{}", + name, country, src, yyyy, ddd, hh, mm, ppu, ffu, fmt, ext, + ) + } else { + format!( + "{}00{}_{}_{}{}{}{}_{}_{}.{}", + name, country, src, yyyy, ddd, hh, mm, ppu, fmt, ext, + ) + } + } +} + +impl std::str::FromStr for ProductionAttributes { + type Err = Error; + fn from_str(fname: &str) -> Result { + let fname = fname.to_uppercase(); + if fname.len() < 13 { + let offset = fname.find('.').unwrap_or(0); + if offset != 8 { + return Err(Error::NonStandardFileName); + }; + + // determine type of RINEX first + // because it determines how to parse the "name" field + let year = fname[offset + 1..offset + 3] + .parse::() + .map_err(|_| Error::NonStandardFileName)?; + + let rtype = &fname[offset + 3..offset + 4]; + let name_offset = match rtype { + "I" => 3usize, // only 3 digits on IONEX + _ => 4usize, + }; + + Ok(Self { + year: year + 2_000, // year uses 2 digit in old format + name: fname[..name_offset].to_string(), + doy: { + fname[4..7] + .parse::() + .map_err(|_| Error::NonStandardFileName)? + }, + region: match rtype { + "I" => fname.chars().nth(3), + _ => None, + }, + details: None, + }) + } else { + let offset = fname.find('.').unwrap_or(0); + if offset < 30 { + return Err(Error::NonStandardFileName); + }; + + // determine type of RINEX first + // because it determines how to parse the "name" field + let year = fname[12..16] + .parse::() + .map_err(|_| Error::NonStandardFileName)?; + + let rtype = &fname[offset + 3..offset + 4]; + let name_offset = match rtype { + "I" => 3usize, // only 3 digits on IONEX + _ => 4usize, + }; + + Ok(Self { + year, + name: fname[..name_offset].to_string(), + doy: { + fname[16..19] + .parse::() + .map_err(|_| Error::NonStandardFileName)? + }, + region: None, // IONEX files only use a short format + details: Some(DetailedProductionAttributes { + country: fname[6..9].to_string(), + data_src: DataSource::from_str(&fname[10..11])?, + ppu: PPU::from_str(&fname[24..27])?, + hh: { + fname[19..21] + .parse::() + .map_err(|_| Error::NonStandardFileName)? + }, + mm: { + fname[21..23] + .parse::() + .map_err(|_| Error::NonStandardFileName)? + }, + ffu: match offset { + 34 => Some(FFU::from_str(&fname[28..32])?), + _ => None, // NAV FILE case + }, + }), + }) + } + } +} + +#[cfg(test)] +mod test { + use super::DetailedProductionAttributes; + use super::ProductionAttributes; + use super::{DataSource, FFU, PPU}; + + use hifitime::Unit; + use std::str::FromStr; + #[test] + fn short_rinex_filenames() { + for (filename, name, year, doy) in [ + ("AJAC3550.21O", "AJAC", 2021, 355), + ("AJAC3550.21D", "AJAC", 2021, 355), + ("KOSG0010.15O", "KOSG", 2015, 1), + ("rovn0010.21o", "ROVN", 2021, 1), + ("barq071q.19o", "BARQ", 2019, 71), + ("VLNS0010.22D", "VLNS", 2022, 1), + ] { + println!("Testing RINEX filename \"{}\"", filename); + let attrs = ProductionAttributes::from_str(filename).unwrap(); + assert_eq!(attrs.name, name); + assert_eq!(attrs.year, year); + assert_eq!(attrs.doy, doy); + } + } + #[test] + fn long_rinex_filenames() { + for (filename, name, year, doy, detail) in [ + ( + "ACOR00ESP_R_20213550000_01D_30S_MO.crx", + "ACOR", + 2021, + 355, + DetailedProductionAttributes { + country: "ESP".to_string(), + data_src: DataSource::Receiver, + ppu: PPU::Daily, + hh: 0, + mm: 0, + ffu: Some(FFU { + val: 30, + unit: Unit::Second, + }), + }, + ), + ( + "KMS300DNK_R_20221591000_01H_30S_MO.crx", + "KMS3", + 2022, + 159, + DetailedProductionAttributes { + country: "DNK".to_string(), + data_src: DataSource::Receiver, + ppu: PPU::Hourly, + hh: 10, + mm: 0, + ffu: Some(FFU { + val: 30, + unit: Unit::Second, + }), + }, + ), + ( + "AMEL00NLD_R_20210010000_01D_MN.rnx", + "AMEL", + 2021, + 1, + DetailedProductionAttributes { + country: "NLD".to_string(), + data_src: DataSource::Receiver, + hh: 0, + mm: 0, + ppu: PPU::Daily, + ffu: None, + }, + ), + ( + "ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz", + "ESBC", + 2020, + 177, + DetailedProductionAttributes { + country: "DNK".to_string(), + data_src: DataSource::Receiver, + ppu: PPU::Daily, + hh: 0, + mm: 0, + ffu: Some(FFU { + val: 30, + unit: Unit::Second, + }), + }, + ), + ( + "MOJN00DNK_R_20201770000_01D_30S_MO.crx.gz", + "MOJN", + 2020, + 177, + DetailedProductionAttributes { + country: "DNK".to_string(), + data_src: DataSource::Receiver, + ppu: PPU::Daily, + hh: 0, + mm: 0, + ffu: Some(FFU { + val: 30, + unit: Unit::Second, + }), + }, + ), + ( + "ESBC00DNK_R_20201772223_01D_30S_MO.crx.gz", + "ESBC", + 2020, + 177, + DetailedProductionAttributes { + country: "DNK".to_string(), + data_src: DataSource::Receiver, + ppu: PPU::Daily, + hh: 22, + mm: 23, + ffu: Some(FFU { + val: 30, + unit: Unit::Second, + }), + }, + ), + ] { + println!("Testing RINEX filename \"{}\"", filename); + let attrs = ProductionAttributes::from_str(filename).unwrap(); + assert_eq!(attrs.name, name); + assert_eq!(attrs.year, year); + assert_eq!(attrs.doy, doy); + assert_eq!(attrs.details, Some(detail)); + } + } + #[test] + fn ionex_filenames() { + for (filename, name, year, doy, region) in [ + ("CKMG0020.22I", "CKM", 2022, 2, 'G'), + ("CKMG0090.21I", "CKM", 2021, 9, 'G'), + ("jplg0010.17i", "JPL", 2017, 1, 'G'), + ] { + println!("Testing IONEX filename \"{}\"", filename); + let attrs = ProductionAttributes::from_str(filename).unwrap(); + assert_eq!(attrs.name, name); + assert_eq!(attrs.year, year); + assert_eq!(attrs.doy, doy); + assert_eq!(attrs.region, Some(region)); + } + } +} diff --git a/rinex/src/production/ppu.rs b/rinex/src/production/ppu.rs new file mode 100644 index 000000000..6af573a8b --- /dev/null +++ b/rinex/src/production/ppu.rs @@ -0,0 +1,82 @@ +use super::Error; +use hifitime::{Duration, Unit}; + +#[cfg(feature = "serde")] +use serde::Serialize; + +/// PPU Gives information on file periodicity. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub enum PPU { + /// A Daily file is the standard and will contain 24h of data + #[default] + Daily, + /// Contains 15' of data + QuarterHour, + /// Contains 1h of data + Hourly, + /// Contains 1 year of data + Yearly, + /// Unspecified + Unspecified, +} + +impl PPU { + /// Returns this file periodicity as a [Duration] + pub fn duration(&self) -> Option { + match self { + Self::QuarterHour => Some(15 * Unit::Minute), + Self::Hourly => Some(1 * Unit::Hour), + Self::Daily => Some(1 * Unit::Day), + Self::Yearly => Some(365 * Unit::Day), + _ => None, + } + } +} + +impl std::fmt::Display for PPU { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::QuarterHour => write!(f, "15M"), + Self::Hourly => write!(f, "01H"), + Self::Daily => write!(f, "01D"), + Self::Yearly => write!(f, "O1Y"), + Self::Unspecified => write!(f, "00U"), + } + } +} + +impl std::str::FromStr for PPU { + type Err = Error; + fn from_str(s: &str) -> Result { + match s { + "15M" => Ok(Self::QuarterHour), + "01H" => Ok(Self::Hourly), + "01D" => Ok(Self::Daily), + "01Y" => Ok(Self::Yearly), + _ => Ok(Self::Unspecified), + } + } +} + +#[cfg(test)] +mod test { + use super::PPU; + use hifitime::Unit; + use std::str::FromStr; + #[test] + fn ppu_parsing() { + for (c, expected, dur) in [ + ("15M", PPU::QuarterHour, Some(15 * Unit::Minute)), + ("01H", PPU::Hourly, Some(1 * Unit::Hour)), + ("01D", PPU::Daily, Some(1 * Unit::Day)), + ("01Y", PPU::Yearly, Some(365 * Unit::Day)), + ("XX", PPU::Unspecified, None), + ("01U", PPU::Unspecified, None), + ] { + let ppu = PPU::from_str(c).unwrap(); + assert_eq!(ppu, expected); + assert_eq!(ppu.duration(), dur); + } + } +} diff --git a/rinex/src/production/sequence.rs b/rinex/src/production/sequence.rs new file mode 100644 index 000000000..ecc9cffc5 --- /dev/null +++ b/rinex/src/production/sequence.rs @@ -0,0 +1,80 @@ +/* File sequence: used to describe batch or day course some files represent. */ +use super::Error; + +#[cfg(feature = "serde")] +use serde::Serialize; + +/// FileSequence is used to describe whether this +/// file is part of a batch of files or +/// which section (time frame) of the day course it represents. +#[derive(Clone, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub enum FileSequence { + /// This file is integrated in a file batch (# id) + Batch(u8), + /// This file represents a specific time frame of a daycourse. + /// 0 means midnight to midnight +1h. + /// 10 means midnight past 10h to +11h. + /// And so forth. + DayPortion(u8), + /// This file represents an entire day course + #[default] + DayCourse, +} + +impl std::str::FromStr for FileSequence { + type Err = Error; + fn from_str(content: &str) -> Result { + let chars = content.chars().nth(0).unwrap(); + + // "0" means entire day + if chars == '0' { + Ok(Self::DayCourse) + } else if chars.is_ascii_alphabetic() { + let value = chars as u32 - 97; + if value < 24 { + Ok(Self::DayPortion(value as u8)) + } else { + Err(Error::InvalidFileSequence) + } + } else { + let batch_id = content + .parse::() + .map_err(|_| Error::InvalidFileSequence)?; + Ok(Self::Batch(batch_id)) + } + } +} + +#[cfg(test)] +mod test { + use super::FileSequence; + use std::str::FromStr; + #[test] + fn file_sequence_parsing() { + for (desc, expected) in [ + ("a", FileSequence::DayPortion(0)), + ("b", FileSequence::DayPortion(1)), + ("c", FileSequence::DayPortion(2)), + ("d", FileSequence::DayPortion(3)), + ("e", FileSequence::DayPortion(4)), + ("u", FileSequence::DayPortion(20)), + ("v", FileSequence::DayPortion(21)), + ("w", FileSequence::DayPortion(22)), + ("x", FileSequence::DayPortion(23)), + ("0", FileSequence::DayCourse), + ("1", FileSequence::Batch(1)), + ("2", FileSequence::Batch(2)), + ("10", FileSequence::Batch(10)), + ] { + let seq = FileSequence::from_str(desc).unwrap_or_else(|_| { + panic!("failed to parse \"{}\"", desc); + }); + assert_eq!(seq, expected); + } + assert!( + FileSequence::from_str("z").is_err(), + "this file sequence is invalid" + ); + } +} diff --git a/rinex/src/production/source.rs b/rinex/src/production/source.rs new file mode 100644 index 000000000..96773aba3 --- /dev/null +++ b/rinex/src/production/source.rs @@ -0,0 +1,40 @@ +use super::Error; + +#[cfg(feature = "serde")] +use serde::Serialize; + +#[derive(Clone, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub enum DataSource { + /// Source of data is hardware (radio) receiver. + /// It can also represent a sensor in case of meteo observations. + Receiver, + /// Other stream source, like RTCM + Stream, + /// Unknown data source + #[default] + Unknown, +} + +impl std::str::FromStr for DataSource { + type Err = Error; + fn from_str(content: &str) -> Result { + if content.eq("R") { + Ok(Self::Receiver) + } else if content.eq("S") { + Ok(Self::Stream) + } else { + Ok(Self::Unknown) + } + } +} + +impl DataSource { + pub(crate) fn to_char(&self) -> char { + match self { + Self::Receiver => 'R', + Self::Stream => 'S', + Self::Unknown => 'U', + } + } +} diff --git a/rinex/src/tests/decompression.rs b/rinex/src/tests/decompression.rs index 4de12887a..af791683e 100644 --- a/rinex/src/tests/decompression.rs +++ b/rinex/src/tests/decompression.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod test { use crate::hatanaka::Decompressor; + use crate::tests::toolkit::obsrinex_check_observables; use crate::tests::toolkit::random_name; use crate::tests::toolkit::test_observation_rinex; use crate::{erratic_time_frame, evenly_spaced_time_frame, tests::toolkit::TestTimeFrame}; @@ -481,6 +482,37 @@ mod test { ), ); + /* G +R +E +C */ + obsrinex_check_observables( + &rnx, + Constellation::GPS, + &[ + "C1C", "L1C", "S1C", "C2S", "L2S", "S2S", "C2W", "L2W", "S2W", "C5Q", "L5Q", "S5Q", + ], + ); + obsrinex_check_observables( + &rnx, + Constellation::Galileo, + &[ + "C1C", "L1C", "S1C", "C5Q", "L5Q", "S5Q", "C6C", "L6C", "S6C", "C7Q", "L7Q", "S7Q", + "C8Q", "L8Q", "S8Q", + ], + ); + obsrinex_check_observables( + &rnx, + Constellation::Glonass, + &[ + "C1C", "L1C", "S1C", "C2P", "L2P", "S2P", "C2C", "L2C", "S2C", "C3Q", "L3Q", "S3Q", + ], + ); + obsrinex_check_observables( + &rnx, + Constellation::BeiDou, + &[ + "C2I", "L2I", "S2I", "C6I", "L6I", "S6I", "C7I", "L7I", "S7I", + ], + ); + /* * record test */ @@ -703,4 +735,122 @@ mod test { assert!(clk_offset.is_none()); } } + #[test] + #[cfg(feature = "flate2")] + fn v3_mojn00dnk() { + let crnx = + Rinex::from_file("../test_resources/CRNX/V3/MOJN00DNK_R_20201770000_01D_30S_MO.crx.gz"); + assert!(crnx.is_ok()); + let rnx = crnx.unwrap(); + + /* C +E +G +I +J +R +S */ + obsrinex_check_observables( + &rnx, + Constellation::BeiDou, + &[ + "C2I", "C6I", "C7I", "D2I", "D6I", "D7I", "L2I", "L6I", "L7I", "S2I", "S6I", "S7I", + ], + ); + + obsrinex_check_observables( + &rnx, + Constellation::Galileo, + &[ + "C1C", "C5Q", "C6C", "C7Q", "C8Q", "D1C", "D5Q", "D6C", "D7Q", "D8Q", "L1C", "L5Q", + "L6C", "L7Q", "L8Q", "S1C", "S5Q", "S6C", "S7Q", "S8Q", + ], + ); + + obsrinex_check_observables( + &rnx, + Constellation::GPS, + &[ + "C1C", "C1W", "C2L", "C2W", "C5Q", "D1C", "D2L", "D2W", "D5Q", "L1C", "L2L", "L2W", + "L5Q", "S1C", "S1W", "S2L", "S2W", "S5Q", + ], + ); + + obsrinex_check_observables(&rnx, Constellation::IRNSS, &["C5A", "D5A", "L5A", "S5A"]); + + obsrinex_check_observables( + &rnx, + Constellation::QZSS, + &[ + "C1C", "C2L", "C5Q", "D1C", "D2L", "D5Q", "L1C", "L2L", "L5Q", "S1C", "S2L", "S5Q", + ], + ); + + obsrinex_check_observables( + &rnx, + Constellation::Glonass, + &[ + "C1C", "C1P", "C2C", "C2P", "C3Q", "D1C", "D1P", "D2C", "D2P", "D3Q", "L1C", "L1P", + "L2C", "L2P", "L3Q", "S1C", "S1P", "S2C", "S2P", "S3Q", + ], + ); + + obsrinex_check_observables( + &rnx, + Constellation::SBAS, + &["C1C", "C5I", "D1C", "D5I", "L1C", "L5I", "S1C", "S5I"], + ); + } + #[test] + #[cfg(feature = "flate2")] + fn v3_esbc00dnk() { + let crnx = + Rinex::from_file("../test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz"); + assert!(crnx.is_ok()); + let rnx = crnx.unwrap(); + + /* C +E +G +J +R +S */ + obsrinex_check_observables( + &rnx, + Constellation::BeiDou, + &[ + "C2I", "C6I", "C7I", "D2I", "D6I", "D7I", "L2I", "L6I", "L7I", "S2I", "S6I", "S7I", + ], + ); + + obsrinex_check_observables( + &rnx, + Constellation::Galileo, + &[ + "C1C", "C5Q", "C6C", "C7Q", "C8Q", "D1C", "D5Q", "D6C", "D7Q", "D8Q", "L1C", "L5Q", + "L6C", "L7Q", "L8Q", "S1C", "S5Q", "S6C", "S7Q", "S8Q", + ], + ); + + obsrinex_check_observables( + &rnx, + Constellation::GPS, + &[ + "C1C", "C1W", "C2L", "C2W", "C5Q", "D1C", "D2L", "D2W", "D5Q", "L1C", "L2L", "L2W", + "L5Q", "S1C", "S1W", "S2L", "S2W", "S5Q", + ], + ); + + obsrinex_check_observables( + &rnx, + Constellation::QZSS, + &[ + "C1C", "C2L", "C5Q", "D1C", "D2L", "D5Q", "L1C", "L2L", "L5Q", "S1C", "S2L", "S5Q", + ], + ); + + obsrinex_check_observables( + &rnx, + Constellation::Glonass, + &[ + "C1C", "C1P", "C2C", "C2P", "C3Q", "D1C", "D1P", "D2C", "D2P", "D3Q", "L1C", "L1P", + "L2C", "L2P", "L3Q", "S1C", "S1P", "S2C", "S2P", "S3Q", + ], + ); + + obsrinex_check_observables( + &rnx, + Constellation::SBAS, + &["C1C", "C5I", "D1C", "D5I", "L1C", "L5I", "S1C", "S5I"], + ); + } } diff --git a/rinex/src/tests/filename.rs b/rinex/src/tests/filename.rs new file mode 100644 index 000000000..b4bf49a26 --- /dev/null +++ b/rinex/src/tests/filename.rs @@ -0,0 +1,75 @@ +use crate::prelude::*; +use std::path::Path; + +// Test our standardized name generator does follow the specs +#[test] +fn short_filename_conventions() { + for (testfile, expected) in [ + //FIXME: slightly wrong due to HIFITIME PB @ DOY(GNSS) + ("OBS/V2/AJAC3550.21O", "AJAC3550.21O"), + ("OBS/V2/rovn0010.21o", "ROVN0020.20O"), + // FIXME on next hifitime release + ("OBS/V3/LARM0010.22O", "LARM0010.21O"), + // FIXME on next hifitime release + ("OBS/V3/pdel0010.21o", "PDEL0020.20O"), + // FIXME on next hifitime release + ("CRNX/V1/delf0010.21d", "DELF0020.20D"), + // FIXME on next hifitime release + ("CRNX/V1/zegv0010.21d", "ZEGV0020.20D"), + // FIXME on next hifitime release + ("CRNX/V3/DUTH0630.22D", "DUTH0630.22D"), + // FIXME on next hifitime release + ("CRNX/V3/VLNS0010.22D", "VLNS0010.21D"), + ("MET/V2/abvi0010.15m", "ABVI0010.15M"), + // FIXME on next hifitime release + ("MET/V2/clar0020.00m", "CLAR0020.00M"), + ] { + let fp = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("test_resources") + .join(testfile); + + let rinex = Rinex::from_file(fp.to_string_lossy().as_ref()).unwrap(); + + let _actual_filename = fp.file_name().unwrap().to_string_lossy().to_string(); + + let output = rinex.standard_filename(true, None, None); // force short + + assert_eq!(output, expected, "bad short filename generated"); + } +} + +// Test our standardized name generator does follow the specs +#[test] +fn long_filename_conventions() { + for (testfile, expected, custom_suffix) in [ + ( + "OBS/V3/ACOR00ESP_R_20213550000_01D_30S_MO.rnx", + "ACOR00ESP_R_20213552359_01D_30S_MO.rnx", //FIXME: hifitime DOY(GNSS) + None, + ), + ( + "OBS/V3/ALAC00ESP_R_20220090000_01D_30S_MO.rnx", + "ALAC00ESP_R_20220092359_01D_13M_MO.rnx", //FIXME: hifitime DOY(GNSS) + None, + ), + ( + "CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz", + "ESBC00DNK_R_20201772359_01D_30S_MO.crx.gz", //FIXME: hifitime DOY(GNSS) + Some(".gz"), + ), + ] { + let fp = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("test_resources") + .join(testfile); + + let rinex = Rinex::from_file(fp.to_string_lossy().as_ref()).unwrap(); + + //FIXME: hifitime DOY(GNSS) : use filename directly + let _standard_filename = fp.file_name().unwrap().to_string_lossy().to_string(); + + let output = rinex.standard_filename(false, custom_suffix, None); + assert_eq!(output, expected, "bad filename generated"); + } +} diff --git a/rinex/src/tests/mod.rs b/rinex/src/tests/mod.rs index 85adc815e..e36d22a76 100644 --- a/rinex/src/tests/mod.rs +++ b/rinex/src/tests/mod.rs @@ -5,6 +5,7 @@ mod antex; mod clocks; mod compression; mod decompression; +mod filename; mod merge; mod nav; mod obs; diff --git a/rinex/src/tests/obs.rs b/rinex/src/tests/obs.rs index 8862e4488..486c87968 100644 --- a/rinex/src/tests/obs.rs +++ b/rinex/src/tests/obs.rs @@ -1,12 +1,14 @@ #[cfg(test)] mod test { use crate::filter; + use crate::marker::MarkerType; use crate::observable; use crate::observation::SNR; use crate::preprocessing::*; + use crate::tests::toolkit::obsrinex_check_observables; use crate::tests::toolkit::test_observation_rinex; use crate::{erratic_time_frame, evenly_spaced_time_frame, tests::toolkit::TestTimeFrame}; - use crate::{header::*, observation::*, prelude::*}; + use crate::{observation::*, prelude::*}; use gnss_rs::prelude::SV; use gnss_rs::sv; use itertools::Itertools; @@ -41,6 +43,9 @@ mod test { ), ); + /* This file is GPS */ + obsrinex_check_observables(&rinex, Constellation::GPS, &["L1", "L2", "C1", "P1", "P2"]); + //testbench(&rinex, 2, 11, Constellation::GPS, epochs, observables); let record = rinex.record.as_obs().unwrap(); @@ -166,30 +171,17 @@ mod test { ), ); - //let obscodes = obs_hd.codes.get(&Constellation::GPS); - //assert_eq!( - // obscodes, - // &vec![ - // Observable::from_str("C1").unwrap(), - // Observable::from_str("L1").unwrap(), - // Observable::from_str("L2").unwrap(), - // Observable::from_str("P2").unwrap(), - // Observable::from_str("S1").unwrap(), - // Observable::from_str("S2").unwrap(), - // ] - //); - //let obscodes = obs_hd.codes.get(&Constellation::Glonass); - //assert_eq!( - // obscodes, - // &vec![ - // Observable::from_str("C1").unwrap(), - // Observable::from_str("L1").unwrap(), - // Observable::from_str("L2").unwrap(), - // Observable::from_str("P2").unwrap(), - // Observable::from_str("S1").unwrap(), - // Observable::from_str("S2").unwrap(), - // ] - //); + /* This file is GPS + GLO */ + obsrinex_check_observables( + &rinex, + Constellation::GPS, + &["C1", "L1", "L2", "P2", "S1", "S2"], + ); + obsrinex_check_observables( + &rinex, + Constellation::Glonass, + &["C1", "L1", "L2", "P2", "S1", "S2"], + ); let record = rinex.record.as_obs().unwrap(); @@ -319,48 +311,22 @@ mod test { ") ); - ////////////////////////////// - // This file is GPS + GLONASS - ////////////////////////////// - //let obscodes = obs_hd.codes.get(&Constellation::GPS); - //assert_eq!(obscodes.is_some(), true); - //let obscodes = obscodes.unwrap(); - //assert_eq!( - // obscodes, - // &vec![ - // Observable::from_str("C1").unwrap(), - // Observable::from_str("C2").unwrap(), - // Observable::from_str("C5").unwrap(), - // Observable::from_str("L1").unwrap(), - // Observable::from_str("L2").unwrap(), - // Observable::from_str("L5").unwrap(), - // Observable::from_str("P1").unwrap(), - // Observable::from_str("P2").unwrap(), - // Observable::from_str("S1").unwrap(), - // Observable::from_str("S2").unwrap(), - // Observable::from_str("S5").unwrap(), - // ] - //); - - //let obscodes = obs_hd.codes.get(&Constellation::Glonass); - //assert_eq!(obscodes.is_some(), true); - //let obscodes = obscodes.unwrap(); - //assert_eq!( - // obscodes, - // &vec![ - // Observable::from_str("C1").unwrap(), - // Observable::from_str("C2").unwrap(), - // Observable::from_str("C5").unwrap(), - // Observable::from_str("L1").unwrap(), - // Observable::from_str("L2").unwrap(), - // Observable::from_str("L5").unwrap(), - // Observable::from_str("P1").unwrap(), - // Observable::from_str("P2").unwrap(), - // Observable::from_str("S1").unwrap(), - // Observable::from_str("S2").unwrap(), - // Observable::from_str("S5").unwrap(), - // ] - //); + /* This file is GPS + GLO */ + obsrinex_check_observables( + &rinex, + Constellation::GPS, + &[ + "C1", "C2", "C5", "L1", "L2", "L5", "P1", "P2", "S1", "S2", "S5", + ], + ); + + obsrinex_check_observables( + &rinex, + Constellation::Glonass, + &[ + "C1", "C2", "C5", "L1", "L2", "L5", "P1", "P2", "S1", "S2", "S5", + ], + ); /* * Header tb @@ -374,7 +340,11 @@ mod test { 5044091.5729 ))) ); - assert_eq!(header.station_id, "13544M001"); + + let marker = &header.geodetic_marker; + assert!(marker.is_some(), "failed to parse geodetic marker"); + let marker = marker.as_ref().unwrap(); + assert_eq!(marker.number(), Some("13544M001".to_string())); assert_eq!(header.observer, "Hans van der Marel"); assert_eq!(header.agency, "TU Delft for Deltares"); @@ -552,6 +522,18 @@ mod test { ), ); + /* This file is G + R */ + obsrinex_check_observables( + &rinex, + Constellation::GPS, + &["C1C", "L1C", "D1C", "S1C", "C2W", "L2W", "D2W", "S2W"], + ); + obsrinex_check_observables( + &rinex, + Constellation::Glonass, + &["C1C", "L1C", "D1C", "S1C", "C2P", "L2P", "D2P", "S2P"], + ); + /* * test Glonass observables */ @@ -639,7 +621,7 @@ mod test { assert!(clk.is_none()); assert_eq!(vehicles.len(), 17); } - //#[test] + #[test] fn v4_kms300dnk_r_2022_v3crx() { let test_resource = env!("CARGO_MANIFEST_DIR").to_owned() + "/../test_resources/CRNX/V3/KMS300DNK_R_20221591000_01H_30S_MO.crx"; @@ -651,17 +633,47 @@ mod test { ////////////////////////// assert!(rinex.is_observation_rinex()); assert!(rinex.header.obs.is_some()); - let obs = rinex.header.obs.as_ref().unwrap(); - let glo_observables = obs.codes.get(&Constellation::Glonass); - assert!(glo_observables.is_some()); - let glo_observables = glo_observables.unwrap(); - let mut index = 0; - for code in [ - "C1C", "C1P", "C2C", "C2P", "C3Q", "L1C", "L1P", "L2C", "L2P", "L3Q", - ] { - assert_eq!(glo_observables[index], Observable::from_str(code).unwrap()); - index += 1 - } + + /* this file is G +E +R +J +S +C */ + obsrinex_check_observables( + &rinex, + Constellation::BeiDou, + &[ + "C1P", "C2I", "C5P", "C6I", "C7D", "C7I", "L1P", "L2I", "L5P", "L6I", "L7D", "L7I", + ], + ); + + obsrinex_check_observables( + &rinex, + Constellation::Galileo, + &[ + "C1C", "C5Q", "C6C", "C7Q", "C8Q", "L1C", "L5Q", "L6C", "L7Q", "L8Q", + ], + ); + + obsrinex_check_observables( + &rinex, + Constellation::GPS, + &[ + "C1C", "C1L", "C1W", "C2L", "C2W", "C5Q", "L1C", "L1L", "L2L", "L2W", "L5Q", + ], + ); + + obsrinex_check_observables( + &rinex, + Constellation::QZSS, + &["C1C", "C1L", "C2L", "C5Q", "L1C", "L1L", "L2L", "L5Q"], + ); + + obsrinex_check_observables( + &rinex, + Constellation::Glonass, + &[ + "C1C", "C1P", "C2C", "C2P", "C3Q", "L1C", "L1P", "L2C", "L2P", "L3Q", + ], + ); + + obsrinex_check_observables(&rinex, Constellation::SBAS, &["C1C", "C5I", "L1C", "L5I"]); ////////////////////////// // Record testbench @@ -670,7 +682,7 @@ mod test { assert!(record.is_some()); let record = record.unwrap(); // EPOCH[1] - let epoch = Epoch::from_gregorian_utc(2022, 06, 08, 10, 00, 00, 00); + let epoch = Epoch::from_str("2022-06-08T10:00:00 GPST").unwrap(); let epoch = record.get(&(epoch, EpochFlag::Ok)); assert!(epoch.is_some()); let (clk_offset, epoch) = epoch.unwrap(); @@ -678,7 +690,7 @@ mod test { assert_eq!(epoch.len(), 49); // EPOCH[2] - let epoch = Epoch::from_gregorian_utc(2022, 06, 08, 10, 00, 30, 00); + let epoch = Epoch::from_str("2022-06-08T10:00:30 GPST").unwrap(); let epoch = record.get(&(epoch, EpochFlag::Ok)); assert!(epoch.is_some()); let (clk_offset, epoch) = epoch.unwrap(); @@ -686,7 +698,7 @@ mod test { assert_eq!(epoch.len(), 49); // EPOCH[3] - let epoch = Epoch::from_gregorian_utc(2020, 6, 8, 10, 1, 0, 00); + let epoch = Epoch::from_str("2022-06-08T10:01:00 GPST").unwrap(); let epoch = record.get(&(epoch, EpochFlag::Ok)); assert!(epoch.is_some()); let (clk_offset, epoch) = epoch.unwrap(); @@ -704,16 +716,15 @@ mod test { .join("KOSG0010.95O"); let fullpath = path.to_string_lossy(); let rnx = Rinex::from_file(fullpath.as_ref()).unwrap(); - //for (e, sv) in rnx.sv_epoch() { - // println!("{:?} @ {}", sv, e); - //} - //panic!("stop"); + // for (e, sv) in rnx.sv_epoch() { + // println!("{:?} @ {}", sv, e); + // } + // panic!("stop"); test_observation_rinex( &rnx, "2.0", Some("GPS"), "GPS", - //"G01, G04, G05, G06, G16, G17, G18, G19, G20, G21, G22, G23, G24, G25, G27, G29, G31", "G01, G04, G05, G06, G16, G17, G18, G19, G20, G21, G22, G23, G24, G25, G27, G29, G31", "C1, L1, L2, P2, S1", Some("1995-01-01T00:00:00 GPST"), @@ -1080,9 +1091,15 @@ mod test { * Header tb */ let header = rnx.header.clone(); - assert_eq!(header.station, "ESBC00DNK"); - assert_eq!(header.station_id, "10118M001"); - assert_eq!(header.marker_type, Some(MarkerType::Geodetic)); + + assert!( + header.geodetic_marker.is_some(), + "failed to parse geodetic marker" + ); + let marker = header.geodetic_marker.unwrap(); + assert_eq!(marker.name, "ESBC00DNK"); + assert_eq!(marker.number(), Some("10118M001".to_string())); + assert_eq!(marker.marker_type, Some(MarkerType::Geodetic)); /* * Test preprocessing diff --git a/rinex/src/tests/parsing.rs b/rinex/src/tests/parsing.rs index df12a4093..eb93fc31f 100644 --- a/rinex/src/tests/parsing.rs +++ b/rinex/src/tests/parsing.rs @@ -2,6 +2,7 @@ mod test { use crate::navigation::NavMsgType; use crate::prelude::*; + use crate::tests::toolkit::is_null_rinex; use std::path::PathBuf; #[test] fn test_parser() { @@ -206,9 +207,22 @@ mod test { assert!(rinex.is_observation_rinex()); assert!(rinex.epoch().count() > 0); // all files have content assert!(rinex.observation().count() > 0); // all files have content - /* - * test timescale validity - */ + is_null_rinex(&rinex.substract(&rinex), 1.0E-9); // Self - Self should always be null + if data == "OBS" { + let compressed = rinex.rnx2crnx(); + assert!( + compressed.header.is_crinex(), + "is_crinex() should always be true for compressed rinex!" + ); + } else if data == "CRNX" { + let decompressed = rinex.crnx2rnx(); + assert!( + !decompressed.header.is_crinex(), + "is_crinex() should always be false for readable rinex!" + ); + } + + /* Timescale validity */ for ((e, _), _) in rinex.observation() { let ts = e.time_scale; if let Some(e0) = obs_header.time_of_first_obs { diff --git a/rinex/src/tests/toolkit/constant.rs b/rinex/src/tests/toolkit/constant.rs new file mode 100644 index 000000000..0fc0056be --- /dev/null +++ b/rinex/src/tests/toolkit/constant.rs @@ -0,0 +1,53 @@ +use crate::meteo::Record as MetRecord; +use crate::observation::Record as ObsRecord; +use crate::Rinex; + +/* + * Test: panic if given RINEX content is not equal to given constant + */ +pub fn is_constant_rinex(rnx: &Rinex, constant: f64, tolerance: f64) { + if let Some(record) = rnx.record.as_obs() { + is_constant_obs_record(record, constant, tolerance) + } else if let Some(record) = rnx.record.as_meteo() { + is_constant_meteo_record(record, constant, tolerance) + } else { + unimplemented!("is_constant_rinex({})", rnx.header.rinex_type); + } +} + +pub fn is_null_rinex(rnx: &Rinex, tolerance: f64) { + is_constant_rinex(rnx, 0.0_f64, tolerance) +} + +fn is_constant_obs_record(record: &ObsRecord, constant: f64, tolerance: f64) { + for (_, (clk, svnn)) in record { + if let Some(clk) = clk { + let err = (clk - constant).abs(); + if err > tolerance { + panic!("rcvr clock {} != {}", clk, constant); + } + } + for (_, observables) in svnn { + for (observable, observation) in observables { + let err = (observation.obs - constant).abs(); + if err > tolerance { + panic!( + "{} observation {} != {}", + observable, observation.obs, constant + ); + } + } + } + } +} + +fn is_constant_meteo_record(record: &MetRecord, constant: f64, tolerance: f64) { + for (_, observables) in record { + for (observable, observation) in observables { + let err = (observation - constant).abs(); + if err > tolerance { + panic!("{} observation {} != {}", observable, observation, constant); + } + } + } +} diff --git a/rinex/src/tests/toolkit.rs b/rinex/src/tests/toolkit/mod.rs similarity index 98% rename from rinex/src/tests/toolkit.rs rename to rinex/src/tests/toolkit/mod.rs index b8e6deeb7..4bc593ad1 100644 --- a/rinex/src/tests/toolkit.rs +++ b/rinex/src/tests/toolkit/mod.rs @@ -4,6 +4,14 @@ use rand::{distributions::Alphanumeric, Rng}; use hifitime::TimeSeries; +/* OBS RINEX dedicated tools */ +mod observation; +pub use observation::check_observables as obsrinex_check_observables; + +/* ANY RINEX == constant (special ops) */ +mod constant; +pub use constant::{is_constant_rinex, is_null_rinex}; + //#[macro_use] #[macro_export] macro_rules! erratic_time_frame { diff --git a/rinex/src/tests/toolkit/observation.rs b/rinex/src/tests/toolkit/observation.rs new file mode 100644 index 000000000..b854750ec --- /dev/null +++ b/rinex/src/tests/toolkit/observation.rs @@ -0,0 +1,53 @@ +// use crate::observation::Record as ObsRecord; +use crate::prelude::{Constellation, Observable, Rinex}; +use std::str::FromStr; + +/* + * Verifies given constellation does (only) contain the following observables + */ +pub fn check_observables(rnx: &Rinex, constellation: Constellation, observables: &[&str]) { + let expected = observables + .iter() + .map(|desc| Observable::from_str(desc).unwrap()) + .collect::>(); + + match &rnx.header.obs { + Some(obs_specific) => { + let observables = obs_specific.codes.get(&constellation); + if let Some(observables) = observables { + for expected in &expected { + let mut found = false; + for observable in observables { + found |= observable == expected; + } + if !found { + panic!( + "{} observable is not present in header, for {} constellation", + expected, constellation + ); + } + } + for observable in observables { + let mut is_expected = false; + for expected in &expected { + is_expected |= expected == observable; + } + if !is_expected { + panic!( + "{} header observables unexpectedly contain {} observable", + constellation, observable + ); + } + } + } else { + panic!( + "no observable in header, for {} constellation", + constellation + ); + } + }, + _ => { + panic!("empty observation specific header fields"); + }, + } +} diff --git a/rnx2crx/Cargo.toml b/rnx2crx/Cargo.toml index 30c6a238f..5a74bbbbb 100644 --- a/rnx2crx/Cargo.toml +++ b/rnx2crx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rnx2crx" -version = "1.1.3" +version = "1.2.0" license = "MIT OR Apache-2.0" authors = ["Guillaume W. Bres "] description = "RINEX data compressor" @@ -15,4 +15,4 @@ readme = "README.md" chrono = "0.4" thiserror = "1" clap = { version = "4.4.10", features = ["derive", "color"] } -rinex = { path = "../rinex", version = "=0.15.3", features = ["serde"] } +rinex = { path = "../rinex", version = "=0.15.4", features = ["serde"] } diff --git a/rnx2crx/src/cli.rs b/rnx2crx/src/cli.rs index 4ba86b768..992d387af 100644 --- a/rnx2crx/src/cli.rs +++ b/rnx2crx/src/cli.rs @@ -12,7 +12,7 @@ impl Cli { matches: { Command::new("rnx2crx") .author("Guillaume W. Bres ") - .version("1.0") + .version(env!("CARGO_PKG_VERSION")) .about("RINEX compression tool") .arg_required_else_help(true) .color(ColorChoice::Always) @@ -24,26 +24,36 @@ impl Cli { .help("Input RINEX file") .required(true), ) + .arg( + Arg::new("short") + .short('s') + .long("short") + .conflicts_with("output") + .action(ArgAction::SetTrue) + .help("Prefer shortened filename convention. +Otherwise, we default to modern (V3+) long filenames. +Both will not work well if your input does not follow standard conventions at all.")) .arg( Arg::new("output") .short('o') .long("output") - .help("Output RINEX file"), - ) + .action(ArgAction::Set) + .conflicts_with_all(["short"]) + .help("Custom output filename. Otherwise, we follow standard conventions, which will not work correctly if your input does not follow standard conventions.")) .next_help_heading("Compression") .arg( Arg::new("crx1") .long("crx1") .conflicts_with("crx3") .action(ArgAction::SetTrue) - .help("Force to CRINEX1 compression"), + .help("Force to CRINEX1 compression."), ) .arg( Arg::new("crx3") .long("crx3") .conflicts_with("crx1") .action(ArgAction::SetTrue) - .help("Force to CRINEX3 compression"), + .help("Force to CRINEX3 compression."), ) .arg( Arg::new("date") diff --git a/rnx2crx/src/main.rs b/rnx2crx/src/main.rs index ba45915ce..8ac4ced89 100644 --- a/rnx2crx/src/main.rs +++ b/rnx2crx/src/main.rs @@ -4,10 +4,11 @@ use rinex::{prelude::*, Error}; fn main() -> Result<(), Error> { let cli = Cli::new(); let input_path = cli.input_path(); + + let mut rinex = Rinex::from_file(input_path)?; // parse + println!("Compressing \"{}\"..", input_path); - // parse - let mut rinex = Rinex::from_file(input_path)?; - rinex.rnx2crnx(); + rinex.rnx2crnx_mut(); // compression attributes if cli.crx1() { @@ -49,21 +50,10 @@ fn main() -> Result<(), Error> { // output path let output_path = match cli.output_path() { - Some(path) => path.clone(), - _ => { - // deduce from input - match input_path.strip_suffix('o') { - Some(prefix) => prefix.to_owned() + "d", - _ => match input_path.strip_suffix('O') { - Some(prefix) => prefix.to_owned() + "D", - _ => match input_path.strip_suffix("rnx") { - Some(prefix) => prefix.to_owned() + "crx", - _ => String::from("output.crx"), - }, - }, - } - }, + Some(path) => path.clone(), // use customized name + _ => rinex.standard_filename(cli.matches.get_flag("short"), None, None), }; + rinex.to_file(&output_path)?; println!("{} generated", output_path); Ok(()) diff --git a/tools/test-binaries.sh b/tools/test-binaries.sh index a1431da60..e5e1cd19f 100755 --- a/tools/test-binaries.sh +++ b/tools/test-binaries.sh @@ -4,19 +4,40 @@ set -e # binaries tester: to be used in CI and # provide at least basic means to test our CLI ############################################## +cargo build --all-features -r -############ -# 1. CRX2RNX -############ +################# +# CRX2RNX (V1) +################# +./target/release/crx2rnx \ + -f test_resources/CRNX/V1/delf0010.21d + +echo "CRN2RNX (V1) OK" + +################# +# CRX2RNX (V3) +################# ./target/release/crx2rnx \ - -f test_resources/CRNX/V3/KMS300DNK_R_20221591000_01H_30S_MO.crx.gz + -f test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz + +echo "CRN2RNX (V3) OK" -############ -# 2. RNX2CRX -############ +################# +# RNX2CRX (V2) +################# +./target/release/rnx2crx \ + -f test_resources/OBS/V2/delf0010.21o + +echo "RNX2CRX (V2) OK" + +################# +# RNX2CRX (V3) +################# ./target/release/rnx2crx \ -f test_resources/OBS/V3/pdel0010.21o +echo "RNX2CRX (V3) OK" + ############################# # 3. OBS RINEX identification ############################# diff --git a/ublox-rnx/Cargo.toml b/ublox-rnx/Cargo.toml index 1218d351f..3266b8a9a 100644 --- a/ublox-rnx/Cargo.toml +++ b/ublox-rnx/Cargo.toml @@ -22,4 +22,4 @@ serialport = "4.2.0" ublox = "0.4.4" clap = { version = "4.4.10", features = ["derive", "color"] } gnss-rs = { version = "2.1.2", features = ["serde"] } -rinex = { path = "../rinex", version = "=0.15.3", features = ["serde", "nav", "obs"] } +rinex = { path = "../rinex", version = "=0.15.4", features = ["serde", "nav", "obs"] }