From 6fc919091c138c4d7cc92b1efc1032f817092c73 Mon Sep 17 00:00:00 2001 From: gwbres Date: Thu, 28 Dec 2023 23:05:41 +0100 Subject: [PATCH] Cli improvements (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improving Cli with subcommands * bump cggtts * add substract opmode * run clippy --------- Signed-off-by: Guillaume W. Bres Co-authored-by: Laurențiu Nicola --- .github/workflows/rust.yml | 53 +- Cargo.toml | 1 - README.md | 8 +- crx2rnx/Cargo.toml | 2 +- rinex-cli/Cargo.toml | 13 +- rinex-cli/src/analysis/sampling.rs | 2 +- rinex-cli/src/cli.rs | 560 ------------------ rinex-cli/src/cli/graph.rs | 142 +++++ rinex-cli/src/cli/identify.rs | 77 +++ rinex-cli/src/cli/merge.rs | 19 + rinex-cli/src/cli/mod.rs | 358 +++++++++++ rinex-cli/src/cli/positioning.rs | 65 ++ rinex-cli/src/cli/qc.rs | 29 + rinex-cli/src/cli/split.rs | 19 + rinex-cli/src/cli/substract.rs | 23 + rinex-cli/src/cli/time_binning.rs | 18 + rinex-cli/src/file_generation.rs | 0 rinex-cli/src/filter.rs | 37 -- rinex-cli/src/fops.rs | 299 ++++++++++ rinex-cli/src/{plot => graph}/combination.rs | 34 +- rinex-cli/src/{plot => graph}/context.rs | 0 rinex-cli/src/{plot => graph}/mod.rs | 240 +++++++- rinex-cli/src/{plot => graph}/naviplot.rs | 2 +- rinex-cli/src/{plot => graph}/record/ionex.rs | 2 +- .../src/{plot => graph}/record/ionosphere.rs | 2 +- rinex-cli/src/{plot => graph}/record/meteo.rs | 48 +- rinex-cli/src/graph/record/mod.rs | 32 + .../src/{plot => graph}/record/navigation.rs | 111 ++-- .../src/{plot => graph}/record/observation.rs | 4 +- rinex-cli/src/graph/record/sp3_plot.rs | 172 ++++++ rinex-cli/src/graph/skyplot.rs | 56 ++ rinex-cli/src/identification.rs | 453 +++++++------- rinex-cli/src/main.rs | 495 ++-------------- rinex-cli/src/plot/record/mod.rs | 13 - rinex-cli/src/plot/record/sp3_plot.rs | 102 ---- rinex-cli/src/plot/skyplot.rs | 54 -- .../src/positioning/cggtts/mod.rs | 290 ++------- .../src/positioning/cggtts/post_process.rs | 83 +++ rinex-cli/src/positioning/mod.rs | 271 ++++++++- rinex-cli/src/positioning/post_process.rs | 345 ----------- rinex-cli/src/positioning/ppp/mod.rs | 182 ++++++ rinex-cli/src/positioning/ppp/post_process.rs | 335 +++++++++++ rinex-cli/src/positioning/solver.rs | 400 ------------- rinex-cli/src/qc.rs | 52 ++ rinex-qc/Cargo.toml | 4 +- rinex/Cargo.toml | 2 +- rinex/src/antex/antenna/mod.rs | 1 + rinex/src/antex/record.rs | 4 +- rinex/src/header.rs | 2 +- rinex/src/lib.rs | 61 ++ rinex/src/navigation/ionmessage.rs | 2 +- rnx2cggtts/Cargo.toml | 36 -- rnx2cggtts/README.md | 131 ---- rnx2cggtts/src/cli.rs | 361 ----------- rnx2cggtts/src/main.rs | 264 --------- rnx2cggtts/src/preprocessing.rs | 76 --- rnx2crx/Cargo.toml | 2 +- tools/test-binaries.sh | 97 +++ ublox-rnx/Cargo.toml | 2 +- 59 files changed, 3134 insertions(+), 3414 deletions(-) delete mode 100644 rinex-cli/src/cli.rs create mode 100644 rinex-cli/src/cli/graph.rs create mode 100644 rinex-cli/src/cli/identify.rs create mode 100644 rinex-cli/src/cli/merge.rs create mode 100644 rinex-cli/src/cli/mod.rs create mode 100644 rinex-cli/src/cli/positioning.rs create mode 100644 rinex-cli/src/cli/qc.rs create mode 100644 rinex-cli/src/cli/split.rs create mode 100644 rinex-cli/src/cli/substract.rs create mode 100644 rinex-cli/src/cli/time_binning.rs delete mode 100644 rinex-cli/src/file_generation.rs delete mode 100644 rinex-cli/src/filter.rs rename rinex-cli/src/{plot => graph}/combination.rs (68%) rename rinex-cli/src/{plot => graph}/context.rs (100%) rename rinex-cli/src/{plot => graph}/mod.rs (59%) rename rinex-cli/src/{plot => graph}/naviplot.rs (94%) rename rinex-cli/src/{plot => graph}/record/ionex.rs (98%) rename rinex-cli/src/{plot => graph}/record/ionosphere.rs (98%) rename rinex-cli/src/{plot => graph}/record/meteo.rs (56%) create mode 100644 rinex-cli/src/graph/record/mod.rs rename rinex-cli/src/{plot => graph}/record/navigation.rs (96%) rename rinex-cli/src/{plot => graph}/record/observation.rs (97%) create mode 100644 rinex-cli/src/graph/record/sp3_plot.rs create mode 100644 rinex-cli/src/graph/skyplot.rs delete mode 100644 rinex-cli/src/plot/record/mod.rs delete mode 100644 rinex-cli/src/plot/record/sp3_plot.rs delete mode 100644 rinex-cli/src/plot/skyplot.rs rename rnx2cggtts/src/solver.rs => rinex-cli/src/positioning/cggtts/mod.rs (62%) create mode 100644 rinex-cli/src/positioning/cggtts/post_process.rs delete mode 100644 rinex-cli/src/positioning/post_process.rs create mode 100644 rinex-cli/src/positioning/ppp/mod.rs create mode 100644 rinex-cli/src/positioning/ppp/post_process.rs delete mode 100644 rinex-cli/src/positioning/solver.rs create mode 100644 rinex-cli/src/qc.rs delete mode 100644 rnx2cggtts/Cargo.toml delete mode 100644 rnx2cggtts/README.md delete mode 100644 rnx2cggtts/src/cli.rs delete mode 100644 rnx2cggtts/src/main.rs delete mode 100644 rnx2cggtts/src/preprocessing.rs create mode 100755 tools/test-binaries.sh diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 89fd4a30d..41aa5c9d2 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -36,8 +36,8 @@ jobs: cargo install cargo-audit cargo audit - tests: - name: Tests + build: + name: Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -46,20 +46,29 @@ jobs: with: toolchain: stable override: true + - name: UBX2RNX dependencies run: | sudo apt-get update sudo apt-get install -y libudev-dev + - uses: actions-rs/cargo@v1 name: Test with: command: test args: --verbose + - uses: actions-rs/cargo@v1 name: Test (all features) with: command: test args: --verbose --all-features + + - uses: actions-rs/cargo@v1 + name: Build (all features) + with: + command: build + args: --all-features --release windows-build: runs-on: windows-latest @@ -70,26 +79,18 @@ jobs: with: toolchain: stable override: true + - uses: actions-rs/cargo@v1 - name: RINEXCLI + name: Build (default) with: command: build - args: -p rinex-cli --all-features --release --verbose - - uses: actions-rs/cargo@v1 - name: RNX2CGGTS - with: - command: build - args: -p rnx2cggtts --all-features --release --verbose + args: --release --verbose + - uses: actions-rs/cargo@v1 - name: RNX2CRNX + name: Build (all features) with: command: build - args: -p rnx2crx --all-features --release --verbose - - uses: actions-rs/cargo@v1 - name: CRX2RNX - with: - command: build - args: -p crx2rnx --all-features --release --verbose + args: --all-features --release --verbose macos-build: runs-on: macos-latest @@ -100,23 +101,15 @@ jobs: with: toolchain: stable override: true + - uses: actions-rs/cargo@v1 - name: RINEXCLI - with: - command: build - args: -p rinex-cli --all-features --release --verbose - - uses: actions-rs/cargo@v1 - name: RNX2CGGTS + name: Build (default) with: command: build - args: -p rnx2cggtts --all-features --release --verbose + args: --release --verbose + - uses: actions-rs/cargo@v1 - name: RNX2CRNX + name: Build (all features) with: command: build - args: -p rnx2crx --all-features --release --verbose - - uses: actions-rs/cargo@v1 - name: CRX2RNX - with: - command: build - args: -p crx2rnx --all-features --release --verbose + args: --all-features --release --verbose diff --git a/Cargo.toml b/Cargo.toml index 545ebe0c2..1a7a26c80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ members = [ "rinex", "rinex-qc", "rinex-cli", - "rnx2cggtts", "rnx2crx", "sinex", "sp3", diff --git a/README.md b/README.md index 06201d249..12a82954b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ You can also contact us [on our Discord channel](https://discord.gg/Fp2aape) - RINEX V4 full support - Meteo RINEX full support - IONEX 2D support. Partial IONEX 3D support. -- Clock RINEX partial support: to be concluded soon +- Partial ANTEX support +- Parial Clock RINEX support - Several pre processing operations: - File merging - Time beaning @@ -60,15 +61,14 @@ your improvements * [`rinex`](rinex/) is the core library * [`rinex-cli`](rinex-cli/) : an application dedicated to RINEX post processing. -It supports some of `teqc` operations. +It supports some of `teqc` operations. +It integrates a position solver and can format CGGTTS tracks for clock comparison. The application is auto-generated for a few architectures, download it from the [release portal](https://github.com/gwbres/rinex/releases) * [`sp3`](sp3/) High Precision Orbits (by IGS) * [`rnx2crx`](rnx2crx/) is a RINEX compressor (RINEX to Compact RINEX) * [`crx2rnx`](crx2rnx/) is a CRINEX decompresor (Compact RINEX to RINEX) -* [`rnx2cggtts`](rnx2cggtts/) post processes RINEX data and resolves PVT that we -wrap in CGGTTS format which is dedicated to (remote) clock comparison * [`rinex-qc`](rinex-qc/) is a library dedicated to RINEX files analysis * [`qc-traits`](qc-traits/) declares Traits that are shared between `rinex` and `rinex-qc` * [`sinex`](sinex/) SNX dedicated core library diff --git a/crx2rnx/Cargo.toml b/crx2rnx/Cargo.toml index 199ca0c13..3a06f681b 100644 --- a/crx2rnx/Cargo.toml +++ b/crx2rnx/Cargo.toml @@ -12,4 +12,4 @@ readme = "README.md" [dependencies] clap = { version = "4.4.10", features = ["derive", "color"] } -rinex = { path = "../rinex", version = "=0.15.2", features = ["serde"] } +rinex = { path = "../rinex", version = "=0.15.3", features = ["serde"] } diff --git a/rinex-cli/Cargo.toml b/rinex-cli/Cargo.toml index 001571f75..3a11a8129 100644 --- a/rinex-cli/Cargo.toml +++ b/rinex-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rinex-cli" -version = "0.9.7" +version = "0.10.0" license = "MIT OR Apache-2.0" authors = ["Guillaume W. Bres "] description = "Command line tool parse and analyze RINEX data" @@ -28,11 +28,11 @@ map_3d = "0.1.5" # ndarray = "0.15" colorous = "1.0" horrorshow = "0.8" -clap = { version = "4.4.10", features = ["derive", "color"] } +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.2", features = ["full"] } -rinex-qc = { path = "../rinex-qc", version = "=0.1.7", features = ["serde"] } +rinex = { path = "../rinex", version = "=0.15.3", 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"] } @@ -44,3 +44,8 @@ plotly = "0.8.4" gnss-rtk = { version = "0.4.1", features = ["serde"] } # gnss-rtk = { git = "https://github.com/rtk-rs/gnss-rtk", branch = "develop", features = ["serde"] } # gnss-rtk = { path = "../../rtk-rs/gnss-rtk", features = ["serde"] } + +# cggtts +cggtts = { version = "4.1.2", features = ["serde", "scheduler"] } +# cggtts = { git = "https://github.com/gwbres/cggtts", branch = "develop", features = ["serde", "scheduler"] } +# cggtts = { path = "../../cggtts/cggtts", features = ["serde", "scheduler"] } diff --git a/rinex-cli/src/analysis/sampling.rs b/rinex-cli/src/analysis/sampling.rs index 3d3ea002a..9e4a6e43c 100644 --- a/rinex-cli/src/analysis/sampling.rs +++ b/rinex-cli/src/analysis/sampling.rs @@ -1,4 +1,4 @@ -use crate::plot::PlotContext; +use crate::graph::PlotContext; use itertools::Itertools; use plotly::Histogram; //.sorted() use rinex::prelude::RnxContext; diff --git a/rinex-cli/src/cli.rs b/rinex-cli/src/cli.rs deleted file mode 100644 index e147cb2ea..000000000 --- a/rinex-cli/src/cli.rs +++ /dev/null @@ -1,560 +0,0 @@ -use clap::{Arg, ArgAction, ArgMatches, ColorChoice, Command}; -use log::{error, info}; - -use std::path::Path; -use std::str::FromStr; - -use rinex::prelude::*; -use rinex_qc::QcOpts; - -use gnss_rtk::prelude::Config; - -pub struct Cli { - /// Arguments passed by user - matches: ArgMatches, -} - -impl Default for Cli { - fn default() -> Self { - Self::new() - } -} - -impl Cli { - /// Build new command line interface - pub fn new() -> Self { - Self { - matches: { - Command::new("rinex-cli") - .author("Guillaume W. Bres, ") - .version(env!("CARGO_PKG_VERSION")) - .about("RINEX analysis and processing tool") - .arg_required_else_help(true) - .color(ColorChoice::Always) - .arg(Arg::new("filepath") - .short('f') - .long("fp") - .value_name("FILE") - .action(ArgAction::Append) - .required_unless_present("directory") - .help("Input RINEX file. Can be any kind of RINEX, or an SP3 file, -and you can load as many as you want.")) - .arg(Arg::new("directory") - .short('d') - .long("dir") - .value_name("DIRECTORY") - .action(ArgAction::Append) - .required_unless_present("filepath") - .help("Load directory recursively. RINEX and SP3 files are identified -and added like they were individually imported with -f. -You can import as many directories as you need.")) - .arg(Arg::new("quiet") - .short('q') - .long("quiet") - .action(ArgAction::SetTrue) - .help("Disable all terminal output. Also disables auto HTML reports opener.")) - .arg(Arg::new("pretty-json") - .short('j') - .long("json") - .action(ArgAction::SetTrue) - .help("Make JSON output more readable.")) - .arg(Arg::new("workspace") - .short('w') - .long("workspace") - .value_name("FOLDER") - .help("Customize workspace location (folder does not have to exist). -The default workspace is rinex-cli/workspace")) - .arg(Arg::new("no-graph") - .long("no-graph") - .action(ArgAction::SetTrue) - .help("Disable graphs generation, only text reports are to be generated.")) - .next_help_heading("Data generation") - .arg(Arg::new("gpx") - .long("gpx") - .action(ArgAction::SetTrue) - .help("Enable GPX formatting. In RTK mode, a GPX track is generated.")) - .arg(Arg::new("kml") - .long("kml") - .action(ArgAction::SetTrue) - .help("Enable KML formatting. In RTK mode, a KML track is generated.")) - .arg(Arg::new("output") - .short('o') - .long("out") - .value_name("FILE") - .action(ArgAction::Append) - .help("Custom file name to be generated within Workspace. -Allows merged file name to be customized.")) - .next_help_heading("Data identification") - .arg(Arg::new("full-id") - .short('i') - .action(ArgAction::SetTrue) - .help("Turn all identifications ON")) - .arg(Arg::new("epochs") - .long("epochs") - .action(ArgAction::SetTrue) - .help("Enumerate all epochs")) - .arg(Arg::new("gnss") - .long("gnss") - .short('g') - .action(ArgAction::SetTrue) - .help("Enumerate GNSS constellations present in entire context.")) - .arg(Arg::new("sv") - .long("sv") - .action(ArgAction::SetTrue) - .help("Enumerate Sv")) - .arg(Arg::new("sampling") - .long("sampling") - .action(ArgAction::SetTrue) - .help("Sample rate analysis.")) - .arg(Arg::new("header") - .long("header") - .action(ArgAction::SetTrue) - .help("Extracts major header fields")) - .next_help_heading("Preprocessing") - .arg(Arg::new("gps-filter") - .short('G') - .action(ArgAction::SetTrue) - .help("Filters out all GPS vehicles")) - .arg(Arg::new("glo-filter") - .short('R') - .action(ArgAction::SetTrue) - .help("Filters out all Glonass vehicles")) - .arg(Arg::new("gal-filter") - .short('E') - .action(ArgAction::SetTrue) - .help("Filters out all Galileo vehicles")) - .arg(Arg::new("bds-filter") - .short('C') - .action(ArgAction::SetTrue) - .help("Filters out all BeiDou vehicles")) - .arg(Arg::new("qzss-filter") - .short('J') - .action(ArgAction::SetTrue) - .help("Filters out all QZSS vehicles")) - .arg(Arg::new("irnss-filter") - .short('I') - .action(ArgAction::SetTrue) - .help("Filters out all IRNSS vehicles")) - .arg(Arg::new("sbas-filter") - .short('S') - .action(ArgAction::SetTrue) - .help("Filters out all SBAS vehicles")) - .arg(Arg::new("preprocessing") - .short('P') - .num_args(1..) - .action(ArgAction::Append) - .help("Design preprocessing operations, like data filtering or resampling, -prior further analysis. You can stack as many ops as you need. -Preprocessing ops apply prior entering both -q and --rtk modes. -Refer to rinex-cli/doc/preprocessing.md to learn how to operate this interface.")) - .next_help_heading("Observation RINEX") - .arg(Arg::new("observables") - .long("observables") - .long("obs") - .action(ArgAction::SetTrue) - .help("Identify observables in either Observation Data or Meteo Data contained in context.")) - .arg(Arg::new("ssi-range") - .long("ssi-range") - .action(ArgAction::SetTrue) - .help("Display SSI (min,max) range, accross all epochs and vehicles")) - .arg(Arg::new("lli-mask") - .long("lli-mask") - .help("Applies given LLI AND() mask. -Also drops observations that did not come with an LLI flag")) - .arg(Arg::new("if") - .long("if") - .action(ArgAction::SetTrue) - .help("Ionosphere Free combination graph")) - .arg(Arg::new("gf") - .long("gf") - .action(ArgAction::SetTrue) - .conflicts_with("no-graph") - .help("Geometry Free combination graph")) - .arg(Arg::new("wl") - .long("wl") - .action(ArgAction::SetTrue) - .conflicts_with("no-graph") - .help("Wide Lane combination graph")) - .arg(Arg::new("nl") - .long("nl") - .action(ArgAction::SetTrue) - .conflicts_with("no-graph") - .help("Narrow Lane combination graph")) - .arg(Arg::new("mw") - .long("mw") - .action(ArgAction::SetTrue) - .conflicts_with("no-graph") - .help("Melbourne-Wübbena combination graph")) - .arg(Arg::new("dcb") - .long("dcb") - .action(ArgAction::SetTrue) - .conflicts_with("no-graph") - .help("Differential Code Bias analysis")) - .arg(Arg::new("multipath") - .long("mp") - .action(ArgAction::SetTrue) - .conflicts_with("no-graph") - .help("Code Multipath analysis")) - .arg(Arg::new("anomalies") - .short('a') - .long("anomalies") - .action(ArgAction::SetTrue) - .help("Enumerate epochs where anomalies were reported by the receiver")) - .arg(Arg::new("cs") - .long("cs") - .action(ArgAction::SetTrue) - .help("Cycle Slip detection (graphical). -Helps visualize what the CS detector is doing and fine tune its operation. -CS do not get repaired with this command. -If you're just interested in CS information, you probably just want `-qc` instead, avoid combining the two.")) - .arg(Arg::new("antenna-ecef") - .long("antenna-ecef") - .value_name("\"x,y,z\" coordinates in ECEF [m]") - .help("Define the (RX) antenna ground position manualy, in [m] ECEF system. -Some calculations require a reference position. -Ideally this information is contained in the file Header, but user can manually define them (superceeds).")) - .arg(Arg::new("antenna-lla") - .long("antenna-lla") - .value_name("\"lat,lon,alt\" coordinates in ddeg [°]") - .help("Define the (RX) antenna ground position manualy, in decimal degrees. -Some calculations require a reference position. -Ideally this information is contained in the file Header, but user can manually define them (superceeds).")) - .arg(Arg::new("nav-msg") - .long("nav-msg") - .action(ArgAction::SetTrue) - .help("Identify Navigation frame types. -fp must be a NAV file")) - .arg(Arg::new("clock-bias") - .long("clock-bias") - .action(ArgAction::SetTrue) - .help("Display clock biases (offset, drift, drift changes) per epoch and vehicle. --fp must be a NAV file")) - .next_help_heading("Quality Check (QC)") - .arg(Arg::new("qc") - .long("qc") - .action(ArgAction::SetTrue) - .help("Enable Quality Check (QC) mode. -Runs thorough analysis on provided RINEX data. -The summary report by default is integrated to the global HTML report.")) - .arg(Arg::new("qc-config") - .long("qc-cfg") - .value_name("FILE") - .help("Pass a QC configuration file.")) - .arg(Arg::new("qc-only") - .long("qc-only") - .action(ArgAction::SetTrue) - .help("Activates QC mode and disables all other features: quickest qc rendition.")) - .next_help_heading("Positioning") - .arg(Arg::new("positioning") - .short('p') - .action(ArgAction::SetTrue) - .help("Activate positioning mode. Disables all other modes. -Use with ${RUST_LOG} env logger for more information. -Refer to the positioning documentation.")) - .arg(Arg::new("config") - .long("cfg") - .short('c') - .value_name("FILE") - .help("Pass Positioning configuration, refer to doc/positioning.")) - .next_help_heading("File operations") - .arg(Arg::new("merge") - .short('m') - .long("merge") - .value_name("FILE(s)") - .action(ArgAction::Append) - .help("Merge given RINEX this RINEX into primary RINEX. -Primary RINEX was either loaded with `-f`, or is Observation RINEX loaded with `-d`")) - .arg(Arg::new("split") - .long("split") - .value_name("Epoch") - .short('s') - .help("Split RINEX into two separate files")) - .get_matches() - }, - } - } - /// Returns list of input directories - pub fn input_directories(&self) -> Vec<&String> { - if let Some(fp) = self.matches.get_many::("directory") { - fp.collect() - } else { - Vec::new() - } - } - /// Returns individual input filepaths - pub fn input_files(&self) -> Vec<&String> { - if let Some(fp) = self.matches.get_many::("filepath") { - fp.collect() - } else { - Vec::new() - } - } - /// Returns output filepaths - pub fn output_path(&self) -> Option<&String> { - self.matches.get_one::("output") - } - pub fn preprocessing(&self) -> Vec<&String> { - if let Some(filters) = self.matches.get_many::("preprocessing") { - filters.collect() - } else { - Vec::new() - } - } - pub fn quality_check(&self) -> bool { - self.matches.get_flag("qc") - } - fn qc_config_path(&self) -> Option<&String> { - if let Some(path) = self.matches.get_one::("qc-config") { - Some(path) - } else { - None - } - } - pub fn qc_config(&self) -> QcOpts { - if let Some(path) = self.qc_config_path() { - if let Ok(content) = std::fs::read_to_string(path) { - let opts = serde_json::from_str(&content); - if let Ok(opts) = opts { - info!("qc parameter file \"{}\"", path); - opts - } else { - error!("failed to parse parameter file \"{}\"", path); - info!("using default parameters"); - QcOpts::default() - } - } else { - error!("failed to read parameter file \"{}\"", path); - info!("using default parameters"); - QcOpts::default() - } - } else { - QcOpts::default() - } - } - pub fn quality_check_only(&self) -> bool { - self.matches.get_flag("qc-only") - } - pub fn gps_filter(&self) -> bool { - self.matches.get_flag("gps-filter") - } - pub fn glo_filter(&self) -> bool { - self.matches.get_flag("glo-filter") - } - pub fn gal_filter(&self) -> bool { - self.matches.get_flag("gal-filter") - } - pub fn bds_filter(&self) -> bool { - self.matches.get_flag("bds-filter") - } - pub fn qzss_filter(&self) -> bool { - self.matches.get_flag("qzss-filter") - } - pub fn sbas_filter(&self) -> bool { - self.matches.get_flag("sbas-filter") - } - pub fn irnss_filter(&self) -> bool { - self.matches.get_flag("irnss-filter") - } - pub fn gf_combination(&self) -> bool { - self.matches.get_flag("gf") - } - pub fn if_combination(&self) -> bool { - self.matches.get_flag("if") - } - pub fn wl_combination(&self) -> bool { - self.matches.get_flag("wl") - } - pub fn nl_combination(&self) -> bool { - self.matches.get_flag("nl") - } - pub fn mw_combination(&self) -> bool { - self.matches.get_flag("mw") - } - pub fn identification(&self) -> bool { - self.matches.get_flag("sv") - | self.matches.get_flag("epochs") - | self.matches.get_flag("header") - | self.matches.get_flag("observables") - | self.matches.get_flag("ssi-range") - | self.matches.get_flag("nav-msg") - | self.matches.get_flag("anomalies") - | self.matches.get_flag("sampling") - | self.matches.get_flag("full-id") - } - /// Returns true if Sv accross epoch display is requested - pub fn sv_epoch(&self) -> bool { - self.matches.get_flag("sv-epoch") - } - /// Phase /PR DCBs analysis requested - pub fn dcb(&self) -> bool { - self.matches.get_flag("dcb") - } - /// Code Multipath analysis requested - pub fn multipath(&self) -> bool { - self.matches.get_flag("multipath") - } - /// Returns list of requested data to extract - pub fn identification_ops(&self) -> Vec<&str> { - if self.matches.get_flag("full-id") { - vec![ - "sv", - "epochs", - "gnss", - "observables", - "ssi-range", - "nav-msg", - "anomalies", - "sampling", - ] - } else { - let flags = [ - "sv", - "header", - "sampling", - "epochs", - "gnss", - "observables", - "ssi-range", - "nav-msg", - "anomalies", - ]; - flags - .iter() - .filter(|x| self.matches.get_flag(x)) - .copied() - .collect() - } - } - fn get_flag(&self, flag: &str) -> bool { - self.matches.get_flag(flag) - } - /// returns true if pretty JSON is requested - pub fn pretty_json(&self) -> bool { - self.get_flag("pretty-json") - } - /// Returns true if quiet mode is activated - pub fn quiet(&self) -> bool { - self.matches.get_flag("quiet") - } - pub fn positioning(&self) -> bool { - self.matches.get_flag("positioning") - } - pub fn gpx(&self) -> bool { - self.matches.get_flag("gpx") - } - pub fn kml(&self) -> bool { - self.matches.get_flag("kml") - } - pub fn config(&self) -> Option { - if let Some(path) = self.matches.get_one::("config") { - if let Ok(content) = std::fs::read_to_string(path) { - let opts = serde_json::from_str(&content); - if let Ok(opts) = opts { - info!("loaded rtk config: \"{}\"", path); - return Some(opts); - } else { - panic!("failed to parse config file \"{}\"", path); - } - } else { - error!("failed to read config file \"{}\"", path); - info!("using default parameters"); - } - } - None - } - pub fn cs_graph(&self) -> bool { - self.matches.get_flag("cs") - } - /* - * No graph to be generated - */ - pub fn no_graph(&self) -> bool { - self.matches.get_flag("no-graph") - } - /* - * Returns possible file path to merge - */ - pub fn merge_path(&self) -> Option<&Path> { - self.matches.get_one::("merge").map(Path::new) - } - /// Returns optionnal RINEX file to "merge" - pub fn to_merge(&self) -> Option { - if let Some(path) = self.merge_path() { - let path = path.to_str().unwrap(); - match Rinex::from_file(path) { - Ok(rnx) => Some(rnx), - Err(e) => { - error!("failed to parse \"{}\", {}", path, e); - None - }, - } - } else { - None - } - } - /// Returns split operation args - pub fn split(&self) -> Option { - if self.matches.contains_id("split") { - if let Some(s) = self.matches.get_one::("split") { - if let Ok(epoch) = Epoch::from_str(s) { - Some(epoch) - } else { - panic!("failed to parse [EPOCH]"); - } - } else { - None - } - } else { - None - } - } - fn manual_ecef(&self) -> Option<&String> { - self.matches.get_one::("antenna-ecef") - } - fn manual_geodetic(&self) -> Option<&String> { - self.matches.get_one::("antenna-geo") - } - /// Returns Ground Position possibly specified by user - pub fn manual_position(&self) -> Option { - if let Some(args) = self.manual_ecef() { - let content: Vec<&str> = args.split(',').collect(); - if content.len() != 3 { - panic!("expecting \"x, y, z\" description"); - } - if let Ok(pos_x) = f64::from_str(content[0].trim()) { - if let Ok(pos_y) = f64::from_str(content[1].trim()) { - if let Ok(pos_z) = f64::from_str(content[2].trim()) { - return Some(GroundPosition::from_ecef_wgs84((pos_x, pos_y, pos_z))); - } else { - error!("pos(z) should be f64 ECEF [m]"); - } - } else { - error!("pos(y) should be f64 ECEF [m]"); - } - } else { - error!("pos(x) should be f64 ECEF [m]"); - } - } else if let Some(args) = self.manual_geodetic() { - let content: Vec<&str> = args.split(',').collect(); - if content.len() != 3 { - panic!("expecting \"lat, lon, alt\" description"); - } - if let Ok(lat) = f64::from_str(content[0].trim()) { - if let Ok(long) = f64::from_str(content[1].trim()) { - if let Ok(alt) = f64::from_str(content[2].trim()) { - return Some(GroundPosition::from_geodetic((lat, long, alt))); - } else { - error!("altitude should be f64 [ddeg]"); - } - } else { - error!("altitude should be f64 [ddeg]"); - } - } else { - error!("altitude should be f64 [ddeg]"); - } - } - None - } - pub fn workspace(&self) -> Option<&String> { - self.matches.get_one::("workspace") - } -} diff --git a/rinex-cli/src/cli/graph.rs b/rinex-cli/src/cli/graph.rs new file mode 100644 index 000000000..1bb2beb47 --- /dev/null +++ b/rinex-cli/src/cli/graph.rs @@ -0,0 +1,142 @@ +use clap::{Arg, ArgAction, Command}; + +pub fn subcommand() -> Command { + Command::new("graph") + .short_flag('g') + .long_flag("graph") + .arg_required_else_help(true) + .about( + "RINEX dataset visualization (signals, orbits..), rendered as HTML in the workspace.", + ) + .next_help_heading( + "RINEX dependent visualizations. + Will only generate graphs if related dataset is present.", + ) + .next_help_heading("GNSS observations (requires OBS RINEX)") + .arg( + Arg::new("obs") + .short('o') + .long("obs") + .action(ArgAction::SetTrue) + .help( + "Plot all observables. +When OBS RINEX is provided, this will plot raw phase, dopplers and SSI. +When METEO RINEX is provided, data from meteo sensors is plotted too.", + ), + ) + .arg( + Arg::new("dcb") + .long("dcb") + .action(ArgAction::SetTrue) + .help("Plot Differential Code Bias. Requires OBS RINEX."), + ) + .arg( + Arg::new("mp") + .long("mp") + .action(ArgAction::SetTrue) + .help("Plot Code Multipath. Requires OBS RINEX."), + ) + .next_help_heading("GNSS combinations (requires OBS RINEX)") + .arg( + Arg::new("if") + .short('i') + .long("if") + .action(ArgAction::SetTrue) + .help("Plot Ionosphere Free (IF) signal combination."), + ) + .arg( + Arg::new("gf") + .long("gf") + .short('g') + .action(ArgAction::SetTrue) + .conflicts_with("no-graph") + .help("Plot Geometry Free (GF) signal combination."), + ) + .arg( + Arg::new("wl") + .long("wl") + .short('w') + .action(ArgAction::SetTrue) + .help("Plot Wide Lane (WL) signal combination."), + ) + .arg( + Arg::new("nl") + .long("nl") + .short('n') + .action(ArgAction::SetTrue) + .conflicts_with("no-graph") + .help("Plot Narrow Lane (WL) signal combination."), + ) + .arg( + Arg::new("mw") + .long("mw") + .short('m') + .action(ArgAction::SetTrue) + .conflicts_with("no-graph") + .help("Plot Melbourne-Wübbena (MW) signal combination."), + ) + .arg(Arg::new("cs").long("cs").action(ArgAction::SetTrue).help( + "Phase / Cycle Slip graph. +Plots raw phase signal with blackened sample where either CS was declared by receiver, +or we post processed determined a CS.", + )) + .next_help_heading("Navigation (requires NAV RINEX and/or SP3)") + .arg( + Arg::new("skyplot") + .short('s') + .long("sky") + .action(ArgAction::SetTrue) + .help("Skyplot: SV position in the sky, on a compass."), + ) + .arg( + Arg::new("orbits") + .long("orbits") + .action(ArgAction::SetTrue) + .help("SV position in the sky, on 2D cartesian plots."), + ) + .arg( + Arg::new("sp3-res") + .long("sp3-res") + .action(ArgAction::SetTrue) + .help( + "SV orbital attitude residual analysis |BRDC - SP3|. +Requires both NAV RINEX and SP3 that overlap in time.", + ), + ) + .arg( + Arg::new("naviplot") + .long("naviplot") + .action(ArgAction::SetTrue) + .help( + "SV orbital attitude projected in 3D. +Ideal for precise positioning decision making.", + ), + ) + .next_help_heading("Clock states (requires either NAV RINEX, CLK RINEX or SP3)") + .arg( + Arg::new("sv-clock") + .short('c') + .long("clk") + .action(ArgAction::SetTrue) + .help("SV clock bias (offset, drift, drift changes)."), + ) + .next_help_heading("Atmosphere conditions") + .arg( + Arg::new("tropo") + .long("tropo") + .action(ArgAction::SetTrue) + .help("Plot tropospheric delay from meteo sensors estimation. Requires METEO RINEX."), + ) + .arg( + Arg::new("tec") + .long("tec") + .action(ArgAction::SetTrue) + .help("Plot the TEC map. Requires at least one IONEX file."), + ) + .arg( + Arg::new("ionod") + .long("ionod") + .action(ArgAction::SetTrue) + .help("Plot ionospheric delay per signal & SV, at latitude and longitude of signal sampling."), + ) +} diff --git a/rinex-cli/src/cli/identify.rs b/rinex-cli/src/cli/identify.rs new file mode 100644 index 000000000..64da9e501 --- /dev/null +++ b/rinex-cli/src/cli/identify.rs @@ -0,0 +1,77 @@ +// Data identification opmode +use clap::{Arg, ArgAction, Command}; + +pub fn subcommand() -> Command { + Command::new("identify") + .short_flag('i') + .long_flag("id") + .arg_required_else_help(true) + .about("RINEX data identification opmode") + .arg( + Arg::new("all").short('a').action(ArgAction::SetTrue).help( + "Complete RINEX dataset(s) identification. Turns on all following algorithms.", + ), + ) + .arg( + Arg::new("epochs") + .long("epochs") + .short('e') + .action(ArgAction::SetTrue) + .help("Epoch, Time system and sampling analysis."), + ) + .arg( + Arg::new("gnss") + .long("gnss") + .short('g') + .action(ArgAction::SetTrue) + .help("Enumerate GNSS constellations."), + ) + .arg( + Arg::new("sv") + .long("sv") + .short('s') + .action(ArgAction::SetTrue) + .help("Enumerates SV."), + ) + .arg( + Arg::new("header") + .long("header") + .short('h') + .action(ArgAction::SetTrue) + .help("Extracts major header fields"), + ) + .next_help_heading( + "Following sections are RINEX specific. They will only apply to the related subset.", + ) + .next_help_heading("Observation RINEX") + .arg( + Arg::new("observables") + .long("obs") + .short('o') + .action(ArgAction::SetTrue) + .help("Identify observables in either Observation or Meteo dataset(s)."), + ) + .arg( + Arg::new("snr") + .long("snr") + .action(ArgAction::SetTrue) + .help("SNR identification ([min, max] range, per SV..)"), + ) + .arg( + Arg::new("anomalies") + .short('a') + .long("anomalies") + .action(ArgAction::SetTrue) + .help( + "Enumerate abnormal events along the input time frame (epochs). + Abnormal events are unexpected receiver reset or possible cycle slips for example.", + ), + ) + .next_help_heading("Navigation (BRDC) RINEX") + .arg( + Arg::new("nav-msg") + .long("nav-msg") + .action(ArgAction::SetTrue) + .help("Identify Navigation frame types."), + ) +} diff --git a/rinex-cli/src/cli/merge.rs b/rinex-cli/src/cli/merge.rs new file mode 100644 index 000000000..83c3daa78 --- /dev/null +++ b/rinex-cli/src/cli/merge.rs @@ -0,0 +1,19 @@ +// Merge opmode +use clap::{value_parser, Arg, ArgAction, Command}; +use std::path::PathBuf; + +pub fn subcommand() -> Command { + Command::new("merge") + .short_flag('m') + .long_flag("merge") + .arg_required_else_help(true) + .about("Merge a RINEX into another and dump result.") + .arg( + Arg::new("file") + .value_parser(value_parser!(PathBuf)) + .value_name("FILEPATH") + .action(ArgAction::Set) + .required(true) + .help("RINEX file to merge."), + ) +} diff --git a/rinex-cli/src/cli/mod.rs b/rinex-cli/src/cli/mod.rs new file mode 100644 index 000000000..be58308be --- /dev/null +++ b/rinex-cli/src/cli/mod.rs @@ -0,0 +1,358 @@ +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 crate::Error; +use rinex::prelude::*; +use walkdir::WalkDir; + +// identification mode +mod identify; +// graph mode +mod graph; +// merge mode +mod merge; +// split mode +mod split; +// tbin mode +mod time_binning; +// substraction mode +mod substract; +// QC mode +mod qc; +// positioning mode +mod positioning; + +pub struct Cli { + /// Arguments passed by user + pub matches: ArgMatches, +} + +impl Default for Cli { + fn default() -> Self { + Self::new() + } +} + +/// Context defined by User. +pub struct Context { + /// Data context defined by user + pub data: RnxContext, + /// Quiet option + pub quiet: bool, + /// Workspace is the place where this session will generate data. + /// By default it is set to $WORKSPACE/$PRIMARYFILE. + /// $WORKSPACE is either manually definedd by CLI or we create it (as is). + /// $PRIMARYFILE is determined from the most major file contained in the dataset. + pub workspace: PathBuf, + /// (RX) reference position to be used in further analysis. + /// It is either (priority order is important) + /// 1. manually defined by CLI + /// 2. determined from dataset + pub rx_ecef: Option<(f64, f64, f64)>, +} + +impl Context { + /* + * Utility to determine the most major filename stem, + * to be used as the session workspace + */ + pub fn context_stem(data: &RnxContext) -> String { + let ctx_major_stem: &str = data + .rinex_path() + .expect("failed to determine a context name") + .file_stem() + .expect("failed to determine a context name") + .to_str() + .expect("failed to determine a context name"); + /* + * In case $FILENAME.RNX.gz gz compressed, we extract "$FILENAME". + * Can use .file_name() once https://github.com/rust-lang/rust/issues/86319 is stabilized + */ + let primary_stem: Vec<&str> = ctx_major_stem.split('.').collect(); + primary_stem[0].to_string() + } + /* + * Creates File/Data context defined by user. + * Regroups all provided files/folders, + */ + pub fn from_cli(cli: &Cli) -> Result { + let mut data = RnxContext::default(); + let max_depth = match cli.matches.get_one::("depth") { + Some(value) => *value as usize, + None => 5usize, + }; + + /* load all directories recursively, one by one */ + for dir in cli.input_directories() { + 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); + if ret.is_err() { + warn!("failed to load \"{}\": {}", filepath, ret.err().unwrap()); + } + } + } + } + // load individual files, if any + for filepath in cli.input_files() { + let ret = data.load(filepath); + if ret.is_err() { + warn!("failed to load \"{}\": {}", filepath, ret.err().unwrap()); + } + } + let data_stem = Self::context_stem(&data); + let data_position = data.ground_position(); + Ok(Self { + data, + quiet: cli.matches.get_flag("quiet"), + workspace: { + let path = match std::env::var("RINEX_WORKSPACE") { + Ok(path) => Path::new(&path).join(data_stem).to_path_buf(), + _ => match cli.matches.get_one::("workspace") { + Some(base_dir) => Path::new(base_dir).join(data_stem).to_path_buf(), + None => Path::new("WORKSPACE").join(data_stem).to_path_buf(), + }, + }; + // make sure the workspace is viable and exists, otherwise panic + std::fs::create_dir_all(&path).unwrap_or_else(|_| { + panic!( + "failed to create session workspace \"{}\": permission denied!", + path.to_string_lossy() + ) + }); + info!("session workspace is \"{}\"", path.to_string_lossy()); + path + }, + rx_ecef: { + match cli.manual_position() { + Some((x, y, z)) => { + let (lat, lon, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + info!( + "using manually defined position: {:?} [ECEF] (lat={:.5}°, lon={:.5}°", + (x, y, z), + lat, + lon + ); + Some((x, y, z)) + }, + 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); + info!( + "position defined in dataset: {:?} [ECEF] (lat={:.5}°, lon={:.5}°", + (x, y, z), + lat, + lon + ); + Some((x, y, z)) + } else { + // this is not a problem in basic opmodes, + // but will most likely prevail advanced opmodes. + // Let the user know. + warn!("no RX position defined"); + None + } + }, + } + }, + }) + } +} + +impl Cli { + /// Build new command line interface + pub fn new() -> Self { + Self { + matches: { + Command::new("rinex-cli") + .author("Guillaume W. Bres, ") + .version(env!("CARGO_PKG_VERSION")) + .about("RINEX post processing (command line)") + .arg_required_else_help(true) + .color(ColorChoice::Always) + .arg(Arg::new("filepath") + .short('f') + .long("fp") + .value_name("FILE") + .action(ArgAction::Append) + .required_unless_present("directory") + .help("Input file. RINEX (any format, including Clock and ANTEX), and SP3 are accepted. You can load as many files as you need.")) + .arg(Arg::new("directory") + .short('d') + .long("dir") + .value_name("DIRECTORY") + .action(ArgAction::Append) + .required_unless_present("filepath") + .help("Load directory recursively. Default recursive depth is set to 5, +but you can extend that with --depth. +Again any RINEX, and SP3 are accepted. You can load as many directories as you need.")) + .arg(Arg::new("depth") + .long("depth") + .action(ArgAction::Set) + .required(false) + .value_parser(value_parser!(u8)) + .help("Extend maximal recursive search depth of -d. The default is 5.")) + .arg(Arg::new("quiet") + .short('q') + .long("quiet") + .action(ArgAction::SetTrue) + .help("Disable all terminal output. Also disables auto HTML reports opener.")) + .arg(Arg::new("workspace") + .short('w') + .long("workspace") + .value_name("FOLDER") + .value_parser(value_parser!(PathBuf)) + .help("Define custom workspace location. The env. variable RINEX_WORKSPACE, if present, is prefered. +If none of those exist, we will generate local \"WORKSPACE\" folder.")) + .next_help_heading("Preprocessing") + .arg(Arg::new("gps-filter") + .short('G') + .action(ArgAction::SetTrue) + .help("Filters out all GPS vehicles")) + .arg(Arg::new("glo-filter") + .short('R') + .action(ArgAction::SetTrue) + .help("Filters out all Glonass vehicles")) + .arg(Arg::new("gal-filter") + .short('E') + .action(ArgAction::SetTrue) + .help("Filters out all Galileo vehicles")) + .arg(Arg::new("bds-filter") + .short('C') + .action(ArgAction::SetTrue) + .help("Filters out all BeiDou vehicles")) + .arg(Arg::new("qzss-filter") + .short('J') + .action(ArgAction::SetTrue) + .help("Filters out all QZSS vehicles")) + .arg(Arg::new("irnss-filter") + .short('I') + .action(ArgAction::SetTrue) + .help("Filters out all IRNSS vehicles")) + .arg(Arg::new("sbas-filter") + .short('S') + .action(ArgAction::SetTrue) + .help("Filters out all SBAS vehicles")) + .arg(Arg::new("preprocessing") + .short('P') + .num_args(1..) + .action(ArgAction::Append) + .help("Filter designer. Refer to [].")) + .arg(Arg::new("lli-mask") + .long("lli-mask") + .help("Applies given LLI AND() mask. +Also drops observations that did not come with an LLI flag")) + .next_help_heading("Receiver Antenna") + .arg(Arg::new("rx-ecef") + .long("rx-ecef") + .value_name("\"x,y,z\" coordinates in ECEF [m]") + .help("Define the (RX) antenna position manually, in [m] ECEF. +Especially if your dataset does not define such position. +Otherwise it gets automatically picked up.")) + .arg(Arg::new("rx-geo") + .long("rx-geo") + .value_name("\"lat,lon,alt\" coordinates in ddeg [°]") + .help("Define the (RX) antenna position manualy, in decimal degrees.")) + .next_help_heading("Exclusive Opmodes: you can only run one at a time.") + .subcommand(graph::subcommand()) + .subcommand(identify::subcommand()) + .subcommand(merge::subcommand()) + .subcommand(positioning::subcommand()) + .subcommand(qc::subcommand()) + .subcommand(split::subcommand()) + .subcommand(substract::subcommand()) + .subcommand(time_binning::subcommand()) + .get_matches() + }, + } + } + /// Returns list of input directories + pub fn input_directories(&self) -> Vec<&String> { + if let Some(fp) = self.matches.get_many::("directory") { + fp.collect() + } else { + Vec::new() + } + } + /// Returns individual input filepaths + pub fn input_files(&self) -> Vec<&String> { + if let Some(fp) = self.matches.get_many::("filepath") { + fp.collect() + } else { + Vec::new() + } + } + pub fn preprocessing(&self) -> Vec<&String> { + if let Some(filters) = self.matches.get_many::("preprocessing") { + filters.collect() + } else { + Vec::new() + } + } + pub fn gps_filter(&self) -> bool { + self.matches.get_flag("gps-filter") + } + pub fn glo_filter(&self) -> bool { + self.matches.get_flag("glo-filter") + } + pub fn gal_filter(&self) -> bool { + self.matches.get_flag("gal-filter") + } + pub fn bds_filter(&self) -> bool { + self.matches.get_flag("bds-filter") + } + pub fn qzss_filter(&self) -> bool { + self.matches.get_flag("qzss-filter") + } + pub fn sbas_filter(&self) -> bool { + self.matches.get_flag("sbas-filter") + } + pub fn irnss_filter(&self) -> bool { + self.matches.get_flag("irnss-filter") + } + /* + * faillible 3D coordinates parsing + * it's better to panic if the descriptor is badly format + * then continuing with possible other coordinates than the + * ones desired by user + */ + fn parse_3d_coordinates(desc: &String) -> (f64, f64, f64) { + let content = desc.split(',').collect::>(); + if content.len() < 3 { + panic!("expecting x, y and z coordinates (3D)"); + } + let x = f64::from_str(content[0].trim()) + .unwrap_or_else(|_| panic!("failed to parse x coordinates")); + let y = f64::from_str(content[1].trim()) + .unwrap_or_else(|_| panic!("failed to parse y coordinates")); + let z = f64::from_str(content[2].trim()) + .unwrap_or_else(|_| panic!("failed to parse z coordinates")); + (x, y, z) + } + fn manual_ecef(&self) -> Option<(f64, f64, f64)> { + let desc = self.matches.get_one::<&String>("rx-ecef")?; + let ecef = Self::parse_3d_coordinates(desc); + Some(ecef) + } + fn manual_geodetic(&self) -> Option<(f64, f64, f64)> { + let desc = self.matches.get_one::<&String>("rx-geo")?; + let geo = Self::parse_3d_coordinates(desc); + Some(geo) + } + /// Returns RX Position possibly specified by user + pub fn manual_position(&self) -> Option<(f64, f64, f64)> { + if let Some(position) = self.manual_ecef() { + Some(position) + } else if let Some((lat, lon, alt)) = self.manual_geodetic() { + Some(geodetic2ecef(lat, lon, alt, Ellipsoid::WGS84)) + } else { + None + } + } +} diff --git a/rinex-cli/src/cli/positioning.rs b/rinex-cli/src/cli/positioning.rs new file mode 100644 index 000000000..9c75950f5 --- /dev/null +++ b/rinex-cli/src/cli/positioning.rs @@ -0,0 +1,65 @@ +// Positioning OPMODE +use clap::{value_parser, Arg, ArgAction, Command}; +use rinex::prelude::Duration; + +pub fn subcommand() -> Command { + Command::new("positioning") + .short_flag('p') + .arg_required_else_help(false) + .about("Precise Positioning opmode. +Use this mode to resolve precise positions and local time from RINEX dataset. +You should provide Observations from a unique receiver.") + .arg(Arg::new("cfg") + .short('c') + .long("cfg") + .value_name("FILE") + .required(false) + .action(ArgAction::Append) + .help("Pass a Position Solver configuration file (JSON). +[https://docs.rs/gnss-rtk/latest/gnss_rtk/prelude/struct.Config.html] is the structure to represent in JSON. +See [] for meaningful examples.")) + .arg(Arg::new("spp") + .long("spp") + .action(ArgAction::SetTrue) + .help("Force resolution method to Single Point Positioning (SPP). +Otherwise, the Default method is used. +Refer to [https://docs.rs/gnss-rtk/latest/gnss_rtk/prelude/enum.Method.html].")) + .arg(Arg::new("gpx") + .long("gpx") + .action(ArgAction::SetTrue) + .help("Format PVT solutions as GPX track.")) + .arg(Arg::new("kml") + .long("kml") + .action(ArgAction::SetTrue) + .help("Format PVT solutions as KML track.")) + .next_help_heading("CGGTTS (special resolution for clock comparison / time transfer)") + .arg(Arg::new("cggtts") + .long("cggtts") + .action(ArgAction::SetTrue) + .help("Activate CGGTTS special solver. +Wrapps PVT solutions as CGGTTS file(s) for remote clock comparison (time transfer).")) + .arg(Arg::new("tracking") + .long("trk") + .short('t') + .value_parser(value_parser!(Duration)) + .action(ArgAction::Set) + .help("CGGTTS custom tracking duration. +Otherwise, the default tracking duration is used. +Refer to []")) + .arg(Arg::new("lab") + .long("lab") + .action(ArgAction::Set) + .help("Define the name of your station or laboratory here.")) + .arg(Arg::new("utck") + .long("utck") + .action(ArgAction::Set) + .conflicts_with("clock") + .help("If the local clock tracks a local UTC replica, you can define the name +of this replica here.")) + .arg(Arg::new("clock") + .long("clk") + .action(ArgAction::Set) + .conflicts_with("utck") + .help("If the local clock is not a UTC replica and has a specific name, you +can define it here.")) +} diff --git a/rinex-cli/src/cli/qc.rs b/rinex-cli/src/cli/qc.rs new file mode 100644 index 000000000..59c5975f0 --- /dev/null +++ b/rinex-cli/src/cli/qc.rs @@ -0,0 +1,29 @@ +// tbin opmode +use clap::{Arg, ArgAction, Command}; + +pub fn subcommand() -> Command { + Command::new("quality-check") + .short_flag('Q') + .long_flag("qc") + .about( + "File Quality analysis (statistical evaluation) of the dataset. +Typically used prior precise point positioning.", + ) + .arg( + Arg::new("spp") + .long("spp") + .action(ArgAction::SetTrue) + .help("Force solving method to SPP. +Otherwise we use the default Method. +See online documentations [https://docs.rs/gnss-rtk/latest/gnss_rtk/prelude/enum.Method.html#variants].")) + .arg( + Arg::new("cfg") + .short('c') + .long("cfg") + .required(false) + .value_name("FILE") + .action(ArgAction::Append) + .help("Pass a QC configuration file (JSON). +[] is the structure to represent in JSON. +See [] for meaningful examples.")) +} diff --git a/rinex-cli/src/cli/split.rs b/rinex-cli/src/cli/split.rs new file mode 100644 index 000000000..006999942 --- /dev/null +++ b/rinex-cli/src/cli/split.rs @@ -0,0 +1,19 @@ +// Merge opmode +use clap::{value_parser, Arg, ArgAction, Command}; +use rinex::prelude::Epoch; + +pub fn subcommand() -> Command { + Command::new("split") + .short_flag('s') + .long_flag("split") + .arg_required_else_help(true) + .about("Split input files at specified Epoch.") + .arg( + Arg::new("split") + .value_parser(value_parser!(Epoch)) + .value_name("EPOCH") + .action(ArgAction::Set) + .required(true) + .help("Epoch (instant) to split at."), + ) +} diff --git a/rinex-cli/src/cli/substract.rs b/rinex-cli/src/cli/substract.rs new file mode 100644 index 000000000..f34d4f523 --- /dev/null +++ b/rinex-cli/src/cli/substract.rs @@ -0,0 +1,23 @@ +// sub opmode +use clap::{value_parser, Arg, ArgAction, Command}; +use std::path::PathBuf; + +pub fn subcommand() -> Command { + Command::new("sub") + .long_flag("sub") + .arg_required_else_help(true) + .about( + "RINEX(A)-RINEX(B) substraction operation. +This is typically used to compare two GNSS receivers together.", + ) + .arg( + Arg::new("file") + .value_parser(value_parser!(PathBuf)) + .value_name("FILEPATH") + .action(ArgAction::Set) + .required(true) + .help( + "RINEX(B) to substract to a single RINEX file (A), that was previously loaded.", + ), + ) +} diff --git a/rinex-cli/src/cli/time_binning.rs b/rinex-cli/src/cli/time_binning.rs new file mode 100644 index 000000000..93c371d18 --- /dev/null +++ b/rinex-cli/src/cli/time_binning.rs @@ -0,0 +1,18 @@ +// tbin opmode +use clap::{value_parser, Arg, ArgAction, Command}; +use rinex::prelude::Duration; + +pub fn subcommand() -> Command { + Command::new("tbin") + .long_flag("tbin") + .arg_required_else_help(true) + .about("Time binning. Split RINEX files into a batch of equal duration.") + .arg( + Arg::new("interval") + .value_parser(value_parser!(Duration)) + .value_name("Duration") + .action(ArgAction::Set) + .required(true) + .help("Duration"), + ) +} diff --git a/rinex-cli/src/file_generation.rs b/rinex-cli/src/file_generation.rs deleted file mode 100644 index e69de29bb..000000000 diff --git a/rinex-cli/src/filter.rs b/rinex-cli/src/filter.rs deleted file mode 100644 index c431a1f44..000000000 --- a/rinex-cli/src/filter.rs +++ /dev/null @@ -1,37 +0,0 @@ -/*use crate::{Cli, Context}; -use log::{error, trace}; -use rinex::{observation::*, prelude::*}; - -fn args_to_lli_mask(args: &str) -> Option { - if let Ok(u) = u8::from_str_radix(args.trim(), 10) { - LliFlags::from_bits(u) - } else { - None - } -} - -pub fn apply_filters(ctx: &mut Context, cli: &Cli) { -/* - let ops = cli.filter_ops(); - for (op, args) in ops.iter() { - if op.eq(&"lli-mask") { - if let Some(mask) = args_to_lli_mask(args) { - ctx.primary_rinex.observation_lli_and_mask_mut(mask); - trace!("lli mask applied"); - } else { - error!("invalid lli mask \"{}\"", args); - } - } - } -*/ -} - -//TODO -pub fn elevation_mask_filter(ctx: &mut Context, cli: &Cli) { -/* if let Some(mask) = cli.elevation_mask() { - if let Some(ref mut nav) = ctx.nav_rinex { - nav.elevation_mask_mut(mask, ctx.ground_position) - } - }*/ -} -*/ diff --git a/rinex-cli/src/fops.rs b/rinex-cli/src/fops.rs index 8f62df6e0..90287ca7d 100644 --- a/rinex-cli/src/fops.rs +++ b/rinex-cli/src/fops.rs @@ -1,4 +1,303 @@ +use crate::cli::Context; +use crate::Error; +use clap::ArgMatches; +use rinex::prelude::{Duration, Epoch, Rinex, RinexType}; +use rinex::preprocessing::*; +use rinex::{Merge, Split}; +use std::path::PathBuf; use std::process::Command; +use std::str::FromStr; + +/* + * Merges proposed (single) file and generates resulting output, into the workspace + */ +pub fn merge(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { + let merge_path = matches.get_one::("file").unwrap(); + + let merge_filepath = merge_path.to_string_lossy().to_string(); + + let rinex_b = Rinex::from_file(&merge_filepath)?; + + let rinex_c = match rinex_b.header.rinex_type { + RinexType::ObservationData => { + let rinex_a = ctx.data.obs_data().ok_or(Error::MissingObservationRinex)?; + rinex_a.merge(&rinex_b)? + }, + RinexType::NavigationData => { + let rinex_a = ctx.data.nav_data().ok_or(Error::MissingNavigationRinex)?; + rinex_a.merge(&rinex_b)? + }, + _ => unimplemented!(), + }; + + let suffix = merge_path + .file_name() + .expect("failed to determine output path") + .to_string_lossy() + .to_string(); + + let output_path = ctx.workspace.join(suffix).to_string_lossy().to_string(); + + rinex_c.to_file(&output_path)?; + + info!("\"{}\" has been generated", output_path); + Ok(()) +} + +/* + * Splits input files at specified Time Instant + */ +pub fn split(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { + let split_instant = matches + .get_one::("split") + .expect("split epoch is required"); + + if let Some(rinex) = ctx.data.obs_data() { + let (rinex_a, rinex_b) = rinex.split(*split_instant)?; + + let first_epoch = rinex_a + .first_epoch() + .expect("failed to determine file suffix"); + + let (y, m, d, hh, mm, ss, _) = first_epoch.to_gregorian_utc(); + let file_suffix = format!( + "{}{}{}_{}{}{}{}", + y, m, d, hh, mm, ss, first_epoch.time_scale + ); + + let obs_path = ctx + .data + .obs_paths() + .expect("failed to determine output file name") + .get(0) + .unwrap(); + + let filename = obs_path + .file_stem() + .expect("failed to determine output file name") + .to_string_lossy() + .to_string(); + + let mut extension = String::new(); + + let filename = if filename.contains('.') { + /* .crx.gz case */ + let mut iter = filename.split('.'); + let filename = iter + .next() + .expect("failed to determine output file name") + .to_string(); + extension.push_str(iter.next().expect("failed to determine output file name")); + extension.push('.'); + filename + } else { + filename.clone() + }; + + let file_ext = obs_path + .extension() + .expect("failed to determine output file name") + .to_string_lossy() + .to_string(); + + extension.push_str(&file_ext); + + let output = ctx + .workspace + .join(format!("{}-{}.{}", filename, file_suffix, extension)) + .to_string_lossy() + .to_string(); + + rinex_a.to_file(&output)?; + info!("\"{}\" has been generated", output); + + let first_epoch = rinex_b + .first_epoch() + .expect("failed to determine file suffix"); + + let (y, m, d, hh, mm, ss, _) = first_epoch.to_gregorian_utc(); + let file_suffix = format!( + "{}{}{}_{}{}{}{}", + y, m, d, hh, mm, ss, first_epoch.time_scale + ); + + let obs_path = ctx + .data + .obs_paths() + .expect("failed to determine output file name") + .get(0) + .unwrap(); + + let filename = obs_path + .file_stem() + .expect("failed to determine output file name") + .to_string_lossy() + .to_string(); + + let output = ctx + .workspace + .join(format!("{}-{}.{}", filename, file_suffix, extension)) + .to_string_lossy() + .to_string(); + + rinex_b.to_file(&output)?; + info!("\"{}\" has been generated", output); + } + Ok(()) +} + +/* + * Time reframing: subdivde a RINEX into a batch of equal duration + */ +pub fn time_binning(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { + let duration = matches + .get_one::("interval") + .expect("duration is required"); + + if *duration == Duration::ZERO { + panic!("invalid duration"); + } + + if let Some(rinex) = ctx.data.obs_data() { + let (mut first, end) = ( + rinex + .first_epoch() + .expect("failed to determine first epoch"), + rinex.last_epoch().expect("failed to determine last epoch"), + ); + + let mut last = first + *duration; + + let obs_path = ctx + .data + .obs_paths() + .expect("failed to determine output file name") + .get(0) + .unwrap(); + + let filename = obs_path + .file_stem() + .expect("failed to determine output file name") + .to_string_lossy() + .to_string(); + + let mut extension = String::new(); + + let filename = if filename.contains('.') { + /* .crx.gz case */ + let mut iter = filename.split('.'); + let filename = iter + .next() + .expect("failed to determine output file name") + .to_string(); + extension.push_str(iter.next().expect("failed to determine output file name")); + extension.push('.'); + filename + } else { + filename.clone() + }; + + let file_ext = obs_path + .extension() + .expect("failed to determine output file name") + .to_string_lossy() + .to_string(); + + extension.push_str(&file_ext); + + while last <= end { + let rinex = rinex + .filter(Filter::from_str(&format!("< {:?}", last)).unwrap()) + .filter(Filter::from_str(&format!(">= {:?}", first)).unwrap()); + + let (y, m, d, hh, mm, ss, _) = first.to_gregorian_utc(); + let file_suffix = format!("{}{}{}_{}{}{}{}", y, m, d, hh, mm, ss, first.time_scale); + + let output = ctx + .workspace + .join(&format!("{}-{}.{}", filename, file_suffix, extension)) + .to_string_lossy() + .to_string(); + + rinex.to_file(&output)?; + info!("\"{}\" has been generated", output); + + first += *duration; + last += *duration; + } + } + + Ok(()) +} + +/* + * Substract RINEX[A]-RINEX[B] + */ +pub fn substract(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { + let path_b = matches.get_one::("file").unwrap(); + + let path_b = path_b.to_string_lossy().to_string(); + let rinex_b = Rinex::from_file(&path_b) + .unwrap_or_else(|_| panic!("failed to load {}: invalid RINEX", path_b)); + + let rinex_c = match rinex_b.header.rinex_type { + RinexType::ObservationData => { + let rinex_a = ctx.data.obs_data().expect("no OBS RINEX previously loaded"); + + rinex_a + .crnx2rnx() //TODO remove this in future please + .substract( + &rinex_b.crnx2rnx(), //TODO: remove this in future please + )? + }, + t => panic!("operation not feasible for {}", t), + }; + + let mut extension = String::new(); + + let obs_path = ctx + .data + .obs_paths() + .expect("failed to determine output file name") + .get(0) + .unwrap(); + + let filename = obs_path + .file_stem() + .expect("failed to determine output file name") + .to_string_lossy() + .to_string(); + + if filename.contains('.') { + /* .crx.gz case */ + let mut iter = filename.split('.'); + let _filename = iter + .next() + .expect("failed to determine output file name") + .to_string(); + extension.push_str(iter.next().expect("failed to determine output file name")); + extension.push('.'); + } + + let file_ext = obs_path + .extension() + .expect("failed to determine output file name") + .to_string_lossy() + .to_string(); + + extension.push_str(&file_ext); + + let fullpath = ctx + .workspace + .join(format!("DIFFERENCED.{}", extension)) + .to_string_lossy() + .to_string(); + + rinex_c.to_file(&fullpath)?; + + info!("\"{}\" has been generated", fullpath); + Ok(()) +} #[cfg(target_os = "linux")] pub fn open_with_web_browser(path: &str) { diff --git a/rinex-cli/src/plot/combination.rs b/rinex-cli/src/graph/combination.rs similarity index 68% rename from rinex-cli/src/plot/combination.rs rename to rinex-cli/src/graph/combination.rs index 34b0b4115..93640c3b0 100644 --- a/rinex-cli/src/plot/combination.rs +++ b/rinex-cli/src/graph/combination.rs @@ -42,7 +42,39 @@ pub fn plot_gnss_combination( /* * Plot DCB analysis */ -pub fn plot_gnss_dcb_mp( +pub fn plot_gnss_dcb( + data: &HashMap>>, + plot_context: &mut PlotContext, + plot_title: &str, + y_title: &str, +) { + // add a plot + plot_context.add_timedomain_plot(plot_title, y_title); + // generate 1 marker per OP + let markers = generate_markers(data.len()); + // plot all ops + for (op_index, (op, vehicles)) in data.iter().enumerate() { + for (_sv, epochs) in vehicles { + let data_x: Vec = epochs.iter().map(|((e, _flag), _v)| *e).collect(); + let data_y: Vec = epochs.iter().map(|(_, v)| *v).collect(); + let trace = build_chart_epoch_axis(&op.to_string()[1..], Mode::Markers, data_x, data_y) + .marker(Marker::new().symbol(markers[op_index].clone())) + .visible({ + if op_index < 2 { + Visible::True + } else { + Visible::LegendOnly + } + }); + plot_context.add_trace(trace); + } + } +} + +/* + * Plot MP analysis + */ +pub fn plot_gnss_code_mp( data: &HashMap>>, plot_context: &mut PlotContext, plot_title: &str, diff --git a/rinex-cli/src/plot/context.rs b/rinex-cli/src/graph/context.rs similarity index 100% rename from rinex-cli/src/plot/context.rs rename to rinex-cli/src/graph/context.rs diff --git a/rinex-cli/src/plot/mod.rs b/rinex-cli/src/graph/mod.rs similarity index 59% rename from rinex-cli/src/plot/mod.rs rename to rinex-cli/src/graph/mod.rs index 051e0bdc8..062af5f68 100644 --- a/rinex-cli/src/plot/mod.rs +++ b/rinex-cli/src/graph/mod.rs @@ -1,3 +1,9 @@ +use crate::{cli::Context, fops::open_with_web_browser, Error}; +use clap::ArgMatches; +use rinex::observation::{Combination, Combine, Dcb}; +use std::fs::File; +use std::io::Write; + use plotly::{ common::{ AxisSide, @@ -17,22 +23,23 @@ use plotly::{ use rand::Rng; use serde::Serialize; -use rinex::prelude::RnxContext; use rinex::prelude::*; mod record; +use record::{ + plot_atmosphere_conditions, plot_residual_ephemeris, plot_sv_nav_clock, plot_sv_nav_orbits, +}; mod context; pub use context::PlotContext; mod skyplot; -pub use skyplot::skyplot; +use skyplot::skyplot; mod naviplot; -pub use naviplot::naviplot; mod combination; -pub use combination::{plot_gnss_combination, plot_gnss_dcb_mp}; +use combination::{plot_gnss_code_mp, plot_gnss_combination, plot_gnss_dcb}; /* * Generates N marker symbols to be used @@ -495,28 +502,223 @@ pub fn build_3d_chart_epoch_label( .hover_info(HoverInfo::All) } -pub fn plot_record(ctx: &RnxContext, plot_ctx: &mut PlotContext) { +/* Returns True if GNSS combination is to be plotted */ +fn gnss_combination_plot(matches: &ArgMatches) -> bool { + matches.get_flag("if") + || matches.get_flag("gf") + || matches.get_flag("wl") + || matches.get_flag("nl") + || matches.get_flag("mw") +} + +/* Returns True if Navigation plot is to be generated */ +fn navigation_plot(matches: &ArgMatches) -> bool { + matches.get_flag("skyplot") || matches.get_flag("sp3-res") || matches.get_flag("sv-clock") +} + +/* Returns True if Atmosphere conditions is to be generated */ +fn atmosphere_plot(matches: &ArgMatches) -> bool { + matches.get_flag("tropo") || matches.get_flag("tec") || matches.get_flag("ionod") +} + +pub fn graph_opmode(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { /* - * Run feasible record analysis + * Observations graphs */ - if ctx.has_observation_data() { - record::plot_observation(ctx, plot_ctx); + 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); + } + if let Some(data) = ctx.data.meteo_data() { + record::plot_meteo_observations(data, &mut plot_ctx); + } + + /* 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()); + } + } + /* + * GNSS combinations graphs + */ + if gnss_combination_plot(matches) { + let data = ctx.data.obs_data().ok_or(Error::MissingObservationRinex)?; + + let mut plot_ctx = PlotContext::new(); + if matches.get_flag("if") { + let combination = data.combine(Combination::IonosphereFree); + plot_gnss_combination( + &combination, + &mut plot_ctx, + "Ionosphere Free combination", + "Meters of delay", + ); + } + if matches.get_flag("gf") { + let combination = data.combine(Combination::GeometryFree); + plot_gnss_combination( + &combination, + &mut plot_ctx, + "Ionosphere Free combination", + "Meters of delay", + ); + } + if matches.get_flag("wl") { + let combination = data.combine(Combination::WideLane); + plot_gnss_combination( + &combination, + &mut plot_ctx, + "Ionosphere Free combination", + "Meters of delay", + ); + } + if matches.get_flag("nl") { + let combination = data.combine(Combination::NarrowLane); + plot_gnss_combination( + &combination, + &mut plot_ctx, + "Ionosphere Free combination", + "Meters of delay", + ); + } + if matches.get_flag("mw") { + let combination = data.combine(Combination::MelbourneWubbena); + plot_gnss_combination( + &combination, + &mut plot_ctx, + "Ionosphere Free combination", + "Meters of delay", + ); + } + + /* 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()); + } } - if let Some(data) = ctx.meteo_data() { - record::plot_meteo(data, plot_ctx); + /* + * DCB visualization + */ + if matches.get_flag("dcb") { + let data = ctx.data.obs_data().ok_or(Error::MissingObservationRinex)?; + + let mut plot_ctx = PlotContext::new(); + let data = data.dcb(); + plot_gnss_dcb( + &data, + &mut plot_ctx, + "Differential Code Bias", + "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()); + } } - if let Some(data) = ctx.ionex_data() { - if let Some(borders) = data.tec_map_borders() { - record::plot_tec_map(data, borders, plot_ctx); + if matches.get_flag("mp") { + let data = ctx.data.obs_data().ok_or(Error::MissingObservationRinex)?; + + let mut plot_ctx = PlotContext::new(); + 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()); } } - if ctx.has_navigation_data() || ctx.has_sp3() { - record::plot_navigation(ctx, plot_ctx); + if navigation_plot(matches) { + let mut plot_ctx = PlotContext::new(); + + if matches.get_flag("skyplot") { + let rx_ecef = ctx + .rx_ecef + .expect("skyplot requires the receiver location to be defined."); + if ctx.data.sp3_data().is_none() && ctx.data.nav_data().is_none() { + panic!("skyplot requires either BRDC or SP3."); + } + skyplot(&ctx.data, rx_ecef, &mut plot_ctx); + } + if matches.get_flag("orbits") { + plot_sv_nav_orbits(&ctx.data, &mut plot_ctx); + } + if matches.get_flag("sp3-res") { + if ctx.data.sp3_data().is_none() || ctx.data.nav_data().is_none() { + panic!("skyplot requires both BRDC or SP3."); + } + 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()); + } } - if ctx.has_sp3() && ctx.has_navigation_data() { - record::plot_residual_ephemeris(ctx, plot_ctx); + 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()); + } } - if ctx.has_navigation_data() { - record::plot_ionospheric_delay(ctx, plot_ctx); + 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()); + } } + Ok(()) } diff --git a/rinex-cli/src/plot/naviplot.rs b/rinex-cli/src/graph/naviplot.rs similarity index 94% rename from rinex-cli/src/plot/naviplot.rs rename to rinex-cli/src/graph/naviplot.rs index 06065f0bb..120b7754b 100644 --- a/rinex-cli/src/plot/naviplot.rs +++ b/rinex-cli/src/graph/naviplot.rs @@ -1,4 +1,4 @@ -use crate::plot::PlotContext; +use crate::graph::PlotContext; // use plotly::{ // common::{Mode, Visible}, // ScatterPolar, diff --git a/rinex-cli/src/plot/record/ionex.rs b/rinex-cli/src/graph/record/ionex.rs similarity index 98% rename from rinex-cli/src/plot/record/ionex.rs rename to rinex-cli/src/graph/record/ionex.rs index b1a9b59e9..74050c1e5 100644 --- a/rinex-cli/src/plot/record/ionex.rs +++ b/rinex-cli/src/graph/record/ionex.rs @@ -1,5 +1,5 @@ //use itertools::Itertools; -use crate::plot::PlotContext; +use crate::graph::PlotContext; use plotly::color::NamedColor; use plotly::common::{Marker, MarkerSymbol}; use plotly::layout::MapboxStyle; diff --git a/rinex-cli/src/plot/record/ionosphere.rs b/rinex-cli/src/graph/record/ionosphere.rs similarity index 98% rename from rinex-cli/src/plot/record/ionosphere.rs rename to rinex-cli/src/graph/record/ionosphere.rs index d54aa9e3d..5eba86d40 100644 --- a/rinex-cli/src/plot/record/ionosphere.rs +++ b/rinex-cli/src/graph/record/ionosphere.rs @@ -1,4 +1,4 @@ -use crate::plot::{build_chart_epoch_axis, PlotContext}; +use crate::graph::{build_chart_epoch_axis, PlotContext}; // use hifitime::{Epoch, TimeScale}; use plotly::common::{ //Marker, diff --git a/rinex-cli/src/plot/record/meteo.rs b/rinex-cli/src/graph/record/meteo.rs similarity index 56% rename from rinex-cli/src/plot/record/meteo.rs rename to rinex-cli/src/graph/record/meteo.rs index 24b3c85bf..0102f0d41 100644 --- a/rinex-cli/src/plot/record/meteo.rs +++ b/rinex-cli/src/graph/record/meteo.rs @@ -1,11 +1,13 @@ -use crate::plot::{build_chart_epoch_axis, PlotContext}; //generate_markers}; +use crate::graph::{build_chart_epoch_axis, PlotContext}; //generate_markers}; use plotly::common::{Marker, MarkerSymbol, Mode}; +use plotly::ScatterPolar; use rinex::prelude::*; +use statrs::statistics::Statistics; /* - * Plots Meteo RINEX + * Plots Meteo observations */ -pub fn plot_meteo(rnx: &Rinex, plot_context: &mut PlotContext) { +pub fn plot_meteo_observations(rnx: &Rinex, plot_context: &mut PlotContext) { /* * 1 plot per physics */ @@ -22,6 +24,10 @@ pub fn plot_meteo(rnx: &Rinex, plot_context: &mut PlotContext) { Observable::HailIndicator => "", _ => unreachable!(), }; + if *observable == Observable::WindDirection { + // we plot this one differently: on a compass similar to skyplot + continue; + } plot_context.add_timedomain_plot( &format!("{} Observations", observable), &format!("{} [{}]", observable, unit), @@ -57,4 +63,40 @@ pub fn plot_meteo(rnx: &Rinex, plot_context: &mut PlotContext) { .marker(Marker::new().symbol(MarkerSymbol::TriangleUp)); plot_context.add_trace(trace); } + /* + * Plot Wind Direction + */ + let wind_speed = rnx.wind_speed().map(|(_, speed)| speed).collect::>(); + let wind_speed_max = wind_speed.max(); + + let theta = rnx + .wind_direction() + .map(|(_, angle)| angle) + .collect::>(); + + let has_wind_direction = !theta.is_empty(); + + let rho = rnx + .wind_direction() + .map(|(t, _)| { + if let Some(speed) = rnx + .wind_speed() + .find(|(ts, _)| *ts == t) + .map(|(_, speed)| speed) + { + speed / wind_speed_max + } else { + 1.0_f64 + } + }) + .collect::>(); + + let trace = ScatterPolar::new(theta, rho) + .marker(Marker::new().symbol(MarkerSymbol::TriangleUp)) + .connect_gaps(false) + .name("Wind direction [°]"); + if has_wind_direction { + plot_context.add_polar2d_plot("Wind direction (r= normalized speed)"); + plot_context.add_trace(trace); + } } diff --git a/rinex-cli/src/graph/record/mod.rs b/rinex-cli/src/graph/record/mod.rs new file mode 100644 index 000000000..1f96e7611 --- /dev/null +++ b/rinex-cli/src/graph/record/mod.rs @@ -0,0 +1,32 @@ +mod ionex; +mod ionosphere; +mod meteo; +mod navigation; +mod observation; +mod sp3_plot; + +pub use meteo::plot_meteo_observations; +pub use navigation::plot_sv_nav_clock; +pub use navigation::plot_sv_nav_orbits; +pub use observation::plot_observations; +pub use sp3_plot::plot_residual_ephemeris; + +use crate::cli::Context; +use crate::graph::PlotContext; +use clap::ArgMatches; + +use ionex::plot_tec_map; +use ionosphere::plot_ionospheric_delay; + +pub fn plot_atmosphere_conditions(ctx: &Context, plot_ctx: &mut PlotContext, matches: &ArgMatches) { + if matches.get_flag("tropo") { + let _meteo = ctx.data.meteo_data().expect("--tropo requires METEO RINEX"); + } + if matches.get_flag("ionod") { + plot_ionospheric_delay(&ctx.data, plot_ctx); + } + if matches.get_flag("tec") { + let ionex = ctx.data.ionex_data().expect("--tec required IONEX"); + plot_tec_map(ionex, ((0.0_f64, 0.0_f64), (0.0_f64, 0.0_f64)), plot_ctx); + } +} diff --git a/rinex-cli/src/plot/record/navigation.rs b/rinex-cli/src/graph/record/navigation.rs similarity index 96% rename from rinex-cli/src/plot/record/navigation.rs rename to rinex-cli/src/graph/record/navigation.rs index 9d534d877..574cf700f 100644 --- a/rinex-cli/src/plot/record/navigation.rs +++ b/rinex-cli/src/graph/record/navigation.rs @@ -1,9 +1,9 @@ -use crate::plot::{build_3d_chart_epoch_label, build_chart_epoch_axis, PlotContext}; +use crate::graph::{build_3d_chart_epoch_label, build_chart_epoch_axis, PlotContext}; use plotly::common::{Mode, Visible}; use rinex::navigation::Ephemeris; use rinex::prelude::*; -pub fn plot_navigation(ctx: &RnxContext, plot_ctx: &mut PlotContext) { +pub fn plot_sv_nav_clock(ctx: &RnxContext, plot_ctx: &mut PlotContext) { let mut clock_plot_created = false; if let Some(nav) = ctx.nav_data() { /* @@ -152,9 +152,64 @@ pub fn plot_navigation(ctx: &RnxContext, plot_ctx: &mut PlotContext) { } } /* - * Plot Broadcast Orbit (x, y, z) + * Plot BRDC Clock correction */ + if let Some(navdata) = ctx.nav_data() { + if let Some(obsdata) = ctx.obs_data() { + for (sv_index, sv) in obsdata.sv().enumerate() { + if sv_index == 0 { + plot_ctx.add_timedomain_plot("SV Clock Correction", "Correction [s]"); + trace!("brdc clock correction plot"); + } + let epochs: Vec<_> = obsdata + .observation() + .filter_map(|((t, flag), (_, vehicles))| { + if flag.is_ok() && vehicles.contains_key(&sv) { + Some(*t) + } else { + None + } + }) + .collect(); + let clock_corr: Vec<_> = obsdata + .observation() + .filter_map(|((t, flag), (_, _vehicles))| { + if flag.is_ok() { + let (toe, sv_eph) = navdata.sv_ephemeris(sv, *t)?; + /* + * TODO prefer SP3 (if any) + */ + let clock_state = sv_eph.sv_clock(); + let clock_corr = Ephemeris::sv_clock_corr(sv, clock_state, *t, toe); + Some(clock_corr.to_seconds()) + } else { + None + } + }) + .collect(); + + let trace = + build_chart_epoch_axis(&format!("{}", sv), Mode::Markers, epochs, clock_corr) + .visible({ + if sv_index < 3 { + Visible::True + } else { + Visible::LegendOnly + } + }); + plot_ctx.add_trace(trace); + } + } else { + warn!("cannot plot brdc clock correction: needs OBS RINEX"); + } + } +} + +pub fn plot_sv_nav_orbits(ctx: &RnxContext, plot_ctx: &mut PlotContext) { let mut pos_plot_created = false; + /* + * Plot Broadcast Orbit (x, y, z) + */ if let Some(nav) = ctx.nav_data() { for (sv_index, sv) in nav.sv().enumerate() { if sv_index == 0 { @@ -305,54 +360,4 @@ pub fn plot_navigation(ctx: &RnxContext, plot_ctx: &mut PlotContext) { plot_ctx.add_trace(trace); } } - /* - * Plot BRDC Clock correction - */ - if let Some(navdata) = ctx.nav_data() { - if let Some(obsdata) = ctx.obs_data() { - for (sv_index, sv) in obsdata.sv().enumerate() { - if sv_index == 0 { - plot_ctx.add_timedomain_plot("SV Clock Correction", "Correction [s]"); - trace!("sv clock correction plot"); - } - let epochs: Vec<_> = obsdata - .observation() - .filter_map(|((t, flag), (_, vehicles))| { - if flag.is_ok() && vehicles.contains_key(&sv) { - Some(*t) - } else { - None - } - }) - .collect(); - let clock_corr: Vec<_> = obsdata - .observation() - .filter_map(|((t, flag), (_, _vehicles))| { - if flag.is_ok() { - let (toe, sv_eph) = navdata.sv_ephemeris(sv, *t)?; - /* - * TODO prefer SP3 (if any) - */ - let clock_state = sv_eph.sv_clock(); - let clock_corr = Ephemeris::sv_clock_corr(sv, clock_state, *t, toe); - Some(clock_corr.to_seconds()) - } else { - None - } - }) - .collect(); - - let trace = - build_chart_epoch_axis(&format!("{}", sv), Mode::Markers, epochs, clock_corr) - .visible({ - if sv_index < 3 { - Visible::True - } else { - Visible::LegendOnly - } - }); - plot_ctx.add_trace(trace); - } - } - } } diff --git a/rinex-cli/src/plot/record/observation.rs b/rinex-cli/src/graph/record/observation.rs similarity index 97% rename from rinex-cli/src/plot/record/observation.rs rename to rinex-cli/src/graph/record/observation.rs index 5de7eb8b3..fbe804c9f 100644 --- a/rinex-cli/src/plot/record/observation.rs +++ b/rinex-cli/src/graph/record/observation.rs @@ -1,4 +1,4 @@ -use crate::plot::{build_chart_epoch_axis, generate_markers, PlotContext}; +use crate::graph::{build_chart_epoch_axis, generate_markers, PlotContext}; use plotly::common::{Marker, MarkerSymbol, Mode, Visible}; use rinex::prelude::RnxContext; use rinex::{observation::*, prelude::*}; @@ -19,7 +19,7 @@ fn observable_to_physics(observable: &Observable) -> String { /* * Plots given Observation RINEX content */ -pub fn plot_observation(ctx: &RnxContext, plot_context: &mut PlotContext) { +pub fn plot_observations(ctx: &RnxContext, plot_context: &mut PlotContext) { let record = ctx .obs_data() .unwrap() // infaillible diff --git a/rinex-cli/src/graph/record/sp3_plot.rs b/rinex-cli/src/graph/record/sp3_plot.rs new file mode 100644 index 000000000..dfcedc179 --- /dev/null +++ b/rinex-cli/src/graph/record/sp3_plot.rs @@ -0,0 +1,172 @@ +use crate::graph::{build_chart_epoch_axis, PlotContext}; +use plotly::common::{Mode, Visible}; //Marker, MarkerSymbol +use rinex::prelude::Epoch; +use rinex::prelude::RnxContext; +use rinex::prelude::SV; +use std::collections::HashMap; + +/* + * Advanced NAV feature + * compares residual error between broadcast ephemeris + * and SP3 high precision orbits + */ +pub fn plot_residual_ephemeris(ctx: &RnxContext, plot_ctx: &mut PlotContext) { + let sp3 = ctx + .sp3_data() // cannot fail at this point + .unwrap(); + let nav = ctx + .nav_data() // cannot fail at this point + .unwrap(); + /* + * we need at least a small common time frame, + * otherwise analysis is not feasible + */ + let mut feasible = false; + if let (Some(first_sp3), Some(last_sp3)) = (sp3.first_epoch(), sp3.last_epoch()) { + if let (Some(first_nav), Some(last_nav)) = (nav.first_epoch(), nav.last_epoch()) { + feasible |= (first_nav >= first_sp3) || (first_nav <= last_sp3); + feasible |= (last_nav >= first_sp3) || (last_nav <= last_sp3); + } + } + + if !feasible { + warn!("|sp3-nav| residual analysis not feasible due to non common time frame"); + return; + } + + let mut residuals: HashMap, Vec)> = HashMap::new(); + + for (t, nav_sv, (x_km, y_km, z_km)) in nav.sv_position() { + if let Some((_, _, (sp3_x, sp3_y, sp3_z))) = sp3 + .sv_position() + .find(|(e_sp3, sv_sp3, (_, _, _))| *e_sp3 == t && *sv_sp3 == nav_sv) + { + /* no need to interpolate => use right away */ + if let Some((residuals, epochs)) = residuals.get_mut(&nav_sv) { + residuals.push(( + (x_km - sp3_x) / 1.0E3, + (y_km - sp3_y) / 1.0E3, + (z_km - sp3_z) / 1.0E3, + )); + epochs.push(t); + } else { + residuals.insert( + nav_sv, + ( + vec![( + (x_km - sp3_x) / 1.0E3, + (y_km - sp3_y) / 1.0E3, + (z_km - sp3_z) / 1.0E3, + )], + vec![t], + ), + ); + } + } else { + /* needs interpolation */ + if let Some((sp3_x, sp3_y, sp3_z)) = sp3.sv_position_interpolate(nav_sv, t, 11) { + if let Some((residuals, epochs)) = residuals.get_mut(&nav_sv) { + residuals.push(( + (x_km - sp3_x) / 1.0E3, + (y_km - sp3_y) / 1.0E3, + (z_km - sp3_z) / 1.0E3, + )); + epochs.push(t); + } else { + residuals.insert( + nav_sv, + ( + vec![( + (x_km - sp3_x) / 1.0E3, + (y_km - sp3_y) / 1.0E3, + (z_km - sp3_z) / 1.0E3, + )], + vec![t], + ), + ); + } + } + } + } + /* + * Plot x residuals + */ + for (sv_index, (sv, (residuals, epochs))) in residuals.iter().enumerate() { + if sv_index == 0 { + plot_ctx.add_timedomain_plot( + "Broadast Vs SP3 (Post Processed) Residual X errors", + "x_err [m]", + ); + trace!("|sp3 - broadcast| residual x error"); + } + + let trace = build_chart_epoch_axis( + &format!("{:X}", sv), + Mode::Markers, + epochs.to_vec(), + residuals.iter().map(|(x, _, _)| *x).collect::>(), + ) + .visible({ + if sv_index < 4 { + Visible::True + } else { + Visible::LegendOnly + } + }); + plot_ctx.add_trace(trace); + } + /* + * plot y residuals + */ + for (sv_index, (sv, (residuals, epochs))) in residuals.iter().enumerate() { + if sv_index == 0 { + plot_ctx.add_timedomain_plot( + "Broadast Vs SP3 (Post Processed) Residual Y errors", + "y_err [m]", + ); + trace!("|sp3 - broadcast| residual y error"); + } + + let trace = build_chart_epoch_axis( + &format!("{:X}", sv), + Mode::Markers, + epochs.to_vec(), + residuals.iter().map(|(_, y, _)| *y).collect::>(), + ) + .visible({ + if sv_index < 4 { + Visible::True + } else { + Visible::LegendOnly + } + }); + plot_ctx.add_trace(trace); + } + /* + * plot z residuals + */ + for (sv_index, (sv, (residuals, epochs))) in residuals.iter().enumerate() { + if sv_index == 0 { + plot_ctx.add_timedomain_plot( + "Broadast Vs SP3 (Post Processed) Residual Z errors", + "z_err [m]", + ); + trace!("|sp3 - broadcast| residual z error"); + } + + let trace = build_chart_epoch_axis( + &format!("{:X}", sv), + Mode::Markers, + epochs.to_vec(), + residuals.iter().map(|(_, _, z)| *z).collect::>(), + ) + .visible({ + if sv_index < 4 { + Visible::True + } else { + Visible::LegendOnly + } + }); + plot_ctx.add_trace(trace); + } +} diff --git a/rinex-cli/src/graph/skyplot.rs b/rinex-cli/src/graph/skyplot.rs new file mode 100644 index 000000000..37492d96d --- /dev/null +++ b/rinex-cli/src/graph/skyplot.rs @@ -0,0 +1,56 @@ +use crate::graph::PlotContext; +use plotly::{ + common::{Mode, Visible}, + ScatterPolar, +}; +use rinex::prelude::{Epoch, GroundPosition, RnxContext}; + +/* + * Skyplot view + */ +pub fn skyplot(ctx: &RnxContext, rx_ecef: (f64, f64, f64), plot_context: &mut PlotContext) { + plot_context.add_polar2d_plot("Skyplot"); + + if let Some(rnx) = ctx.nav_data() { + for (svnn_index, svnn) in rnx.sv().enumerate() { + // per sv + // grab related elevation data + // Rho = degrees(elev) + // Theta = degrees(azim) + let data: Vec<(Epoch, f64, f64)> = rnx + .sv_elevation_azimuth(Some(GroundPosition::from_ecef_wgs84(rx_ecef))) + .filter_map(|(epoch, sv, (elev, azi))| { + if sv == svnn { + let rho = elev; + let theta = azi; + Some((epoch, rho, theta)) + } else { + None + } + }) + .collect(); + + let rho: Vec = data.iter().map(|(_e, rho, _theta)| *rho).collect(); + let theta: Vec = data.iter().map(|(_e, _rho, theta)| *theta).collect(); + + //TODO: color gradient to emphasize day course + let trace = ScatterPolar::new(theta, rho) + .mode(Mode::LinesMarkers) + .web_gl_mode(true) + .visible({ + /* + * Plot only first few dataset, + * to improve performance when opening plots + */ + if svnn_index < 4 { + Visible::True + } else { + Visible::LegendOnly + } + }) + .connect_gaps(false) + .name(format!("{:X}", svnn)); + plot_context.add_trace(trace); + } + } +} diff --git a/rinex-cli/src/identification.rs b/rinex-cli/src/identification.rs index e1207f5fa..2082a2454 100644 --- a/rinex-cli/src/identification.rs +++ b/rinex-cli/src/identification.rs @@ -1,93 +1,209 @@ -use crate::Cli; -use std::str::FromStr; +use clap::ArgMatches; + +use rinex::{ + observation::SNR, + prelude::{Constellation, Epoch, Observable, Rinex, RnxContext}, + preprocessing::*, +}; -use rinex::observation::SNR; -use rinex::prelude::RnxContext; -use rinex::preprocessing::*; -use rinex::*; -use sp3::SP3; +use std::str::FromStr; use itertools::Itertools; use serde::Serialize; use std::collections::HashMap; -use hifitime::Duration; +use map_3d::{ecef2geodetic, Ellipsoid}; /* - * Basic identification operations + * Dataset identification operations */ -pub fn rinex_identification(ctx: &RnxContext, cli: &Cli) { - let ops = cli.identification_ops(); - let pretty_json = cli.pretty_json(); +pub fn dataset_identification(ctx: &RnxContext, matches: &ArgMatches) { /* - * Run identification on all contained files + * Browse all possible types of data, and apply relevant ID operation */ + if let Some(files) = ctx.obs_paths() { + let files = files + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().to_string()) + .collect::>(); + println!("\n%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"); + println!("%%%%%%%%%%%% Observation Data %%%%%%%%%"); + println!("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"); + println!("{:?}", files); + } if let Some(data) = ctx.obs_data() { - info!("obs. data identification"); - identification( - data, - &format!( - "{:?}", - ctx.obs_paths() - .unwrap() - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect::>() - ), - pretty_json, - ops.clone(), - ); + if matches.get_flag("all") || matches.get_flag("epochs") { + println!("{:#?}", EpochReport::from_data(data)); + } + if matches.get_flag("all") || matches.get_flag("gnss") { + let constel = data + .constellation() + .sorted() + .map(|c| format!("{:X}", c)) + .collect::>(); + println!("Constellations: {:?}", constel); + } + if matches.get_flag("all") || matches.get_flag("sv") { + let sv = data + .sv() + .sorted() + .map(|sv| format!("{:X}", sv)) + .collect::>(); + println!("SV: {:?}", sv); + } + if matches.get_flag("all") || matches.get_flag("observables") { + let observables = data + .observable() + .sorted() + .map(|obs| obs.to_string()) + .collect::>(); + println!("Observables: {:?}", observables); + } + if matches.get_flag("all") || matches.get_flag("snr") { + let report = SNRReport::from_data(data); + println!("SNR: {:#?}", report); + } + if matches.get_flag("all") || matches.get_flag("anomalies") { + let anomalies = data.epoch_anomalies().collect::>(); + if anomalies.is_empty() { + println!("No anomalies reported."); + } else { + println!("Anomalies: {:#?}", anomalies); + } + } } - if let Some(nav) = &ctx.nav_data() { - info!("nav. data identification"); - identification( - nav, - &format!( - "{:?}", - ctx.nav_paths() - .unwrap() - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect::>() - ), - pretty_json, - ops.clone(), - ); + + if let Some(files) = ctx.meteo_paths() { + let files = files + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().to_string()) + .collect::>(); + println!("\n%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"); + println!("%%%%%%%%%%%% Meteo Data %%%%%%%%%"); + println!("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"); + println!("{:?}", files); } - if let Some(data) = &ctx.meteo_data() { - info!("meteo identification"); - identification( - data, - &format!( - "{:?}", - ctx.meteo_paths() - .unwrap() - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect::>() - ), - pretty_json, - ops.clone(), - ); + if let Some(data) = ctx.meteo_data() { + if matches.get_flag("all") || matches.get_flag("epochs") { + println!("{:#?}", EpochReport::from_data(data)); + } + if matches.get_flag("all") || matches.get_flag("observables") { + let observables = data + .observable() + .sorted() + .map(|obs| obs.to_string()) + .collect::>(); + println!("Observables: {:?}", observables); + } + if let Some(header) = &data.header.meteo { + for sensor in &header.sensors { + println!("{} sensor: ", sensor.observable); + if let Some(model) = &sensor.model { + println!("model: \"{}\"", model); + } + if let Some(sensor_type) = &sensor.sensor_type { + println!("type: \"{}\"", sensor_type); + } + if let Some(ecef) = &sensor.position { + let (lat, lon, alt) = ecef2geodetic(ecef.0, ecef.1, ecef.2, Ellipsoid::WGS84); + if !lat.is_nan() && !lon.is_nan() { + println!("coordinates: lat={}°, lon={}°", lat, lon); + } + if alt.is_nan() { + println!("altitude above sea: {}m", ecef.3); + } else { + println!("altitude above sea: {}m", alt + ecef.3); + } + } + } + } + } + + if let Some(files) = ctx.nav_paths() { + let files = files + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().to_string()) + .collect::>(); + println!("\n%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"); + println!("%%%%%%%%%%%% Navigation Data (BRDC) %%%%%%%%%"); + println!("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"); + println!("{:?}", files); } - if let Some(data) = &ctx.ionex_data() { - info!("ionex identification"); - identification( - data, - &format!( - "{:?}", - ctx.ionex_paths() - .unwrap() - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect::>() - ), - pretty_json, - ops.clone(), - ); + if let Some(data) = ctx.nav_data() { + if matches.get_flag("all") || matches.get_flag("nav-msg") { + let msg = data.nav_msg_type().collect::>(); + println!("BRDC NAV Messages: {:?}", msg); + } + println!("BRDC Ephemerides: "); + let ephemerides = data.filter(Filter::from_str("EPH").unwrap()); + if matches.get_flag("all") || matches.get_flag("epochs") { + println!("{:#?}", EpochReport::from_data(data)); + } + if matches.get_flag("all") || matches.get_flag("gnss") { + let constel = ephemerides + .constellation() + .sorted() + .map(|c| format!("{:X}", c)) + .collect::>(); + println!("Constellations: {:?}", constel); + } + if matches.get_flag("all") || matches.get_flag("sv") { + let sv = ephemerides + .sv() + .sorted() + .map(|sv| format!("{:X}", sv)) + .collect::>(); + println!("SV: {:?}", sv); + } } - if let Some(sp3) = ctx.sp3_data() { - sp3_identification(sp3); + + if let Some(files) = ctx.sp3_paths() { + let files = files + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().to_string()) + .collect::>(); + println!("\n%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"); + println!("%%%%%%%%%%%% Precise Orbits (SP3) %%%%%%%%%"); + println!("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"); + println!("{:?}", files); + } + if let Some(data) = ctx.sp3_data() { + println!("SP3 orbits: "); + if matches.get_flag("all") || matches.get_flag("epochs") { + let report = EpochReport { + first: match data.first_epoch() { + Some(first) => first.to_string(), + None => "Undefined".to_string(), + }, + last: match data.last_epoch() { + Some(last) => last.to_string(), + None => "Undefined".to_string(), + }, + sampling: { + [( + format!("dt={}s", data.epoch_interval.to_seconds()), + data.nb_epochs(), + )] + .into() + }, + system: { + if let Some(system) = data.constellation.timescale() { + system.to_string() + } else { + "Undefined".to_string() + } + }, + }; + println!("{:#?}", report); + } + if matches.get_flag("all") || matches.get_flag("sv") { + let sv = data + .sv() + .sorted() + .map(|sv| format!("{:X}", sv)) + .collect::>(); + println!("SV: {:?}", sv); + } } } @@ -95,140 +211,73 @@ pub fn rinex_identification(ctx: &RnxContext, cli: &Cli) { struct EpochReport { pub first: String, pub last: String, + pub system: String, + pub sampling: HashMap, } -#[derive(Clone, Debug, Serialize)] -struct SSIReport { - pub min: Option, - pub max: Option, -} - -fn report_sampling_histogram(data: &Vec<(Duration, usize)>) { - let data: HashMap = data - .iter() - .map(|(dt, pop)| (dt.to_string(), *pop)) - .collect(); - println!("{:#?}", data); -} - -/* - * Method dedicated to sampling "identification" - */ -fn sampling_identification(rnx: &Rinex) { - if rnx.is_navigation_rinex() { - /* - * with NAV RINEX, we're interested in - * differentiating the BRDC NAV/ION and basically all messages time frames - */ - let data: Vec<(Duration, usize)> = rnx - .filter(Filter::from_str("EPH").unwrap()) - .sampling_histogram() - .collect(); - println!("BRDC ephemeris:"); - report_sampling_histogram(&data); - } else { - // Other RINEX types: run sampling histogram analysis - let data: Vec<(Duration, usize)> = rnx.sampling_histogram().collect(); - report_sampling_histogram(&data); - } -} - -fn identification(rnx: &Rinex, path: &str, pretty_json: bool, ops: Vec<&str>) { - for op in ops { - debug!("identification: {}", op); - if op.eq("header") { - let content = match pretty_json { - true => serde_json::to_string_pretty(&rnx.header).unwrap(), - false => serde_json::to_string(&rnx.header).unwrap(), - }; - println!("[{}]: {}", path, content); - } else if op.eq("epochs") { - let report = EpochReport { - first: format!("{:?}", rnx.first_epoch()), - last: format!("{:?}", rnx.last_epoch()), - }; - let content = match pretty_json { - true => serde_json::to_string_pretty(&report).unwrap(), - false => serde_json::to_string(&report).unwrap(), - }; - println!("[{}]: {}", path, content); - } else if op.eq("sv") && (rnx.is_observation_rinex() || rnx.is_navigation_rinex()) { - let mut csv = String::new(); - for (i, sv) in rnx.sv().sorted().enumerate() { - if i == rnx.sv().count() - 1 { - csv.push_str(&format!("{}\n", sv)); +impl EpochReport { + fn from_data(data: &Rinex) -> Self { + let first_epoch = data.first_epoch(); + Self { + first: { + if let Some(first) = first_epoch { + first.to_string() } else { - csv.push_str(&format!("{}, ", sv)); + "NONE".to_string() } - } - println!("[{}]: {}", path, csv); - } else if op.eq("observables") && rnx.is_observation_rinex() { - let mut data: Vec<_> = rnx.observable().collect(); - data.sort(); - let content = match pretty_json { - true => serde_json::to_string_pretty(&data).unwrap(), - false => serde_json::to_string(&data).unwrap(), - }; - println!("[{}]: {}", path, content); - } else if op.eq("gnss") && (rnx.is_observation_rinex() || rnx.is_navigation_rinex()) { - let mut data: Vec<_> = rnx.constellation().collect(); - data.sort(); - let content = match pretty_json { - true => serde_json::to_string_pretty(&data).unwrap(), - false => serde_json::to_string(&data).unwrap(), - }; - println!("[{}]: {}", path, content); - } else if op.eq("ssi-range") && rnx.is_observation_rinex() { - let ssi = SSIReport { - min: { - rnx.snr() - .min_by(|(_, _, _, snr_a), (_, _, _, snr_b)| snr_a.cmp(snr_b)) - .map(|(_, _, _, snr)| snr) - }, - max: { - rnx.snr() - .max_by(|(_, _, _, snr_a), (_, _, _, snr_b)| snr_a.cmp(snr_b)) - .map(|(_, _, _, snr)| snr) - }, - }; - let content = match pretty_json { - true => serde_json::to_string_pretty(&ssi).unwrap(), - false => serde_json::to_string(&ssi).unwrap(), - }; - println!("[{}]: {}", path, content); - } else if op.eq("orbits") && rnx.is_navigation_rinex() { - error!("nav::orbits not available yet"); - //let data: Vec<_> = rnx.orbit_fields(); - //let content = match pretty_json { - // true => serde_json::to_string_pretty(&data).unwrap(), - // false => serde_json::to_string(&data).unwrap(), - //}; - //println!("{}", content); - } else if op.eq("nav-msg") && rnx.is_navigation_rinex() { - let data: Vec<_> = rnx.nav_msg_type().collect(); - println!("{:?}", data); - } else if op.eq("anomalies") && rnx.is_observation_rinex() { - let data: Vec<_> = rnx.epoch_anomalies().collect(); - println!("{:#?}", data); - } else if op.eq("sampling") { - sampling_identification(rnx); + }, + last: { + if let Some(last) = data.last_epoch() { + last.to_string() + } else { + "NONE".to_string() + } + }, + sampling: { + data.sampling_histogram() + .map(|(dt, pop)| (format!("dt={}s", dt.to_seconds()), pop)) + .collect() + }, + system: { + if data.is_observation_rinex() || data.is_meteo_rinex() { + if let Some(first) = first_epoch { + first.time_scale.to_string() + } else { + "Undefined".to_string() + } + } else if data.is_navigation_rinex() { + match data.header.constellation { + Some(Constellation::Mixed) => "Mixed".to_string(), + Some(c) => c.timescale().unwrap().to_string(), + None => "Undefined".to_string(), + } + } else { + "Undefined".to_string() + } + }, } } } -fn sp3_identification(sp3: &SP3) { - let report = format!( - "SP3 IDENTIFICATION -Sampling period: {:?}, -NB of epochs: {}, -Time frame: {:?} - {:?}, -SV: {:?} -", - sp3.epoch_interval, - sp3.nb_epochs(), - sp3.first_epoch(), - sp3.last_epoch(), - sp3.sv().map(|sv| sv.to_string()).collect::>() - ); - println!("{}", report); +#[derive(Clone, Debug, Serialize)] +struct SNRReport { + pub worst: Option<(Epoch, String, Observable, SNR)>, + pub best: Option<(Epoch, String, Observable, SNR)>, +} + +impl SNRReport { + fn from_data(data: &Rinex) -> Self { + Self { + worst: { + data.snr() + .min_by(|(_, _, _, snr_a), (_, _, _, snr_b)| snr_a.cmp(snr_b)) + .map(|((t, _), sv, obs, snr)| (t, sv.to_string(), obs.clone(), snr)) + }, + best: { + data.snr() + .max_by(|(_, _, _, snr_a), (_, _, _, snr_b)| snr_a.cmp(snr_b)) + .map(|((t, _), sv, obs, snr)| (t, sv.to_string(), obs.clone(), snr)) + }, + } + } } diff --git a/rinex-cli/src/main.rs b/rinex-cli/src/main.rs index 8876a40bc..424f7cea2 100644 --- a/rinex-cli/src/main.rs +++ b/rinex-cli/src/main.rs @@ -4,224 +4,43 @@ mod analysis; // basic analysis mod cli; // command line interface -pub mod fops; // file operation helpers +mod fops; +mod graph; mod identification; // high level identification/macros -mod plot; // plotting operations +mod positioning; +mod qc; // QC report generator // plotting operations // file operation helpers // graphical analysis // positioning + CGGTTS opmode mod preprocessing; use preprocessing::preprocess; -mod positioning; - -//use horrorshow::Template; -use rinex::{ - merge::Merge, - observation::{Combination, Combine}, - prelude::*, - split::Split, -}; - -use rinex_qc::*; - extern crate gnss_rs as gnss; extern crate gnss_rtk as rtk; -use cli::Cli; -use identification::rinex_identification; -use plot::PlotContext; +use cli::{Cli, Context}; -//extern crate pretty_env_logger; use env_logger::{Builder, Target}; #[macro_use] extern crate log; -use fops::open_with_web_browser; -use std::io::Write; -use std::path::{Path, PathBuf}; - use thiserror::Error; #[derive(Debug, Error)] pub enum Error { #[error("rinex error")] RinexError(#[from] rinex::Error), + #[error("missing OBS RINEX")] + MissingObservationRinex, + #[error("missing (BRDC) NAV RINEX")] + MissingNavigationRinex, + #[error("merge ops failure")] + MergeError(#[from] rinex::merge::Error), + #[error("split ops failure")] + SplitError(#[from] rinex::split::Error), + #[error("failed to create QC report: permission denied!")] + QcReportCreationError, #[error("positioning solver error")] - PositioningSolverError(#[from] positioning::solver::Error), - #[error("post processing error")] - PositioningPostProcError(#[from] positioning::post_process::Error), -} - -/* - * Utility : determines the file stem of most major RINEX file in the context - */ -pub(crate) fn context_stem(ctx: &RnxContext) -> String { - let ctx_major_stem: &str = ctx - .rinex_path() - .expect("failed to determine a context name") - .file_stem() - .expect("failed to determine a context name") - .to_str() - .expect("failed to determine a context name"); - /* - * In case $FILENAME.RNX.gz gz compressed, we extract "$FILENAME". - * Can use .file_name() once https://github.com/rust-lang/rust/issues/86319 is stabilized - */ - let primary_stem: Vec<&str> = ctx_major_stem.split('.').collect(); - primary_stem[0].to_string() -} - -/* - * Workspace location is fixed to rinex-cli/product/$primary - * at the moment - */ -pub fn workspace_path(ctx: &RnxContext, cli: &Cli) -> PathBuf { - match cli.workspace() { - Some(w) => Path::new(w).join(context_stem(ctx)), - None => Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("WORKSPACE") - .join(context_stem(ctx)), - } -} - -/* - * Helper to create the workspace, ie.: where all reports - * get generated and saved. - */ -pub fn create_workspace(path: PathBuf) { - std::fs::create_dir_all(&path).unwrap_or_else(|_| { - panic!( - "failed to create Workspace \"{}\": permission denied!", - path.to_string_lossy() - ) - }); -} - -use walkdir::WalkDir; - -/* - * Creates File/Data context defined by user. - * Regroups all provided files/folders, - */ -fn build_context(cli: &Cli) -> RnxContext { - let mut ctx = RnxContext::default(); - /* load all directories recursively, one by one */ - for dir in cli.input_directories() { - let walkdir = WalkDir::new(dir).max_depth(5); - 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 = ctx.load(&filepath); - if ret.is_err() { - warn!("failed to load \"{}\": {}", filepath, ret.err().unwrap()); - } - } - } - } - // load individual files, if any - for filepath in cli.input_files() { - let ret = ctx.load(filepath); - if ret.is_err() { - warn!("failed to load \"{}\": {}", filepath, ret.err().unwrap()); - } - } - ctx -} - -/* - * Returns true if Skyplot view if feasible and allowed - */ -fn skyplot_allowed(ctx: &RnxContext, cli: &Cli) -> bool { - if cli.quality_check_only() || cli.positioning() { - /* - * Special modes: no plots allowed - */ - return false; - } - - let has_nav = ctx.has_navigation_data(); - let has_ref_position = ctx.ground_position().is_some() || cli.manual_position().is_some(); - if has_nav && !has_ref_position { - info!("missing a reference position for the skyplot view."); - info!("see rinex-cli -h : antenna positions."); - } - - has_nav && has_ref_position -} - -/* - * Returns true if NAVI plot is both feasible and allowed - */ -fn naviplot_allowed(ctx: &RnxContext, cli: &Cli) -> bool { - // TODO: this need to change once RnxContext gets improved - skyplot_allowed(ctx, cli) -} - -/* - * Plots requested combinations - */ -fn plot_combinations(obs: &Rinex, cli: &Cli, plot_ctx: &mut PlotContext) { - //if cli.dcb() { - // let data = obs.dcb(); - // plot::plot_gnss_dcb( - // plot_ctx, - // "Differential Code Biases", - // "Differential Code Bias [s]", - // &data, - // ); - // info!("dcb analysis"); - //} - if cli.multipath() { - let data = obs.code_multipath(); - plot::plot_gnss_dcb_mp(&data, plot_ctx, "Code Multipath", "Meters of delay"); - info!("code multipath analysis"); - } - if cli.if_combination() { - let data = obs.combine(Combination::IonosphereFree); - plot::plot_gnss_combination( - &data, - plot_ctx, - "Ionosphere Free combination", - "Meters of delay", - ); - info!("iono free combination"); - } - if cli.gf_combination() { - let data = obs.combine(Combination::GeometryFree); - plot::plot_gnss_combination( - &data, - plot_ctx, - "Geometry Free combination", - "Meters of delay", - ); - info!("geo free combination"); - } - if cli.wl_combination() { - let data = obs.combine(Combination::WideLane); - plot::plot_gnss_combination(&data, plot_ctx, "Wide Lane combination", "Meters of delay"); - info!("wide lane combination"); - } - if cli.nl_combination() { - let data = obs.combine(Combination::NarrowLane); - plot::plot_gnss_combination( - &data, - plot_ctx, - "Narrow Lane combination", - "Meters of delay", - ); - info!("wide lane combination"); - } - if cli.mw_combination() { - let data = obs.combine(Combination::MelbourneWubbena); - plot::plot_gnss_combination( - &data, - plot_ctx, - "Melbourne-Wübbena signal combination", - "Meters of Li-Lj delay", - ); - info!("melbourne-wubbena combination"); - } + PositioningSolverError(#[from] positioning::Error), } pub fn main() -> Result<(), Error> { @@ -232,266 +51,44 @@ pub fn main() -> Result<(), Error> { .format_module_path(false) .init(); - // Cli + // Build context defined by user let cli = Cli::new(); - let quiet = cli.quiet(); - let no_graph = cli.no_graph(); - - let qc_only = cli.quality_check_only(); - let qc = cli.quality_check() || qc_only; - - let positioning = cli.positioning(); - - // Initiate plot context - let mut plot_ctx = PlotContext::new(); - - // Initiate QC parameters - let mut qc_opts = cli.qc_config(); - - // Build context - let mut ctx = build_context(&cli); - - // Workspace - let workspace = workspace_path(&ctx, &cli); - info!("workspace is \"{}\"", workspace.to_string_lossy()); - create_workspace(workspace.clone()); - - /* - * Print more info on provided context - */ - if ctx.obs_data().is_some() { - info!("observation data loaded"); - } - if ctx.nav_data().is_some() { - info!("brdc navigation data loaded"); - } - if ctx.sp3_data().is_some() { - info!("sp3 data loaded"); - } - if ctx.meteo_data().is_some() { - info!("meteo data loaded"); - } - if ctx.ionex_data().is_some() { - info!("ionex data loaded"); - } + let mut ctx = Context::from_cli(&cli)?; - /* - * Emphasize which reference position is to be used. - * This will help user make sure everything is correct. - * [+] Cli: always superceeds - * [+] then QC config file is prefered (advanced usage) - * [+] eventually we rely on the context pool. - * Missing ref. position may restrict possible operations. - */ - if let Some(pos) = cli.manual_position() { - let (lat, lon, _) = pos.to_geodetic(); - info!( - "using manually defined reference position {} (lat={:.5}°, lon={:.5}°)", - pos, lat, lon - ); - } else if let Some(pos) = ctx.ground_position() { - let (lat, lon, _) = pos.to_geodetic(); - info!( - "using reference position {} (lat={:.5}°, lon={:.5}°)", - pos, lat, lon - ); - } else { - info!("no reference position given or identified"); - } /* * Preprocessing */ - preprocess(&mut ctx, &cli); - /* - * Basic file identification - */ - if cli.identification() { - rinex_identification(&ctx, &cli); - return Ok(()); // not proceeding further, in this mode - } - /* - * plot combinations (if any) - */ - if !no_graph { - if let Some(obs) = ctx.obs_data() { - plot_combinations(obs, &cli, &mut plot_ctx); - } else { - error!("GNSS combinations requires Observation Data"); - } - } - /* - * MERGE - */ - if let Some(rinex_b) = cli.to_merge() { - let rinex = ctx.rinex_data().expect("undefined RINEX data"); - - let new_rinex = rinex.merge(&rinex_b).expect("failed to merge both files"); - - let filename = match cli.output_path() { - Some(path) => path.clone(), - None => String::from("merged.rnx"), - }; - - let path = workspace.clone().join(filename); - - let path = path - .as_path() - .to_str() - .expect("failed to generate merged file"); - - // generate new file - new_rinex - .to_file(path) - .expect("failed to generate merged file"); - - info!("\"{}\" has been generated", &path); - return Ok(()); - } - /* - * SPLIT - */ - if let Some(epoch) = cli.split() { - let rinex = ctx.rinex_data().expect("undefined RINEX data"); - - let (rnx_a, rnx_b) = rinex - .split(epoch) - .expect("failed to split primary rinex file"); - - let file_suffix = rnx_a - .first_epoch() - .expect("failed to determine file suffix") - .to_string(); - - let path = format!( - "{}/{}-{}.txt", - workspace.to_string_lossy(), - context_stem(&ctx), - file_suffix - ); - - rnx_a - .to_file(&path) - .unwrap_or_else(|_| panic!("failed to generate splitted file \"{}\"", path)); - - let file_suffix = rnx_b - .first_epoch() - .expect("failed to determine file suffix") - .to_string(); - - let path = format!( - "{}/{}-{}.txt", - workspace.to_string_lossy(), - context_stem(&ctx), - file_suffix - ); - - rnx_b - .to_file(&path) - .unwrap_or_else(|_| panic!("failed to generate splitted file \"{}\"", path)); - - // [*] stop here, special mode: no further analysis allowed - return Ok(()); - } - /* - * skyplot - */ - if !no_graph { - if skyplot_allowed(&ctx, &cli) { - let nav = ctx.nav_data().unwrap(); // infaillble - let ground_pos = ctx.ground_position().unwrap(); // infaillible - plot::skyplot(nav, ground_pos, &mut plot_ctx); - info!("skyplot view generated"); - } else if !no_graph { - info!("skyplot view is not feasible"); - } - } - /* - * 3D NAVI plot - */ - if naviplot_allowed(&ctx, &cli) && !no_graph { - plot::naviplot(&ctx, &mut plot_ctx); - info!("navi plot generated"); - } - /* - * CS Detector - */ - if cli.cs_graph() && !no_graph { - info!("cs detector"); - //let mut detector = CsDetector::default(); - //let cs = detector.cs_detection(&ctx.primary_rinex); - } - /* - * Record analysis / visualization - * analysis depends on the provided record type - */ - if !qc_only && !positioning && !no_graph { - info!("entering record analysis"); - plot::plot_record(&ctx, &mut plot_ctx); - - /* - * Render Graphs (HTML) - */ - let html_path = workspace_path(&ctx, &cli).join("graphs.html"); - let html_path = html_path.to_str().unwrap(); - - let mut html_fd = std::fs::File::create(html_path) - .unwrap_or_else(|_| panic!("failed to create \"{}\"", &html_path)); - write!(html_fd, "{}", plot_ctx.to_html()).expect("failed to render graphs"); + preprocess(&mut ctx.data, &cli); - info!("graphs rendered in $WORKSPACE/graphs.html"); - if !quiet { - open_with_web_browser(html_path); - } - } /* - * QC Mode + * Exclusive opmodes */ - if qc { - info!("entering qc mode"); - /* - * QC Config / versus command line - * let the possibility to define some parameters - * from the command line, instead of the config file. - */ - if qc_opts.ground_position.is_none() { - // config did not specify it - if let Some(pos) = cli.manual_position() { - // manually passed - qc_opts = qc_opts.with_ground_position_ecef(pos.to_ecef_wgs84()); - } - } - - /* - * Print some info about current setup & parameters, prior analysis. - */ - info!("Classification method : {:?}", qc_opts.classification); - info!("Reference position : {:?}", qc_opts.ground_position); - info!("Minimal SNR : {:?}", qc_opts.min_snr_db); - info!("Elevation mask : {:?}", qc_opts.elev_mask); - info!("Sampling gap tolerance: {:?}", qc_opts.gap_tolerance); - - let html_report = QcReport::html(&ctx, qc_opts); - - let report_path = workspace.join("report.html"); - let mut report_fd = std::fs::File::create(&report_path).unwrap_or_else(|_| { - panic!( - "failed to create report \"{}\" : permission denied", - report_path.to_string_lossy() - ) - }); - - write!(report_fd, "{}", html_report).expect("failed to generate QC summary report"); - - info!("qc report $WORSPACE/report.html has been generated"); - if !quiet { - open_with_web_browser(&report_path.to_string_lossy()); - } + match cli.matches.subcommand() { + Some(("graph", submatches)) => { + graph::graph_opmode(&ctx, submatches)?; + }, + Some(("identify", submatches)) => { + identification::dataset_identification(&ctx.data, submatches); + }, + Some(("merge", submatches)) => { + fops::merge(&ctx, submatches)?; + }, + Some(("split", submatches)) => { + fops::split(&ctx, submatches)?; + }, + Some(("quality-check", submatches)) => { + qc::qc_report(&ctx, submatches)?; + }, + Some(("positioning", submatches)) => { + positioning::precise_positioning(&ctx, submatches)?; + }, + Some(("sub", submatches)) => { + fops::substract(&ctx, submatches)?; + }, + Some(("tbin", submatches)) => { + fops::time_binning(&ctx, submatches)?; + }, + _ => error!("no opmode specified!"), } - - if positioning { - let results = positioning::solver(&mut ctx, &cli)?; - positioning::post_process(workspace, &cli, &ctx, results)?; - } - Ok(()) } // main diff --git a/rinex-cli/src/plot/record/mod.rs b/rinex-cli/src/plot/record/mod.rs deleted file mode 100644 index 9dccd44e9..000000000 --- a/rinex-cli/src/plot/record/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod ionex; -mod ionosphere; -mod meteo; -mod navigation; -mod observation; -mod sp3_plot; - -pub use ionex::plot_tec_map; -pub use ionosphere::plot_ionospheric_delay; -pub use meteo::plot_meteo; -pub use navigation::plot_navigation; -pub use observation::plot_observation; -pub use sp3_plot::plot_residual_ephemeris; diff --git a/rinex-cli/src/plot/record/sp3_plot.rs b/rinex-cli/src/plot/record/sp3_plot.rs deleted file mode 100644 index cb0018e33..000000000 --- a/rinex-cli/src/plot/record/sp3_plot.rs +++ /dev/null @@ -1,102 +0,0 @@ -use crate::plot::{build_3d_chart_epoch_label, PlotContext}; -use plotly::common::{Mode, Visible}; //Marker, MarkerSymbol -use rinex::prelude::Epoch; -use rinex::prelude::RnxContext; - -/* - * Advanced NAV feature - * compares residual error between broadcast ephemeris - * and SP3 high precision orbits - */ -pub fn plot_residual_ephemeris(ctx: &RnxContext, plot_ctx: &mut PlotContext) { - let sp3 = ctx - .sp3_data() // cannot fail at this point - .unwrap(); - let nav = ctx - .nav_data() // cannot fail at this point - .unwrap(); - /* - * we need at least a small common time frame, - * otherwise analysis is not feasible - */ - let mut feasible = false; - if let (Some(first_sp3), Some(last_sp3)) = (sp3.first_epoch(), sp3.last_epoch()) { - if let (Some(first_nav), Some(last_nav)) = (nav.first_epoch(), nav.last_epoch()) { - feasible |= (first_nav > first_sp3) && (first_nav < last_sp3); - feasible |= (last_nav > first_sp3) && (last_nav < last_sp3); - } - } - - if !feasible { - warn!("|sp3-nav| residual analysis not feasible due to non common time frame"); - return; - } - /* - * Position residual errors : [m] - * 1 trace (color) per SV - */ - for (sv_index, sv) in nav.sv().enumerate() { - if sv_index == 0 { - plot_ctx.add_cartesian3d_plot( - "Broadast / SP3 Residual Position Error", - "dx [m]", - "dy [m]", - "dz [m]", - ); - trace!("|sp3 - broadcast| residual (x, y, z) error"); - } - let sv_position = nav - .sv_position() - .filter_map(|(t, nav_sv, (x_km, y_km, z_km))| { - if sv == nav_sv { - Some((t, (x_km, y_km, z_km))) - } else { - None - } - }); - - let mut epochs: Vec = Vec::new(); - let mut residuals: Vec<(f64, f64, f64)> = Vec::new(); - - for (t, (sv_x, sv_y, sv_z)) in sv_position { - if let Some((_, _, (sp3_x, sp3_y, sp3_z))) = sp3 - .sv_position() - .find(|(e_sp3, sv_sp3, (_, _, _))| *e_sp3 == t && *sv_sp3 == sv) - { - /* no need to interpolate => use right away */ - epochs.push(t); - residuals.push(( - (sv_x - sp3_x) / 1.0E3, - (sv_y - sp3_y) / 1.0E3, - (sv_z - sp3_z) / 1.0E3, - )); - } else { - /* needs interpolation */ - if let Some((sp3_x, sp3_y, sp3_z)) = sp3.sv_position_interpolate(sv, t, 11) { - epochs.push(t); - residuals.push(( - (sv_x - sp3_x) / 1.0E3, - (sv_y - sp3_y) / 1.0E3, - (sv_z - sp3_z) / 1.0E3, - )); - } - } - } - let trace = build_3d_chart_epoch_label( - &format!("{:X}", sv), - Mode::Markers, - epochs, - residuals.iter().map(|(x, _, _)| *x).collect::>(), - residuals.iter().map(|(_, y, _)| *y).collect::>(), - residuals.iter().map(|(_, _, z)| *z).collect::>(), - ) - .visible({ - if sv_index < 4 { - Visible::True - } else { - Visible::LegendOnly - } - }); - plot_ctx.add_trace(trace); - } -} diff --git a/rinex-cli/src/plot/skyplot.rs b/rinex-cli/src/plot/skyplot.rs deleted file mode 100644 index 326942fd6..000000000 --- a/rinex-cli/src/plot/skyplot.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::plot::PlotContext; -use plotly::{ - common::{Mode, Visible}, - ScatterPolar, -}; -use rinex::prelude::{Epoch, GroundPosition, Rinex}; - -/* - * Skyplot view - */ -pub fn skyplot(rnx: &Rinex, ref_position: GroundPosition, plot_context: &mut PlotContext) { - plot_context.add_polar2d_plot("Skyplot"); - - for (svnn_index, svnn) in rnx.sv().enumerate() { - // per sv - // grab related elevation data - // Rho = degrees(elev) - // Theta = degrees(azim) - let data: Vec<(Epoch, f64, f64)> = rnx - .sv_elevation_azimuth(Some(ref_position)) - .filter_map(|(epoch, sv, (elev, azi))| { - if sv == svnn { - let rho = elev; - let theta = azi; - Some((epoch, rho, theta)) - } else { - None - } - }) - .collect(); - - let rho: Vec = data.iter().map(|(_e, rho, _theta)| *rho).collect(); - let theta: Vec = data.iter().map(|(_e, _rho, theta)| *theta).collect(); - - //TODO: color gradient to emphasize day course - let trace = ScatterPolar::new(theta, rho) - .mode(Mode::LinesMarkers) - .web_gl_mode(true) - .visible({ - /* - * Plot only first few dataset, - * to improve performance when opening plots - */ - if svnn_index < 4 { - Visible::True - } else { - Visible::LegendOnly - } - }) - .connect_gaps(false) - .name(format!("{:X}", svnn)); - plot_context.add_trace(trace); - } -} diff --git a/rnx2cggtts/src/solver.rs b/rinex-cli/src/positioning/cggtts/mod.rs similarity index 62% rename from rnx2cggtts/src/solver.rs rename to rinex-cli/src/positioning/cggtts/mod.rs index 38717ec59..723e3262b 100644 --- a/rnx2cggtts/src/solver.rs +++ b/rinex-cli/src/positioning/cggtts/mod.rs @@ -1,28 +1,21 @@ -use crate::Cli; +//! CGGTTS special resolution opmoode. +use clap::ArgMatches; use std::collections::HashMap; use std::str::FromStr; -use thiserror::Error; + +mod post_process; +pub use post_process::{post_process, Error as PostProcessingError}; use gnss::prelude::{Constellation, SV}; -use rinex::{ - carrier::Carrier, - navigation::Ephemeris, - prelude::{Observable, Rinex, RnxContext}, -}; +use rinex::{carrier::Carrier, navigation::Ephemeris, prelude::Observable}; use rtk::prelude::{ - AprioriPosition, - BdModel, Candidate, - Config, Duration, Epoch, InterpolationResult, IonosphericBias, - KbModel, - Method, - NgModel, Observation, PVTSolutionType, Solver, @@ -35,140 +28,10 @@ use cggtts::{ track::{FitData, GlonassChannel, SVTracker, Scheduler}, }; -//use statrs::statistics::Statistics; -use map_3d::{ecef2geodetic, Ellipsoid}; - -#[derive(Debug, Error)] -pub enum Error { - #[error("solver error")] - SolverError(#[from] rtk::Error), - #[error("missing observations")] - MissingObservationData, - #[error("missing brdc navigation")] - MissingBroadcastNavigationData, - #[error("undefined apriori position")] - UndefinedAprioriPosition, -} - -fn tropo_components(meteo: Option<&Rinex>, t: Epoch, lat_ddeg: f64) -> Option<(f64, f64)> { - const MAX_LATDDEG_DELTA: f64 = 15.0; - let max_dt = Duration::from_hours(24.0); - let rnx = meteo?; - let meteo = rnx.header.meteo.as_ref().unwrap(); - - let delays: Vec<(Observable, f64)> = meteo - .sensors - .iter() - .filter_map(|s| match s.observable { - Observable::ZenithDryDelay => { - let (x, y, z, _) = s.position?; - let (lat, _, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); - if (lat - lat_ddeg).abs() < MAX_LATDDEG_DELTA { - let value = rnx - .zenith_dry_delay() - .filter(|(t_sens, _)| (*t_sens - t).abs() < max_dt) - .min_by_key(|(t_sens, _)| (*t_sens - t).abs()); - let (_, value) = value?; - debug!("{:?} lat={} zdd {}", t, lat_ddeg, value); - Some((s.observable.clone(), value)) - } else { - None - } - }, - Observable::ZenithWetDelay => { - let (x, y, z, _) = s.position?; - let (lat, _, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); - if (lat - lat_ddeg).abs() < MAX_LATDDEG_DELTA { - let value = rnx - .zenith_wet_delay() - .filter(|(t_sens, _)| (*t_sens - t).abs() < max_dt) - .min_by_key(|(t_sens, _)| (*t_sens - t).abs()); - let (_, value) = value?; - debug!("{:?} lat={} zdd {}", t, lat_ddeg, value); - Some((s.observable.clone(), value)) - } else { - None - } - }, - _ => None, - }) - .collect(); - - if delays.len() < 2 { - None - } else { - let zdd = delays - .iter() - .filter_map(|(obs, value)| { - if obs == &Observable::ZenithDryDelay { - Some(*value) - } else { - None - } - }) - .reduce(|k, _| k) - .unwrap(); - - let zwd = delays - .iter() - .filter_map(|(obs, value)| { - if obs == &Observable::ZenithWetDelay { - Some(*value) - } else { - None - } - }) - .reduce(|k, _| k) - .unwrap(); - - Some((zwd, zdd)) - } -} - -fn kb_model(nav: &Rinex, t: Epoch) -> Option { - let kb_model = nav - .klobuchar_models() - .min_by_key(|(t_i, _, _)| (t - *t_i).abs()); - - if let Some((_, sv, kb_model)) = kb_model { - Some(KbModel { - h_km: { - match sv.constellation { - Constellation::BeiDou => 375.0, - // we only expect GPS or BDS here, - // badly formed RINEX will generate errors in the solutions - _ => 350.0, - } - }, - alpha: kb_model.alpha, - beta: kb_model.beta, - }) - } else { - /* RINEX 3 case */ - let iono_corr = nav.header.ionod_correction?; - if let Some(kb_model) = iono_corr.as_klobuchar() { - Some(KbModel { - h_km: 350.0, //TODO improve this - alpha: kb_model.alpha, - beta: kb_model.beta, - }) - } else { - None - } - } -} - -fn bd_model(nav: &Rinex, t: Epoch) -> Option { - nav.bdgim_models() - .min_by_key(|(t_i, _)| (t - *t_i).abs()) - .map(|(_, model)| BdModel { alpha: model.alpha }) -} - -fn ng_model(nav: &Rinex, t: Epoch) -> Option { - nav.nequick_g_models() - .min_by_key(|(t_i, _)| (t - *t_i).abs()) - .map(|(_, model)| NgModel { a: model.a }) -} +use crate::cli::Context; +use crate::positioning::{ + bd_model, kb_model, ng_model, tropo_components, Error as PositioningError, +}; fn reset_sv_tracker(sv: SV, trackers: &mut HashMap<(SV, Observable), SVTracker>) { for ((k_sv, _), tracker) in trackers { @@ -190,111 +53,40 @@ fn reset_sv_tracker(sv: SV, trackers: &mut HashMap<(SV, Observable), SVTracker>) // } // } -pub fn resolve(ctx: &mut RnxContext, cli: &Cli) -> Result, Error> { +/* + * Resolves CGGTTS tracks from input context + */ +pub fn resolve( + ctx: &Context, + mut solver: Solver, + rx_lat_ddeg: f64, + matches: &ArgMatches, +) -> Result, PositioningError> +where + APC: Fn(Epoch, SV, f64) -> Option<(f64, f64, f64)>, + I: Fn(Epoch, SV, usize) -> Option, +{ // custom tracking duration - let trk_duration = cli.tracking_duration(); - info!("tracking duration set to {}", trk_duration); - - // parse custom config, if any - let cfg = match cli.config() { - Some(cfg) => cfg, - None => { - /* no manual config: we use the optimal known to this day */ - Config::preset(Method::SPP) + let trk_duration = match matches.get_one::("tracking") { + Some(tracking) => { + info!("Using custom tracking duration {:?}", *tracking); + *tracking }, - }; - - match cfg.method { - Method::SPP => info!("single point positioning"), - Method::PPP => info!("precise point positioning"), - }; - - let pos = match cli.manual_apc() { - Some(pos) => pos, - None => ctx - .ground_position() - .ok_or(Error::UndefinedAprioriPosition)?, - }; - - let apriori_ecef = pos.to_ecef_wgs84(); - let apriori = Vector3::::new(apriori_ecef.0, apriori_ecef.1, apriori_ecef.2); - let apriori = AprioriPosition::from_ecef(apriori); - - let lat_ddeg = apriori.geodetic[0]; - - // print config to be used - info!("{:#?}", cfg); - - let obs_data = match ctx.obs_data() { - Some(data) => data, - None => { - return Err(Error::MissingObservationData); + _ => { + let tracking = Duration::from_seconds(Scheduler::BIPM_TRACKING_DURATION_SECONDS.into()); + info!("Using default tracking duration {:?}", tracking); + tracking }, }; + // infaillible, at this point + let obs_data = ctx.data.obs_data().unwrap(); + let nav_data = ctx.data.nav_data().unwrap(); + let meteo_data = ctx.data.meteo_data(); + let dominant_sampling_period = obs_data .dominant_sample_rate() - .expect("RNX2CGGTTS requires steady RINEX observations"); - - let nav_data = match ctx.nav_data() { - Some(data) => data, - None => { - return Err(Error::MissingBroadcastNavigationData); - }, - }; - - let sp3_data = ctx.sp3_data(); - let atx_data = ctx.atx_data(); - let meteo_data = ctx.meteo_data(); - - let mut solver = Solver::new( - &cfg, - apriori, - /* state vector interpolator */ - |t, sv, order| { - /* SP3 source is prefered */ - if let Some(sp3) = sp3_data { - if let Some((x, y, z)) = sp3.sv_position_interpolate(sv, t, order) { - let (x, y, z) = (x * 1.0E3, y * 1.0E3, z * 1.0E3); - let (elevation, azimuth) = - Ephemeris::elevation_azimuth((x, y, z), apriori_ecef); - Some( - InterpolationResult::from_mass_center_position((x, y, z)) - .with_elevation_azimuth((elevation, azimuth)), - ) - } else { - // debug!("{:?} ({}): sp3 interpolation failed", t, sv); - if let Some((x, y, z)) = nav_data.sv_position_interpolate(sv, t, order) { - let (x, y, z) = (x * 1.0E3, y * 1.0E3, z * 1.0E3); - let (elevation, azimuth) = - Ephemeris::elevation_azimuth((x, y, z), apriori_ecef); - Some( - InterpolationResult::from_apc_position((x, y, z)) - .with_elevation_azimuth((elevation, azimuth)), - ) - } else { - // debug!("{:?} ({}): nav interpolation failed", t, sv); - None - } - } - } else if let Some((x, y, z)) = nav_data.sv_position_interpolate(sv, t, order) { - let (x, y, z) = (x * 1.0E3, y * 1.0E3, z * 1.0E3); - let (elevation, azimuth) = Ephemeris::elevation_azimuth((x, y, z), apriori_ecef); - Some( - InterpolationResult::from_apc_position((x, y, z)) - .with_elevation_azimuth((elevation, azimuth)), - ) - } else { - // debug!("{:?} ({}): nav interpolation failed", t, sv); - None - } - }, - /* APC corrections provider */ - |_t, _sv, _freq| { - let _atx = atx_data?; - None - }, - )?; + .expect("RNX2CGGTTS requires steady GNSS observations"); // CGGTTS specifics let mut tracks = Vec::::new(); @@ -312,7 +104,7 @@ pub fn resolve(ctx: &mut RnxContext, cli: &Cli) -> Result, Error> { } // Nearest TROPO - let zwd_zdd = tropo_components(meteo_data, *t, lat_ddeg); + let zwd_zdd = tropo_components(meteo_data, *t, rx_lat_ddeg); for (sv, observations) in vehicles { let sv_eph = nav_data.sv_ephemeris(*sv, *t); @@ -352,7 +144,6 @@ pub fn resolve(ctx: &mut RnxContext, cli: &Cli) -> Result, Error> { let mut code = Option::::None; let mut phase = Option::::None; - let mut assoc_doppler = Option::::None; if observable.is_pseudorange_observable() { code = Some(Observation { @@ -368,17 +159,18 @@ pub fn resolve(ctx: &mut RnxContext, cli: &Cli) -> Result, Error> { }); } + // we only one phase or code here if code.is_none() && phase.is_none() { continue; } - let doppler = Option::::None; + let mut doppler = Option::::None; let doppler_to_match = Observable::from_str(&format!("D{}", &observable.to_string()[..1])).unwrap(); for (observable, data) in observations { if observable.is_doppler_observable() && observable == &doppler_to_match { - assoc_doppler = Some(Observation { + doppler = Some(Observation { frequency, snr: { data.snr.map(|snr| snr.into()) }, value: data.obs, diff --git a/rinex-cli/src/positioning/cggtts/post_process.rs b/rinex-cli/src/positioning/cggtts/post_process.rs new file mode 100644 index 000000000..608ecb462 --- /dev/null +++ b/rinex-cli/src/positioning/cggtts/post_process.rs @@ -0,0 +1,83 @@ +//! CGGTTS track formation and post processing +use crate::cli::Context; +use cggtts::prelude::*; +use cggtts::Coordinates; +use clap::ArgMatches; +use std::fs::File; +use std::io::Write; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("failed to write cggtts file (permission denied)")] + IoError(#[from] std::io::Error), +} + +/* + * CGGTTS file generation and solutions post processing + */ +pub fn post_process(ctx: &Context, tracks: Vec, matches: &ArgMatches) -> Result<(), Error> { + /* + * CGGTTS formation and customization + */ + let obs_data = ctx.data.obs_data().unwrap(); // infaillible at this point + + // receiver customization + let rcvr = match &obs_data.header.rcvr { + Some(rcvr) => Rcvr { + manufacturer: String::from("XX"), + model: rcvr.model.clone(), + serial_number: rcvr.sn.clone(), + year: 0, + release: rcvr.firmware.clone(), + }, + None => Rcvr::default(), + }; + // LAB/Agency customization + let lab = if let Some(custom) = matches.get_one::("lab") { + custom.to_string() + } else { + let stem = Context::context_stem(&ctx.data); + if let Some(index) = stem.find('_') { + stem[..index].to_string() + } else { + "LAB".to_string() + } + }; + + let mut cggtts = CGGTTS::default() + .station(&lab) + .nb_channels(1) // TODO: improve this ? + .receiver(rcvr.clone()) + .ims(rcvr.clone()) // TODO : improve this ? + .apc_coordinates({ + // TODO: wrong, CGGTTS wants ITRF: need some conversion + let (x, y, z) = ctx.rx_ecef.unwrap(); // infallible at this point + Coordinates { x, y, z } + }) + .reference_frame("WGS84") //TODO incorrect + .reference_time({ + if let Some(utck) = matches.get_one::("utck") { + ReferenceTime::UTCk(utck.clone()) + } else if let Some(clock) = matches.get_one::("clock") { + ReferenceTime::Custom(clock.clone()) + } else { + ReferenceTime::Custom("Unknown".to_string()) + } + }) + .comments(&format!( + "rinex-cli v{} - https://georust.org", + env!("CARGO_PKG_VERSION") + )); + + for track in tracks { + cggtts.tracks.push(track); + } + + let filename = ctx.workspace.join(cggtts.filename()); + let mut fd = File::create(&filename)?; + write!(fd, "{}", cggtts)?; + info!("{} has been generated", filename.to_string_lossy()); + + Ok(()) +} diff --git a/rinex-cli/src/positioning/mod.rs b/rinex-cli/src/positioning/mod.rs index f6b06eb8c..6b4449f01 100644 --- a/rinex-cli/src/positioning/mod.rs +++ b/rinex-cli/src/positioning/mod.rs @@ -1,5 +1,268 @@ -pub mod solver; -pub use solver::solver; +use crate::cli::Context; +use std::fs::read_to_string; -pub mod post_process; -pub use post_process::post_process; +mod ppp; // precise point positioning +use ppp::post_process as ppp_post_process; +use ppp::PostProcessingError as PPPPostProcessingError; + +mod cggtts; // CGGTTS special solver +use cggtts::post_process as cggtts_post_process; +use cggtts::PostProcessingError as CGGTTSPostProcessingError; + +use clap::ArgMatches; +use gnss::prelude::Constellation; // SV}; +use rinex::navigation::Ephemeris; +use rinex::prelude::{Observable, Rinex}; + +use rtk::prelude::{ + AprioriPosition, BdModel, Config, Duration, Epoch, InterpolationResult, KbModel, Method, + NgModel, Solver, Vector3, +}; + +use map_3d::{ecef2geodetic, Ellipsoid}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("solver error")] + SolverError(#[from] rtk::Error), + #[error("undefined apriori position")] + UndefinedAprioriPosition, + #[error("ppp post processing error")] + PPPPostProcessingError(#[from] PPPPostProcessingError), + #[error("cggtts post processing error")] + CGGTTSPostProcessingError(#[from] CGGTTSPostProcessingError), +} + +pub fn tropo_components(meteo: Option<&Rinex>, t: Epoch, lat_ddeg: f64) -> Option<(f64, f64)> { + const MAX_LATDDEG_DELTA: f64 = 15.0; + let max_dt = Duration::from_hours(24.0); + let rnx = meteo?; + let meteo = rnx.header.meteo.as_ref().unwrap(); + + let delays: Vec<(Observable, f64)> = meteo + .sensors + .iter() + .filter_map(|s| match s.observable { + Observable::ZenithDryDelay => { + let (x, y, z, _) = s.position?; + let (lat, _, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + if (lat - lat_ddeg).abs() < MAX_LATDDEG_DELTA { + let value = rnx + .zenith_dry_delay() + .filter(|(t_sens, _)| (*t_sens - t).abs() < max_dt) + .min_by_key(|(t_sens, _)| (*t_sens - t).abs()); + let (_, value) = value?; + debug!("{:?} lat={} zdd {}", t, lat_ddeg, value); + Some((s.observable.clone(), value)) + } else { + None + } + }, + Observable::ZenithWetDelay => { + let (x, y, z, _) = s.position?; + let (lat, _, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); + if (lat - lat_ddeg).abs() < MAX_LATDDEG_DELTA { + let value = rnx + .zenith_wet_delay() + .filter(|(t_sens, _)| (*t_sens - t).abs() < max_dt) + .min_by_key(|(t_sens, _)| (*t_sens - t).abs()); + let (_, value) = value?; + debug!("{:?} lat={} zdd {}", t, lat_ddeg, value); + Some((s.observable.clone(), value)) + } else { + None + } + }, + _ => None, + }) + .collect(); + + if delays.len() < 2 { + None + } else { + let zdd = delays + .iter() + .filter_map(|(obs, value)| { + if obs == &Observable::ZenithDryDelay { + Some(*value) + } else { + None + } + }) + .reduce(|k, _| k) + .unwrap(); + + let zwd = delays + .iter() + .filter_map(|(obs, value)| { + if obs == &Observable::ZenithWetDelay { + Some(*value) + } else { + None + } + }) + .reduce(|k, _| k) + .unwrap(); + + Some((zwd, zdd)) + } +} + +/* + * Grabs nearest KB model (in time) + */ +pub fn kb_model(nav: &Rinex, t: Epoch) -> Option { + let kb_model = nav + .klobuchar_models() + .min_by_key(|(t_i, _, _)| (t - *t_i).abs()); + + if let Some((_, sv, kb_model)) = kb_model { + Some(KbModel { + h_km: { + match sv.constellation { + Constellation::BeiDou => 375.0, + // we only expect GPS or BDS here, + // badly formed RINEX will generate errors in the solutions + _ => 350.0, + } + }, + alpha: kb_model.alpha, + beta: kb_model.beta, + }) + } else { + /* RINEX 3 case */ + let iono_corr = nav.header.ionod_correction?; + iono_corr.as_klobuchar().map(|kb_model| KbModel { + h_km: 350.0, //TODO improve this + alpha: kb_model.alpha, + beta: kb_model.beta, + }) + } +} + +/* + * Grabs nearest BD model (in time) + */ +pub fn bd_model(nav: &Rinex, t: Epoch) -> Option { + nav.bdgim_models() + .min_by_key(|(t_i, _)| (t - *t_i).abs()) + .map(|(_, model)| BdModel { alpha: model.alpha }) +} + +/* + * Grabs nearest NG model (in time) + */ +pub fn ng_model(nav: &Rinex, t: Epoch) -> Option { + nav.nequick_g_models() + .min_by_key(|(t_i, _)| (t - *t_i).abs()) + .map(|(_, model)| NgModel { a: model.a }) +} + +pub fn precise_positioning(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { + let method = match matches.get_flag("spp") { + true => Method::SPP, + false => Method::PPP, + }; + + let cfg = match matches.get_one::("cfg") { + Some(fp) => { + let content = read_to_string(fp) + .unwrap_or_else(|_| panic!("failed to read configuration: permission denied")); + let cfg = serde_json::from_str(&content) + .unwrap_or_else(|_| panic!("failed to parse configuration: invalid content")); + info!("using custom solver configuration: {:#?}", cfg); + cfg + }, + None => { + let cfg = Config::preset(method); + info!("using default {:?} solver preset: {:#?}", method, cfg); + cfg + }, + }; + + /* + * verify requirements + */ + let apriori_ecef = ctx.rx_ecef.ok_or(Error::UndefinedAprioriPosition)?; + + let apriori = Vector3::::new(apriori_ecef.0, apriori_ecef.1, apriori_ecef.2); + let apriori = AprioriPosition::from_ecef(apriori); + let rx_lat_ddeg = apriori.geodetic[0]; + + if ctx.data.obs_data().is_none() { + panic!("positioning requires Observation RINEX"); + } + + let nav_data = ctx + .data + .nav_data() + .expect("positioning requires Navigation RINEX"); + + let sp3_data = ctx.data.sp3_data(); + if sp3_data.is_none() { + panic!("High precision orbits (SP3) are unfortunately mandatory at the moment.."); + } + + // print config to be used + info!("Using solver {:?} method", method); + info!("Using solver configuration {:#?}", cfg); + + let solver = Solver::new( + &cfg, + apriori, + /* state vector interpolator */ + |t, sv, order| { + /* SP3 source is prefered */ + if let Some(sp3) = sp3_data { + if let Some((x, y, z)) = sp3.sv_position_interpolate(sv, t, order) { + let (x, y, z) = (x * 1.0E3, y * 1.0E3, z * 1.0E3); + let (elevation, azimuth) = + Ephemeris::elevation_azimuth((x, y, z), apriori_ecef); + Some( + InterpolationResult::from_mass_center_position((x, y, z)) + .with_elevation_azimuth((elevation, azimuth)), + ) + } else { + // debug!("{:?} ({}): sp3 interpolation failed", t, sv); + if let Some((x, y, z)) = nav_data.sv_position_interpolate(sv, t, order) { + let (x, y, z) = (x * 1.0E3, y * 1.0E3, z * 1.0E3); + let (elevation, azimuth) = + Ephemeris::elevation_azimuth((x, y, z), apriori_ecef); + Some( + InterpolationResult::from_apc_position((x, y, z)) + .with_elevation_azimuth((elevation, azimuth)), + ) + } else { + // debug!("{:?} ({}): nav interpolation failed", t, sv); + None + } + } + } else if let Some((x, y, z)) = nav_data.sv_position_interpolate(sv, t, order) { + let (x, y, z) = (x * 1.0E3, y * 1.0E3, z * 1.0E3); + let (elevation, azimuth) = Ephemeris::elevation_azimuth((x, y, z), apriori_ecef); + Some( + InterpolationResult::from_apc_position((x, y, z)) + .with_elevation_azimuth((elevation, azimuth)), + ) + } else { + // debug!("{:?} ({}): nav interpolation failed", t, sv); + None + } + }, + /* APC corrections provider */ + |_t, _sv, _freq| None, + )?; + + if matches.get_flag("cggtts") { + /* CGGTTS special opmode */ + let tracks = cggtts::resolve(ctx, solver, rx_lat_ddeg, matches)?; + cggtts_post_process(ctx, tracks, matches)?; + } else { + /* PPP */ + let pvt_solutions = ppp::resolve(ctx, solver, rx_lat_ddeg); + /* save solutions (graphs, reports..) */ + ppp_post_process(ctx, pvt_solutions, matches)?; + } + Ok(()) +} diff --git a/rinex-cli/src/positioning/post_process.rs b/rinex-cli/src/positioning/post_process.rs deleted file mode 100644 index bd50992cb..000000000 --- a/rinex-cli/src/positioning/post_process.rs +++ /dev/null @@ -1,345 +0,0 @@ -use crate::cli::Cli; -use std::collections::{BTreeMap, HashMap}; -use std::fs::File; -use std::io::Write; -use std::path::PathBuf; -use thiserror::Error; - -use hifitime::Epoch; -use rinex::prelude::RnxContext; -use rtk::prelude::PVTSolution; - -extern crate gpx; -use gpx::{errors::GpxError, Gpx, GpxVersion, Waypoint}; - -use kml::{ - types::AltitudeMode, types::Coord as KmlCoord, types::Geometry as KmlGeometry, - types::KmlDocument, types::Placemark, types::Point as KmlPoint, Kml, KmlVersion, KmlWriter, -}; - -extern crate geo_types; -use geo_types::Point as GeoPoint; - -use crate::context_stem; - -use plotly::color::NamedColor; -use plotly::common::Mode; -use plotly::common::{Marker, MarkerSymbol}; -use plotly::layout::MapboxStyle; -use plotly::ScatterMapbox; - -use crate::fops::open_with_web_browser; -use crate::plot::{build_3d_chart_epoch_label, build_chart_epoch_axis, PlotContext}; -use map_3d::{ecef2geodetic, rad2deg, Ellipsoid}; - -#[derive(Debug, Error)] -pub enum Error { - #[error("std::io error")] - IOError(#[from] std::io::Error), - #[error("failed to generate gpx track")] - GpxError(#[from] GpxError), - #[error("failed to generate kml track")] - KmlError(#[from] kml::Error), -} - -pub fn post_process( - workspace: PathBuf, - cli: &Cli, - ctx: &RnxContext, - results: BTreeMap, -) -> Result<(), Error> { - // create a dedicated plot context - let no_graph = cli.no_graph(); - let mut plot_ctx = PlotContext::new(); - - let (x, y, z) = ctx - .ground_position() - .unwrap() // cannot fail at this point - .to_ecef_wgs84(); - - let (lat_ddeg, lon_ddeg, _) = ctx - .ground_position() - .unwrap() // cannot fail at this point - .to_geodetic(); - - if !no_graph { - let epochs = results.keys().copied().collect::>(); - - let (mut lat, mut lon) = (Vec::::new(), Vec::::new()); - for result in results.values() { - let px = x + result.pos.x; - let py = y + result.pos.y; - let pz = z + result.pos.z; - let (lat_ddeg, lon_ddeg, _) = ecef2geodetic(px, py, pz, Ellipsoid::WGS84); - lat.push(rad2deg(lat_ddeg)); - lon.push(rad2deg(lon_ddeg)); - } - - plot_ctx.add_world_map( - "PVT solutions", - true, // show legend - MapboxStyle::OpenStreetMap, - (lat_ddeg, lon_ddeg), //center - 18, // zoom in!! - ); - - let ref_scatter = ScatterMapbox::new(vec![lat_ddeg], vec![lon_ddeg]) - .marker( - Marker::new() - .size(5) - .symbol(MarkerSymbol::Circle) - .color(NamedColor::Red), - ) - .name("Apriori"); - plot_ctx.add_trace(ref_scatter); - - let pvt_scatter = ScatterMapbox::new(lat, lon) - .marker( - Marker::new() - .size(5) - .symbol(MarkerSymbol::Circle) - .color(NamedColor::Black), - ) - .name("PVT"); - plot_ctx.add_trace(pvt_scatter); - - let trace = build_3d_chart_epoch_label( - "error", - Mode::Markers, - epochs.clone(), - results.values().map(|e| e.pos.x).collect::>(), - results.values().map(|e| e.pos.y).collect::>(), - results.values().map(|e| e.pos.z).collect::>(), - ); - - /* - * Create graphical visualization - * dx, dy : one dual plot - * hdop, vdop : one dual plot - * dz : dedicated plot - * dt, tdop : one dual plot - */ - plot_ctx.add_cartesian3d_plot( - "Position errors", - "x error [m]", - "y error [m]", - "z error [m]", - ); - plot_ctx.add_trace(trace); - - plot_ctx.add_timedomain_2y_plot("Velocity (X & Y)", "Speed [m/s]", "Speed [m/s]"); - let trace = build_chart_epoch_axis( - "velocity (x)", - Mode::Markers, - epochs.clone(), - results.values().map(|p| p.vel.x).collect::>(), - ); - plot_ctx.add_trace(trace); - - let trace = build_chart_epoch_axis( - "velocity (y)", - Mode::Markers, - epochs.clone(), - results.values().map(|p| p.vel.y).collect::>(), - ) - .y_axis("y2"); - plot_ctx.add_trace(trace); - - plot_ctx.add_timedomain_plot("Velocity (Z)", "Speed [m/s]"); - let trace = build_chart_epoch_axis( - "velocity (z)", - Mode::Markers, - epochs.clone(), - results.values().map(|p| p.vel.z).collect::>(), - ); - plot_ctx.add_trace(trace); - - plot_ctx.add_timedomain_plot("GDOP", "GDOP [m]"); - let trace = build_chart_epoch_axis( - "gdop", - Mode::Markers, - epochs.clone(), - results.values().map(|e| e.gdop()).collect::>(), - ); - plot_ctx.add_trace(trace); - - plot_ctx.add_timedomain_2y_plot("HDOP, VDOP", "HDOP [m]", "VDOP [m]"); - let trace = build_chart_epoch_axis( - "hdop", - Mode::Markers, - epochs.clone(), - results - .values() - .map(|e| e.hdop(lat_ddeg, lon_ddeg)) - .collect::>(), - ); - plot_ctx.add_trace(trace); - - let trace = build_chart_epoch_axis( - "vdop", - Mode::Markers, - epochs.clone(), - results - .values() - .map(|e| e.vdop(lat_ddeg, lon_ddeg)) - .collect::>(), - ) - .y_axis("y2"); - plot_ctx.add_trace(trace); - - plot_ctx.add_timedomain_2y_plot("Clock offset", "dt [s]", "TDOP [s]"); - let trace = build_chart_epoch_axis( - "dt", - Mode::Markers, - epochs.clone(), - results.values().map(|e| e.dt).collect::>(), - ); - plot_ctx.add_trace(trace); - - let trace = build_chart_epoch_axis( - "tdop", - Mode::Markers, - epochs.clone(), - results.values().map(|e| e.tdop()).collect::>(), - ) - .y_axis("y2"); - plot_ctx.add_trace(trace); - - // render plots - let graphs = workspace.join("rtk.html"); - let graphs = graphs.to_string_lossy().to_string(); - let mut fd = - File::create(&graphs).unwrap_or_else(|_| panic!("failed to crate \"{}\"", graphs)); - write!(fd, "{}", plot_ctx.to_html()).expect("failed to render rtk visualization"); - info!("\"{}\" rtk view generated", graphs); - } - - /* - * Generate txt, GPX, KML.. - */ - let txtpath = workspace.join("pvt-solutions.csv"); - let txtfile = txtpath.to_string_lossy().to_string(); - let mut fd = File::create(&txtfile)?; - - let mut gpx_track = gpx::Track::default(); - let mut kml_track = Vec::::new(); - - writeln!( - fd, - "Epoch, dx, dy, dz, x_ecef, y_ecef, z_ecef, speed_x, speed_y, speed_z, hdop, vdop, rcvr_clock_bias, tdop" - )?; - - for (epoch, solution) in results { - let (px, py, pz) = (x + solution.pos.x, y + solution.pos.y, z + solution.pos.z); - let (lat, lon, alt) = map_3d::ecef2geodetic(px, py, pz, map_3d::Ellipsoid::WGS84); - let (hdop, vdop, tdop) = ( - solution.hdop(lat_ddeg, lon_ddeg), - solution.vdop(lat_ddeg, lon_ddeg), - solution.tdop(), - ); - writeln!( - fd, - "{:?}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}", - epoch, - solution.pos.x, - solution.pos.y, - solution.pos.z, - px, - py, - pz, - solution.vel.x, - solution.vel.y, - solution.vel.z, - hdop, - vdop, - solution.dt, - tdop - )?; - if cli.gpx() { - let mut segment = gpx::TrackSegment::new(); - let mut wp = Waypoint::new(GeoPoint::new(rad2deg(lat), rad2deg(lon))); - wp.elevation = Some(alt); - wp.speed = None; // TODO ? - wp.time = None; // TODO Gpx::Time - wp.name = Some(format!("{:?}", epoch)); - wp.hdop = Some(hdop); - wp.vdop = Some(vdop); - wp.sat = None; //TODO: nb of contributing satellites - wp.dgps_age = None; //TODO: Number of seconds since last DGPS update, from the element. - wp.dgpsid = None; //TODO: ID of DGPS station used in differential correction, in the range [0, 1023]. - segment.points.push(wp); - gpx_track.segments.push(segment); - } - if cli.kml() { - kml_track.push(Kml::Placemark(Placemark { - name: Some(format!("{:?}", epoch)), - description: Some(String::from("\"Receiver Location\"")), - geometry: { - Some(KmlGeometry::Point(KmlPoint { - coord: { - KmlCoord { - x: rad2deg(lat), - y: rad2deg(lon), - z: Some(alt), - } - }, - extrude: false, - altitude_mode: AltitudeMode::Absolute, - attrs: HashMap::new(), - })) - }, - attrs: [(String::from("TDOP"), format!("{:.6E}", solution.tdop()))] - .into_iter() - .collect(), - children: vec![], - })); - } - } - info!("\"{}\" generated", txtfile); - if cli.gpx() { - let gpxpath = workspace.join(format!("{}.gpx", context_stem(ctx))); - let gpxfile = gpxpath.to_string_lossy().to_string(); - let fd = File::create(&gpxfile)?; - - let mut gpx = Gpx::default(); - gpx.version = GpxVersion::Gpx11; - gpx_track.name = Some(context_stem(ctx).to_string()); - // gpx_track.number = Some(1); - gpx.tracks.push(gpx_track); - - gpx::write(&gpx, fd)?; - info!("{} gpx track generated", gpxfile); - } - if cli.kml() { - let kmlpath = workspace.join(format!("{}.kml", context_stem(ctx))); - let kmlfile = kmlpath.to_string_lossy().to_string(); - let mut fd = File::create(&kmlfile)?; - - let kmldoc = KmlDocument { - version: KmlVersion::V23, - attrs: [( - String::from("rtk-version"), - env!("CARGO_PKG_VERSION").to_string(), - )] - .into_iter() - .collect(), - elements: { - vec![Kml::Folder { - attrs: HashMap::new(), - elements: kml_track, - }] - }, - }; - let mut writer = KmlWriter::from_writer(&mut fd); - writer.write(&Kml::KmlDocument(kmldoc))?; - info!("{} kml track generated", kmlfile); - } - - if !cli.quiet() && !no_graph { - let graphs = workspace.join("rtk.html"); - let graphs = graphs.to_string_lossy().to_string(); - open_with_web_browser(&graphs); - } - - Ok(()) -} diff --git a/rinex-cli/src/positioning/ppp/mod.rs b/rinex-cli/src/positioning/ppp/mod.rs new file mode 100644 index 000000000..2ae5307fd --- /dev/null +++ b/rinex-cli/src/positioning/ppp/mod.rs @@ -0,0 +1,182 @@ +//! PPP solver +use crate::cli::Context; +use crate::positioning::{bd_model, kb_model, ng_model, tropo_components}; +use rinex::carrier::Carrier; +use rinex::navigation::Ephemeris; +use rinex::prelude::SV; +use std::collections::BTreeMap; + +mod post_process; +pub use post_process::{post_process, Error as PostProcessingError}; + +use rtk::prelude::{ + Candidate, Epoch, InterpolationResult, IonosphericBias, Observation, PVTSolution, + PVTSolutionType, Solver, TroposphericBias, Vector3, +}; + +pub fn resolve( + ctx: &Context, + mut solver: Solver, + rx_lat_ddeg: f64, +) -> BTreeMap +where + APC: Fn(Epoch, SV, f64) -> Option<(f64, f64, f64)>, + I: Fn(Epoch, SV, usize) -> Option, +{ + let mut solutions: BTreeMap = BTreeMap::new(); + + // infaillible, at this point + let obs_data = ctx.data.obs_data().unwrap(); + let nav_data = ctx.data.nav_data().unwrap(); + let meteo_data = ctx.data.meteo_data(); + + let sp3_data = ctx.data.sp3_data(); + let sp3_has_clock = match sp3_data { + Some(sp3) => sp3.sv_clock().count() > 0, + None => false, + }; + + for ((t, flag), (_clk, vehicles)) in obs_data.observation() { + let mut candidates = Vec::::with_capacity(4); + + if !flag.is_ok() { + /* we only consider "OK" epochs" */ + continue; + } + + // /* + // * store possibly provided clk state estimator, + // * so we can compare ours to this one later + // */ + // if let Some(clk) = clk { + // provided_clk.insert(*t, *clk); + // } + + for (sv, observations) in vehicles { + let sv_eph = nav_data.sv_ephemeris(*sv, *t); + if sv_eph.is_none() { + warn!("{:?} ({}) : undetermined ephemeris", t, sv); + continue; // can't proceed further + } + + let (toe, sv_eph) = sv_eph.unwrap(); + + /* + * Prefer SP3 for clock state (if any), + * otherwise, use brdc + */ + let clock_state = match sp3_has_clock { + true => { + let sp3 = sp3_data.unwrap(); + if let Some(_clk) = sp3 + .sv_clock() + .filter_map(|(sp3_t, sp3_sv, clk)| { + if sp3_t == *t && sp3_sv == *sv { + Some(clk * 1.0E-6) + } else { + None + } + }) + .reduce(|clk, _| clk) + { + let clock_state = sv_eph.sv_clock(); + Vector3::new(clock_state.0, 0.0_f64, 0.0_f64) + } else { + /* + * SP3 preference: abort on missing Epochs + */ + //continue ; + let clock_state = sv_eph.sv_clock(); + Vector3::new(clock_state.0, clock_state.1, clock_state.2) + } + }, + false => { + let clock_state = sv_eph.sv_clock(); + Vector3::new(clock_state.0, clock_state.1, clock_state.2) + }, + }; + + let clock_corr = Ephemeris::sv_clock_corr( + *sv, + (clock_state[0], clock_state[1], clock_state[2]), + *t, + toe, + ); + + let mut codes = Vec::::new(); + let mut phases = Vec::::new(); + let mut dopplers = Vec::::new(); + + for (observable, data) in observations { + if let Ok(carrier) = Carrier::from_observable(sv.constellation, observable) { + let frequency = carrier.frequency(); + + if observable.is_pseudorange_observable() { + codes.push(Observation { + frequency, + snr: { data.snr.map(|snr| snr.into()) }, + value: data.obs, + }); + } else if observable.is_phase_observable() { + phases.push(Observation { + frequency, + snr: { data.snr.map(|snr| snr.into()) }, + value: data.obs, + }); + } else if observable.is_doppler_observable() { + dopplers.push(Observation { + frequency, + snr: { data.snr.map(|snr| snr.into()) }, + value: data.obs, + }); + } + } + } + + if let Ok(candidate) = Candidate::new( + *sv, + *t, + clock_state, + clock_corr, + codes.clone(), + phases.clone(), + dopplers.clone(), + ) { + candidates.push(candidate); + } else { + warn!("{:?}: failed to form {} candidate", t, sv); + } + } + + // grab possible tropo components + let zwd_zdd = tropo_components(meteo_data, *t, rx_lat_ddeg); + + let iono_bias = IonosphericBias { + kb_model: kb_model(nav_data, *t), + bd_model: bd_model(nav_data, *t), + ng_model: ng_model(nav_data, *t), + stec_meas: None, //TODO + }; + + let tropo_bias = TroposphericBias { + total: None, //TODO + zwd_zdd, + }; + + match solver.resolve( + *t, + PVTSolutionType::PositionVelocityTime, + candidates, + &iono_bias, + &tropo_bias, + ) { + Ok((t, pvt)) => { + debug!("{:?} : {:?}", t, pvt); + solutions.insert(t, pvt); + }, + Err(e) => warn!("{:?} : pvt solver error \"{}\"", t, e), + } + } + + solutions +} diff --git a/rinex-cli/src/positioning/ppp/post_process.rs b/rinex-cli/src/positioning/ppp/post_process.rs new file mode 100644 index 000000000..8e10783dd --- /dev/null +++ b/rinex-cli/src/positioning/ppp/post_process.rs @@ -0,0 +1,335 @@ +use crate::cli::Context; +use clap::ArgMatches; +use std::collections::{BTreeMap, HashMap}; +use std::fs::File; +use std::io::Write; +use thiserror::Error; + +use hifitime::Epoch; +use rtk::prelude::PVTSolution; + +extern crate gpx; +use gpx::{errors::GpxError, Gpx, GpxVersion, Waypoint}; + +use kml::{ + types::AltitudeMode, types::Coord as KmlCoord, types::Geometry as KmlGeometry, + types::KmlDocument, types::Placemark, types::Point as KmlPoint, Kml, KmlVersion, KmlWriter, +}; + +extern crate geo_types; +use geo_types::Point as GeoPoint; + +use plotly::color::NamedColor; +use plotly::common::Mode; +use plotly::common::{Marker, MarkerSymbol}; +use plotly::layout::MapboxStyle; +use plotly::ScatterMapbox; + +use crate::fops::open_with_web_browser; +use crate::graph::{build_3d_chart_epoch_label, build_chart_epoch_axis, PlotContext}; +use map_3d::{ecef2geodetic, rad2deg, Ellipsoid}; + +#[derive(Debug, Error)] +pub enum Error { + #[error("std::io error")] + IOError(#[from] std::io::Error), + #[error("failed to generate gpx track")] + GpxError(#[from] GpxError), + #[error("failed to generate kml track")] + KmlError(#[from] kml::Error), +} + +pub fn post_process( + ctx: &Context, + results: BTreeMap, + matches: &ArgMatches, +) -> Result<(), Error> { + // create a dedicated plot context + let mut plot_ctx = PlotContext::new(); + + 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 epochs = results.keys().copied().collect::>(); + + let (mut lat, mut lon) = (Vec::::new(), Vec::::new()); + for result in results.values() { + let px = x + result.pos.x; + let py = y + result.pos.y; + let pz = z + result.pos.z; + let (lat_ddeg, lon_ddeg, _) = ecef2geodetic(px, py, pz, Ellipsoid::WGS84); + lat.push(rad2deg(lat_ddeg)); + lon.push(rad2deg(lon_ddeg)); + } + + plot_ctx.add_world_map( + "PVT solutions", + true, // show legend + MapboxStyle::OpenStreetMap, + (lat_ddeg, lon_ddeg), //center + 18, // zoom in!! + ); + + let ref_scatter = ScatterMapbox::new(vec![lat_ddeg], vec![lon_ddeg]) + .marker( + Marker::new() + .size(5) + .symbol(MarkerSymbol::Circle) + .color(NamedColor::Red), + ) + .name("Apriori"); + plot_ctx.add_trace(ref_scatter); + + let pvt_scatter = ScatterMapbox::new(lat, lon) + .marker( + Marker::new() + .size(5) + .symbol(MarkerSymbol::Circle) + .color(NamedColor::Black), + ) + .name("PVT"); + plot_ctx.add_trace(pvt_scatter); + + let trace = build_3d_chart_epoch_label( + "error", + Mode::Markers, + epochs.clone(), + results.values().map(|e| e.pos.x).collect::>(), + results.values().map(|e| e.pos.y).collect::>(), + results.values().map(|e| e.pos.z).collect::>(), + ); + + /* + * Create graphical visualization + * dx, dy : one dual plot + * hdop, vdop : one dual plot + * dz : dedicated plot + * dt, tdop : one dual plot + */ + plot_ctx.add_cartesian3d_plot( + "Position errors", + "x error [m]", + "y error [m]", + "z error [m]", + ); + plot_ctx.add_trace(trace); + + plot_ctx.add_timedomain_2y_plot("Velocity (X & Y)", "Speed [m/s]", "Speed [m/s]"); + let trace = build_chart_epoch_axis( + "velocity (x)", + Mode::Markers, + epochs.clone(), + results.values().map(|p| p.vel.x).collect::>(), + ); + plot_ctx.add_trace(trace); + + let trace = build_chart_epoch_axis( + "velocity (y)", + Mode::Markers, + epochs.clone(), + results.values().map(|p| p.vel.y).collect::>(), + ) + .y_axis("y2"); + plot_ctx.add_trace(trace); + + plot_ctx.add_timedomain_plot("Velocity (Z)", "Speed [m/s]"); + let trace = build_chart_epoch_axis( + "velocity (z)", + Mode::Markers, + epochs.clone(), + results.values().map(|p| p.vel.z).collect::>(), + ); + plot_ctx.add_trace(trace); + + plot_ctx.add_timedomain_plot("GDOP", "GDOP [m]"); + let trace = build_chart_epoch_axis( + "gdop", + Mode::Markers, + epochs.clone(), + results.values().map(|e| e.gdop()).collect::>(), + ); + plot_ctx.add_trace(trace); + + plot_ctx.add_timedomain_2y_plot("HDOP, VDOP", "HDOP [m]", "VDOP [m]"); + let trace = build_chart_epoch_axis( + "hdop", + Mode::Markers, + epochs.clone(), + results + .values() + .map(|e| e.hdop(lat_ddeg, lon_ddeg)) + .collect::>(), + ); + plot_ctx.add_trace(trace); + + let trace = build_chart_epoch_axis( + "vdop", + Mode::Markers, + epochs.clone(), + results + .values() + .map(|e| e.vdop(lat_ddeg, lon_ddeg)) + .collect::>(), + ) + .y_axis("y2"); + plot_ctx.add_trace(trace); + + plot_ctx.add_timedomain_2y_plot("Clock offset", "dt [s]", "TDOP [s]"); + let trace = build_chart_epoch_axis( + "dt", + Mode::Markers, + epochs.clone(), + results.values().map(|e| e.dt).collect::>(), + ); + plot_ctx.add_trace(trace); + + let trace = build_chart_epoch_axis( + "tdop", + Mode::Markers, + epochs.clone(), + results.values().map(|e| e.tdop()).collect::>(), + ) + .y_axis("y2"); + plot_ctx.add_trace(trace); + + // render plots + let graphs = ctx.workspace.join("PPP.html"); + let graphs = graphs.to_string_lossy().to_string(); + let mut fd = File::create(&graphs).unwrap_or_else(|_| panic!("failed to crate \"{}\"", graphs)); + write!(fd, "{}", plot_ctx.to_html()).expect("failed to render rtk visualization"); + info!("\"{}\" solutions generated", graphs); + + /* + * Generate txt, GPX, KML.. + */ + let txtpath = ctx.workspace.join("PVT.csv"); + let txtfile = txtpath.to_string_lossy().to_string(); + let mut fd = File::create(&txtfile)?; + + let mut gpx_track = gpx::Track::default(); + let mut kml_track = Vec::::new(); + + writeln!( + fd, + "Epoch, dx, dy, dz, x_ecef, y_ecef, z_ecef, speed_x, speed_y, speed_z, hdop, vdop, rcvr_clock_bias, tdop" + )?; + + for (epoch, solution) in results { + let (px, py, pz) = (x + solution.pos.x, y + solution.pos.y, z + solution.pos.z); + let (lat, lon, alt) = map_3d::ecef2geodetic(px, py, pz, Ellipsoid::WGS84); + let (hdop, vdop, tdop) = ( + solution.hdop(lat_ddeg, lon_ddeg), + solution.vdop(lat_ddeg, lon_ddeg), + solution.tdop(), + ); + writeln!( + fd, + "{:?}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}, {:.6E}", + epoch, + solution.pos.x, + solution.pos.y, + solution.pos.z, + px, + py, + pz, + solution.vel.x, + solution.vel.y, + solution.vel.z, + hdop, + vdop, + solution.dt, + tdop + )?; + if matches.get_flag("gpx") { + let mut segment = gpx::TrackSegment::new(); + let mut wp = Waypoint::new(GeoPoint::new(rad2deg(lat), rad2deg(lon))); + wp.elevation = Some(alt); + wp.speed = None; // TODO ? + wp.time = None; // TODO Gpx::Time + wp.name = Some(format!("{:?}", epoch)); + wp.hdop = Some(hdop); + wp.vdop = Some(vdop); + wp.sat = None; //TODO: nb of contributing satellites + wp.dgps_age = None; //TODO: Number of seconds since last DGPS update, from the element. + wp.dgpsid = None; //TODO: ID of DGPS station used in differential correction, in the range [0, 1023]. + segment.points.push(wp); + gpx_track.segments.push(segment); + } + if matches.get_flag("kml") { + kml_track.push(Kml::Placemark(Placemark { + name: Some(format!("{:?}", epoch)), + description: Some(String::from("\"Receiver Location\"")), + geometry: { + Some(KmlGeometry::Point(KmlPoint { + coord: { + KmlCoord { + x: rad2deg(lat), + y: rad2deg(lon), + z: Some(alt), + } + }, + extrude: false, + altitude_mode: AltitudeMode::Absolute, + attrs: HashMap::new(), + })) + }, + attrs: [(String::from("TDOP"), format!("{:.6E}", solution.tdop()))] + .into_iter() + .collect(), + children: vec![], + })); + } + } + info!("\"{}\" generated", txtfile); + if matches.get_flag("gpx") { + let prefix = Context::context_stem(&ctx.data); + let gpxpath = ctx.workspace.join(format!("{}.gpx", prefix)); + let gpxfile = gpxpath.to_string_lossy().to_string(); + + let fd = File::create(&gpxfile)?; + + let mut gpx = Gpx::default(); + gpx.version = GpxVersion::Gpx11; + gpx_track.name = Some(prefix.clone()); + // gpx_track.number = Some(1); + gpx.tracks.push(gpx_track); + + gpx::write(&gpx, fd)?; + info!("{} gpx track generated", gpxfile); + } + if matches.get_flag("kml") { + let prefix = Context::context_stem(&ctx.data); + let kmlpath = ctx.workspace.join(format!("{}.kml", prefix)); + let kmlfile = kmlpath.to_string_lossy().to_string(); + + let mut fd = File::create(&kmlfile)?; + + let kmldoc = KmlDocument { + version: KmlVersion::V23, + attrs: [( + String::from("rtk-version"), + env!("CARGO_PKG_VERSION").to_string(), + )] + .into_iter() + .collect(), + elements: { + vec![Kml::Folder { + attrs: HashMap::new(), + elements: kml_track, + }] + }, + }; + let mut writer = KmlWriter::from_writer(&mut fd); + writer.write(&Kml::KmlDocument(kmldoc))?; + info!("{} kml track generated", kmlfile); + } + + if !ctx.quiet { + let graphs = ctx.workspace.join("PPP.html"); + let graphs = graphs.to_string_lossy().to_string(); + open_with_web_browser(&graphs); + } + + Ok(()) +} diff --git a/rinex-cli/src/positioning/solver.rs b/rinex-cli/src/positioning/solver.rs deleted file mode 100644 index 8fe45347a..000000000 --- a/rinex-cli/src/positioning/solver.rs +++ /dev/null @@ -1,400 +0,0 @@ -use crate::Cli; -//use statrs::statistics::Statistics; - -use gnss::prelude::Constellation; // SV}; -use rinex::carrier::Carrier; -use rinex::navigation::Ephemeris; -use rinex::prelude::{Observable, Rinex, RnxContext}; - -use rtk::prelude::{ - AprioriPosition, BdModel, Candidate, Config, Duration, Epoch, InterpolationResult, - IonosphericBias, KbModel, Method, NgModel, Observation, PVTSolution, PVTSolutionType, Solver, - TroposphericBias, Vector3, -}; - -use map_3d::{ecef2geodetic, Ellipsoid}; -use std::collections::{BTreeMap, HashMap}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum Error { - #[error("solver error")] - SolverError(#[from] rtk::Error), - #[error("missing observations")] - MissingObservationData, - #[error("missing brdc navigation")] - MissingBroadcastNavigationData, - #[error("positioning requires overlapped SP3 data at the moment")] - MissingSp3Data, - #[error("undefined apriori position")] - UndefinedAprioriPosition, -} - -fn tropo_components(meteo: Option<&Rinex>, t: Epoch, lat_ddeg: f64) -> Option<(f64, f64)> { - const MAX_LATDDEG_DELTA: f64 = 15.0; - let max_dt = Duration::from_hours(24.0); - let rnx = meteo?; - let meteo = rnx.header.meteo.as_ref().unwrap(); - - let delays: Vec<(Observable, f64)> = meteo - .sensors - .iter() - .filter_map(|s| match s.observable { - Observable::ZenithDryDelay => { - let (x, y, z, _) = s.position?; - let (lat, _, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); - if (lat - lat_ddeg).abs() < MAX_LATDDEG_DELTA { - let value = rnx - .zenith_dry_delay() - .filter(|(t_sens, _)| (*t_sens - t).abs() < max_dt) - .min_by_key(|(t_sens, _)| (*t_sens - t).abs()); - let (_, value) = value?; - debug!("{:?} lat={} zdd {}", t, lat_ddeg, value); - Some((s.observable.clone(), value)) - } else { - None - } - }, - Observable::ZenithWetDelay => { - let (x, y, z, _) = s.position?; - let (lat, _, _) = ecef2geodetic(x, y, z, Ellipsoid::WGS84); - if (lat - lat_ddeg).abs() < MAX_LATDDEG_DELTA { - let value = rnx - .zenith_wet_delay() - .filter(|(t_sens, _)| (*t_sens - t).abs() < max_dt) - .min_by_key(|(t_sens, _)| (*t_sens - t).abs()); - let (_, value) = value?; - debug!("{:?} lat={} zdd {}", t, lat_ddeg, value); - Some((s.observable.clone(), value)) - } else { - None - } - }, - _ => None, - }) - .collect(); - - if delays.len() < 2 { - None - } else { - let zdd = delays - .iter() - .filter_map(|(obs, value)| { - if obs == &Observable::ZenithDryDelay { - Some(*value) - } else { - None - } - }) - .reduce(|k, _| k) - .unwrap(); - - let zwd = delays - .iter() - .filter_map(|(obs, value)| { - if obs == &Observable::ZenithWetDelay { - Some(*value) - } else { - None - } - }) - .reduce(|k, _| k) - .unwrap(); - - Some((zwd, zdd)) - } -} - -fn kb_model(nav: &Rinex, t: Epoch) -> Option { - let kb_model = nav - .klobuchar_models() - .min_by_key(|(t_i, _, _)| (t - *t_i).abs()); - - if let Some((_, sv, kb_model)) = kb_model { - Some(KbModel { - h_km: { - match sv.constellation { - Constellation::BeiDou => 375.0, - // we only expect GPS or BDS here, - // badly formed RINEX will generate errors in the solutions - _ => 350.0, - } - }, - alpha: kb_model.alpha, - beta: kb_model.beta, - }) - } else { - /* RINEX 3 case */ - let iono_corr = nav.header.ionod_correction?; - if let Some(kb_model) = iono_corr.as_klobuchar() { - Some(KbModel { - h_km: 350.0, //TODO improve this - alpha: kb_model.alpha, - beta: kb_model.beta, - }) - } else { - None - } - } -} - -fn bd_model(nav: &Rinex, t: Epoch) -> Option { - nav.bdgim_models() - .min_by_key(|(t_i, _)| (t - *t_i).abs()) - .map(|(_, model)| BdModel { alpha: model.alpha }) -} - -fn ng_model(nav: &Rinex, t: Epoch) -> Option { - nav.nequick_g_models() - .min_by_key(|(t_i, _)| (t - *t_i).abs()) - .map(|(_, model)| NgModel { a: model.a }) -} - -pub fn solver(ctx: &mut RnxContext, cli: &Cli) -> Result, Error> { - // parse custom config, if any - let cfg = match cli.config() { - Some(cfg) => cfg, - None => { - /* no manual config: we use the optimal known to this day */ - Config::preset(Method::SPP) - }, - }; - - match cfg.method { - Method::SPP => info!("single point positioning"), - Method::PPP => info!("precise point positioning"), - }; - - let pos = match cli.manual_position() { - Some(pos) => pos, - None => ctx - .ground_position() - .ok_or(Error::UndefinedAprioriPosition)?, - }; - - let apriori_ecef = pos.to_ecef_wgs84(); - let apriori = Vector3::::new(apriori_ecef.0, apriori_ecef.1, apriori_ecef.2); - let apriori = AprioriPosition::from_ecef(apriori); - - let lat_ddeg = apriori.geodetic[0]; - - // print config to be used - info!("{:#?}", cfg); - - let obs_data = match ctx.obs_data() { - Some(data) => data, - None => { - return Err(Error::MissingObservationData); - }, - }; - - let nav_data = match ctx.nav_data() { - Some(data) => data, - None => { - return Err(Error::MissingBroadcastNavigationData); - }, - }; - - let sp3_data = ctx.sp3_data(); - let sp3_has_clock = match sp3_data { - Some(sp3) => sp3.sv_clock().count() > 0, - None => false, - }; - - let meteo_data = ctx.meteo_data(); - - let mut solver = Solver::new( - &cfg, - apriori, - /* state vector interpolator */ - |t, sv, order| { - /* SP3 source is prefered */ - if let Some(sp3) = sp3_data { - if let Some((x, y, z)) = sp3.sv_position_interpolate(sv, t, order) { - let (x, y, z) = (x * 1.0E3, y * 1.0E3, z * 1.0E3); - let (elevation, azimuth) = - Ephemeris::elevation_azimuth((x, y, z), apriori_ecef); - Some( - InterpolationResult::from_mass_center_position((x, y, z)) - .with_elevation_azimuth((elevation, azimuth)), - ) - } else { - // debug!("{:?} ({}): sp3 interpolation failed", t, sv); - if let Some((x, y, z)) = nav_data.sv_position_interpolate(sv, t, order) { - let (x, y, z) = (x * 1.0E3, y * 1.0E3, z * 1.0E3); - let (elevation, azimuth) = - Ephemeris::elevation_azimuth((x, y, z), apriori_ecef); - Some( - InterpolationResult::from_apc_position((x, y, z)) - .with_elevation_azimuth((elevation, azimuth)), - ) - } else { - // debug!("{:?} ({}): nav interpolation failed", t, sv); - None - } - } - } else if let Some((x, y, z)) = nav_data.sv_position_interpolate(sv, t, order) { - let (x, y, z) = (x * 1.0E3, y * 1.0E3, z * 1.0E3); - let (elevation, azimuth) = Ephemeris::elevation_azimuth((x, y, z), apriori_ecef); - Some( - InterpolationResult::from_apc_position((x, y, z)) - .with_elevation_azimuth((elevation, azimuth)), - ) - } else { - // debug!("{:?} ({}): nav interpolation failed", t, sv); - None - } - }, - /* APC corrections provider */ - |_t, _sv, _freq| None, - )?; - - // resolved PVT solutions - let mut solutions: BTreeMap = BTreeMap::new(); - // possibly provided resolved T components (contained in RINEX) - let mut provided_clk: HashMap = HashMap::new(); - - for ((t, flag), (clk, vehicles)) in obs_data.observation() { - let mut candidates = Vec::::with_capacity(4); - - if !flag.is_ok() { - /* we only consider "OK" epochs" */ - continue; - } - - /* - * store possibly provided clk state estimator, - * so we can compare ours to this one later - */ - if let Some(clk) = clk { - provided_clk.insert(*t, *clk); - } - - for (sv, observations) in vehicles { - let sv_eph = nav_data.sv_ephemeris(*sv, *t); - if sv_eph.is_none() { - warn!("{:?} ({}) : undetermined ephemeris", t, sv); - continue; // can't proceed further - } - - let (toe, sv_eph) = sv_eph.unwrap(); - - /* - * Prefer SP3 for clock state (if any), - * otherwise, use brdc - */ - let clock_state = match sp3_has_clock { - true => { - let sp3 = sp3_data.unwrap(); - if let Some(_clk) = sp3 - .sv_clock() - .filter_map(|(sp3_t, sp3_sv, clk)| { - if sp3_t == *t && sp3_sv == *sv { - Some(clk * 1.0E-6) - } else { - None - } - }) - .reduce(|clk, _| clk) - { - let clock_state = sv_eph.sv_clock(); - Vector3::new(clock_state.0, 0.0_f64, 0.0_f64) - } else { - /* - * SP3 preference: abort on missing Epochs - */ - //continue ; - let clock_state = sv_eph.sv_clock(); - Vector3::new(clock_state.0, clock_state.1, clock_state.2) - } - }, - false => { - let clock_state = sv_eph.sv_clock(); - Vector3::new(clock_state.0, clock_state.1, clock_state.2) - }, - }; - - let clock_corr = Ephemeris::sv_clock_corr( - *sv, - (clock_state[0], clock_state[1], clock_state[2]), - *t, - toe, - ); - - let mut codes = Vec::::new(); - let mut phases = Vec::::new(); - let mut dopplers = Vec::::new(); - - for (observable, data) in observations { - if let Ok(carrier) = Carrier::from_observable(sv.constellation, observable) { - let frequency = carrier.frequency(); - - if observable.is_pseudorange_observable() { - codes.push(Observation { - frequency, - snr: { data.snr.map(|snr| snr.into()) }, - value: data.obs, - }); - } else if observable.is_phase_observable() { - phases.push(Observation { - frequency, - snr: { data.snr.map(|snr| snr.into()) }, - value: data.obs, - }); - } else if observable.is_doppler_observable() { - dopplers.push(Observation { - frequency, - snr: { data.snr.map(|snr| snr.into()) }, - value: data.obs, - }); - } - } - } - - if let Ok(candidate) = Candidate::new( - *sv, - *t, - clock_state, - clock_corr, - codes.clone(), - phases.clone(), - dopplers.clone(), - ) { - candidates.push(candidate); - } else { - warn!("{:?}: failed to form {} candidate", t, sv); - } - } - - // grab possible tropo components - let zwd_zdd = tropo_components(meteo_data, *t, lat_ddeg); - - let iono_bias = IonosphericBias { - kb_model: kb_model(nav_data, *t), - bd_model: bd_model(nav_data, *t), - ng_model: ng_model(nav_data, *t), - stec_meas: None, //TODO - }; - - let tropo_bias = TroposphericBias { - total: None, //TODO - zwd_zdd, - }; - - match solver.resolve( - *t, - PVTSolutionType::PositionVelocityTime, - candidates, - &iono_bias, - &tropo_bias, - ) { - Ok((t, pvt)) => { - debug!("{:?} : {:?}", t, pvt); - solutions.insert(t, pvt); - }, - Err(e) => warn!("{:?} : pvt solver error \"{}\"", t, e), - } - } - - Ok(solutions) -} diff --git a/rinex-cli/src/qc.rs b/rinex-cli/src/qc.rs new file mode 100644 index 000000000..940a07351 --- /dev/null +++ b/rinex-cli/src/qc.rs @@ -0,0 +1,52 @@ +//! File Quality opmode +use clap::ArgMatches; +use log::info; +use std::fs::{read_to_string, File}; +use std::io::Write; + +use crate::cli::Context; +use crate::fops::open_with_web_browser; +use crate::Error; +use rinex_qc::{QcOpts, QcReport}; + +pub fn qc_report(ctx: &Context, matches: &ArgMatches) -> Result<(), Error> { + let cfg = match matches.get_one::("cfg") { + Some(fp) => { + let content = read_to_string(fp) + .unwrap_or_else(|_| panic!("failed to read QC configuration: permission denied")); + let cfg = serde_json::from_str(&content) + .unwrap_or_else(|_| panic!("failed to parse QC configuration: invalid content")); + info!("using custom QC configuration: {:#?}", cfg); + cfg + }, + None => { + let cfg = QcOpts::default(); + info!("using default QC configuration: {:#?}", cfg); + cfg + }, + }; + + /* + * print more infos + */ + info!("Classification method : {:?}", cfg.classification); + info!("Reference position : {:?}", cfg.ground_position); + info!("Minimal SNR : {:?}", cfg.min_snr_db); + info!("Elevation mask : {:?}", cfg.elev_mask); + info!("Sampling gap tolerance: {:?}", cfg.gap_tolerance); + + let html = QcReport::html(&ctx.data, cfg); + let report_path = ctx.workspace.join("QC.html"); + + let mut fd = File::create(&report_path).map_err(|_| Error::QcReportCreationError)?; + + write!(fd, "{}", html).expect("failed to render HTML report"); + + info!("QC report \"{}\" has been generated", report_path.display()); + + if !ctx.quiet { + let fullpath = report_path.to_string_lossy().to_string(); + open_with_web_browser(&fullpath); + } + Ok(()) +} diff --git a/rinex-qc/Cargo.toml b/rinex-qc/Cargo.toml index 50f467330..73ad2f363 100644 --- a/rinex-qc/Cargo.toml +++ b/rinex-qc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rinex-qc" -version = "0.1.7" +version = "0.1.8" license = "MIT OR Apache-2.0" authors = ["Guillaume W. Bres "] description = "RINEX data analysis" @@ -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.2", features = ["full"] } +rinex = { path = "../rinex", version = "=0.15.3", features = ["full"] } gnss-rs = { version = "2.1.2", features = ["serde"] } [dev-dependencies] diff --git a/rinex/Cargo.toml b/rinex/Cargo.toml index a3d6b2d8b..9596dc383 100644 --- a/rinex/Cargo.toml +++ b/rinex/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rinex" -version = "0.15.2" +version = "0.15.3" license = "MIT OR Apache-2.0" authors = ["Guillaume W. Bres "] description = "Package to parse and analyze RINEX data" diff --git a/rinex/src/antex/antenna/mod.rs b/rinex/src/antex/antenna/mod.rs index 3b3fb50ae..48554cc49 100644 --- a/rinex/src/antex/antenna/mod.rs +++ b/rinex/src/antex/antenna/mod.rs @@ -145,6 +145,7 @@ pub enum AntennaMatcher { } impl AntennaMatcher { + #[cfg(feature = "antex")] pub(crate) fn to_lowercase(&self) -> Self { match self { Self::IGSCode(code) => Self::IGSCode(code.to_lowercase()), diff --git a/rinex/src/antex/record.rs b/rinex/src/antex/record.rs index ac1d5018c..eeb56d0bf 100644 --- a/rinex/src/antex/record.rs +++ b/rinex/src/antex/record.rs @@ -430,13 +430,13 @@ impl Merge for Record { } } if !has_signal { - subset.insert(carrier.clone(), freqdata.clone()); + subset.insert(*carrier, freqdata.clone()); } } } if !has_ant { let mut inner = HashMap::::new(); - inner.insert(carrier.clone(), freqdata.clone()); + inner.insert(*carrier, freqdata.clone()); self.push((antenna.clone(), inner)); } } diff --git a/rinex/src/header.rs b/rinex/src/header.rs index a890aba4c..2b2d892f8 100644 --- a/rinex/src/header.rs +++ b/rinex/src/header.rs @@ -980,7 +980,7 @@ impl Header { // The Klobuchar model needs two lines to be entirely described. if let Some(kb_model) = model.as_klobuchar() { let correction_type = content.split_at(5).0.trim(); - if correction_type.ends_with("B") { + if correction_type.ends_with('B') { let alpha = ionod_correction.unwrap().as_klobuchar().unwrap().alpha; let (beta, region) = (kb_model.beta, kb_model.region); ionod_correction = Some(IonMessage::KlobucharModel(KbModel { diff --git a/rinex/src/lib.rs b/rinex/src/lib.rs index 03544f6c2..b2618bbbf 100644 --- a/rinex/src/lib.rs +++ b/rinex/src/lib.rs @@ -587,6 +587,67 @@ impl Rinex { pub fn is_observation_rinex(&self) -> bool { self.header.rinex_type == types::Type::ObservationData } + + /// Generates a new RINEX = Self(=RINEX(A)) - RHS(=RINEX(B)). + /// Therefore RHS is considered reference. + /// 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 { + let mut record = observation::Record::default(); + let lhs_rec = self + .record + .as_obs() + .expect("can't substract other rinex format"); + + let rhs_rec = rhs + .record + .as_obs() + .expect("can't substract other rinex format"); + + for ((epoch, flag), (_, svnn)) in lhs_rec { + if let Some((_, 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 { + if let Some(ref_observation) = ref_observables.get(observable) { + if let Some((_, c_svnn)) = record.get_mut(&(*epoch, *flag)) { + if let Some(c_observables) = c_svnn.get_mut(sv) { + c_observables.insert( + observable.clone(), + ObservationData { + obs: observation.obs - ref_observation.obs, + lli: None, + snr: None, + }, + ); + } else { + // new observable + let mut inner = + HashMap::::new(); + inner.insert(observable.clone(), *observation); + c_svnn.insert(*sv, inner); + } + } else { + // new epoch + let mut map = HashMap::::new(); + map.insert(observable.clone(), *observation); + let mut inner = + BTreeMap::>::new(); + inner.insert(*sv, map); + record.insert((*epoch, *flag), (None, inner)); + } + } + } + } + } + } + } + + let rinex = Rinex::new(self.header.clone(), record::Record::ObsRecord(record)); + Ok(rinex) + } + /// Returns true if Differential Code Biases (DCBs) /// are compensated for, in this file, for this GNSS constellation. /// DCBs are biases due to tiny frequency differences, diff --git a/rinex/src/navigation/ionmessage.rs b/rinex/src/navigation/ionmessage.rs index b5e2d58f2..aa860630e 100644 --- a/rinex/src/navigation/ionmessage.rs +++ b/rinex/src/navigation/ionmessage.rs @@ -392,7 +392,7 @@ impl IonMessage { false => KbRegionCode::WideArea, }; /* determine which field we're dealing with */ - if corr_type.ends_with("A") { + if corr_type.ends_with('A') { let a0 = f64::from_str(a0.trim()).map_err(|_| Error::KbAlphaValueError)?; let a1 = f64::from_str(a1.trim()).map_err(|_| Error::KbAlphaValueError)?; let a2 = f64::from_str(a2.trim()).map_err(|_| Error::KbAlphaValueError)?; diff --git a/rnx2cggtts/Cargo.toml b/rnx2cggtts/Cargo.toml deleted file mode 100644 index 7424e5759..000000000 --- a/rnx2cggtts/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "rnx2cggtts" -version = "1.0.2" -license = "MIT OR Apache-2.0" -authors = ["Guillaume W. Bres "] -description = "CGGTTS data generation from RINEX" -homepage = "https://github.com/georust/rinex" -repository = "https://github.com/georust/rinex" -keywords = ["rinex", "timing", "gps"] -categories = ["science", "science::geo", "command-line-interface", "command-line-utilities"] -edition = "2021" -readme = "README.md" - -[dependencies] -log = "0.4" -chrono = "0.4" -thiserror = "1" -walkdir = "2.4.0" -serde_json = "1" -# statrs = "0.16" -map_3d = "0.1.5" -env_logger = "0.10" -gnss-rs = { version = "2.1.2" , features = ["serde"] } -clap = { version = "4.4.10", features = ["derive", "color"] } -serde = { version = "1.0", default-features = false, features = ["derive"] } -rinex = { path = "../rinex", version = "=0.15.2", features = ["full"] } - -# cggtts -cggtts = { version = "4.1.1", features = ["serde", "scheduler"] } -# cggtts = { git = "https://github.com/gwbres/cggtts", branch = "develop", features = ["serde", "scheduler"] } -# cggtts = { path = "../../cggtts/cggtts", features = ["serde", "scheduler"] } - -# solver -gnss-rtk = { version = "0.4.1", features = ["serde"] } -# gnss-rtk = { git = "https://github.com/rtk-rs/gnss-rtk", branch = "develop", features = ["serde"] } -# gnss-rtk = { path = "../../rtk-rs/gnss-rtk", features = ["serde"] } diff --git a/rnx2cggtts/README.md b/rnx2cggtts/README.md deleted file mode 100644 index fb4f5a847..000000000 --- a/rnx2cggtts/README.md +++ /dev/null @@ -1,131 +0,0 @@ -RNX2CGGTTS -========== - -[![crates.io](https://img.shields.io/crates/v/rnx2cggtts.svg)](https://crates.io/crates/rnx2cggtts) -[![License](https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square)](https://github.com/gwbres/rinex/blob/main/LICENSE-APACHE) -[![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](https://github.com/gwbres/rinex/blob/main/LICENSE-MIT) - -`RNX2CGGTTS` is a command line tool to convert a RINEX context to CGGTTS. - -This operation is special and dedicated to high precision timing and time transfer in particular. - -CGGTTS is defined by BIPM and supported by the [cggtts-rs library](https://github.com/gwbres/cggtts). - -:warning: This application only works with CGGTTS 2E (latest revision) :warning: - -## RINEX and sampling context - -From a coherent RINEX context generated by a single receiver from which we precisely know the location, -RNX2CGGTTS uses the position solver to precisely resolve the receiver clock state. - -This operation is the combination of the following libraries : - -- RINEX: to define a coherent RINEX context and parse it -- CGGTTS: to generate a CGGTTS file -- the Nyx Space library is used in the position solver -- Nyx Hifitime to define time and timescales accurately - -## Requirements - -RINEX2CGGTTS requires at least one Observation RINEX file, and associated Navigation RINEX. -SP3 is also ideal and highly recommended to be provided. - -You should always inject data that comes from the same station (unique GNSS receiver). -Thefore, we don't recommend loading Observation data using the `-d` option, -unless you store them in dedicated folders. -Loading each individual file with `-f` is the most reasonnable approach. - -## Getting started - -Build the application with cargo. The RINEX ecosystem has some requirements on the minimal rustc version, refer to the general README : - -```bash -git clone https://github.com/georust/rinex -cargo build --release -./target/release/rnx2cggtts -h -``` - -RNX2CGGTTS uses the same RINEX context definition and loading -interface as RINEXCLI. Follow its [guidelines](https://github.com/georust/rinex/tree/main/rinex-cli) to learn how to load your data context. - -RNX2CGGTTS shares the same behavior and interface as RINEXCLI with activate positioning. Refer to the positioning part of its documentation and the gnss-rtk solver, to fully understand how to configure and operate the solver. - -RNX2CGGTTS can preprocess the RINEX context as the RINXCLI application, learn how to -[operate the preprocessor](https://github.com/georust/rinex/blob/main/rinex-cli/doc/preprocessing.md) -to operate this tool efficiently. - -## Command line example - -Use a combination of the following arguments to load your context: - -- `-f` (`--fp`) to load files individually -- `-d` to load a directory recursively. - -Accepted files : - -- RINEX (<= V4) -- SP3 - -Command line example : - -```bash -TODO -``` - -## :Warning: receiver coordinates - -TODO - -## Accurate hardware setup definitions - -In CGGTTS we're talking about 0.1 ns errors, therefore every fraction of delay counts. - -The GNSS-RTK solver configuration can take into account two hardware induced source of delay -[that are defined right here] and need to be correctly characterized and defined in your RNX2CGGTTS ops. - -You can either define them in your configuration file (loaded with `-c`), or define them indivually with - -* - use `--rf-delay [ns]` to define the delay induced by the antenna and the RF cable prior the GNSS receiver -* - use `--ref-delay [ns]` to define the cable delay between the receiver and its external 10MHz/1PPS source - -Note that both delays are always defined in nanoseconds (f64), whether it is through the command line interface -or the configuration file. - -## Generated Data - -RINEX2CGGTTS will generate : - -- a CGGTTS file named after the station (receiver) being used. -The CGGTTS contains one track -- the same txt file you get in RINXCLI with active positioning, but focused on timing components, the spatial coordinates are removed -- txt file visualization, when `--no-graph` is not specified - -## Synchronous CGGTTS - -RNX2CGGTTS will only form __synchronous__ CGGTTS tracks as defined by BIPM. -Historically, the definition was closely tied to GPS ephemerides and receiver/hardware behavior and limitations. -Nowadays, we can safely say the defined scheduling is only there to provide synchronous CGGTTS tracks on both remote sites. -It is then easier to exchange CGGTTS files and perform the remote clock comparison: you don't need to interpolate anything. - -## :articial_satellite: Common View Time Transfer using CGGTTS files - -Using RNX2CGGTTS (this app) and CGGTTS post processing [that app], we can perform a ""Time Transfer"" using a Common View method. -The need here is to compare two high quality but remote clocks. -For that, we use two local GNSS receivers that generate RINEX data. -With RNX2CGGTTS we resolve the local clock state against GPST (arbitrary). -Do that on both RINEX context and regroup the two CGGTTS tracks so we can compare them. - -To illustrate that, we will two complete RINEX contexts from CDDDIS portal. -I chose station AJAC located in South Corsica (France) and Nialesun (Norway) on day XXX year 2023. - -Resolve the clock state and format it into CGGTTS, on both sites, using RNX2CGGTTS : - -```bash -provide two examples here please -``` - -Compare the two remote clocks to one another using CGGTTSCLI : - -```bash -example results please -``` diff --git a/rnx2cggtts/src/cli.rs b/rnx2cggtts/src/cli.rs deleted file mode 100644 index 40f9cce57..000000000 --- a/rnx2cggtts/src/cli.rs +++ /dev/null @@ -1,361 +0,0 @@ -use clap::{Arg, ArgAction, ArgMatches, ColorChoice, Command}; -use log::{error, info}; -use std::collections::HashMap; - -use std::str::FromStr; - -pub struct Cli { - /// Arguments passed by user - matches: ArgMatches, -} - -use cggtts::{prelude::ReferenceTime, track::Scheduler}; -use gnss_rtk::prelude::Config; -use rinex::prelude::*; - -impl Cli { - /// Build new command line interface - pub fn new() -> Self { - Self { - matches: { - Command::new("rnx2cggtts") - .author("Guillaume W. Bres, ") - .version(env!("CARGO_PKG_VERSION")) - .about("CGGTTS from RINEX Data generation tool") - .arg_required_else_help(true) - .color(ColorChoice::Always) - .arg(Arg::new("filepath") - .short('f') - .long("fp") - .value_name("FILE") - .action(ArgAction::Append) - .required_unless_present("directory") - .help("Input RINEX file. Can be any kind of RINEX or SP3, -and you can load as many as you want.")) - .arg(Arg::new("directory") - .short('d') - .long("dir") - .value_name("DIRECTORY") - .required_unless_present("filepath") - .help("Load directory recursively. RINEX and SP3 files are identified -and added like they were individually imported with -f. -You can load as many directories as you need.")) - .arg(Arg::new("workspace") - .short('w') - .long("workspace") - .value_name("FOLDER") - .help("Customize workspace location (folder does not have to exist). -The default workspace is rinex-cli/workspace")) - .next_help_heading("CGGTTS") - .arg(Arg::new("custom-clock") - .long("clk") - .value_name("NAME") - .help("Set the name of your local custom clock (in case it's a UTC replica, prefer -u).")) - .arg(Arg::new("utck") - .short('u') - .value_name("NAME") - .help("Set the name of your local UTC replica. In case your local clock is not tracking UTC, prefer --clk.")) - .arg(Arg::new("station") - .short('s') - .value_name("NAME") - .help("Define / override the station name. If not specified, we expect the input -RINEX Observations to follow naming conventions and we deduce the station name from the filename.")) - .arg(Arg::new("filename") - .short('o') - .value_name("FILENAME") - .help("Set CGGTTS filename to be generated (within workspace). -When not defined, the CGGTTS follows naming conventions, and is named after the Station and Receiver definitions.")) - .next_help_heading("Antenna") - .arg(Arg::new("apc") - .short('a') - .long("apc") - .value_name("\"lat, lon, alt\" coordinates triplet in ddeg") - .help("Define the APC coordinates manually as Lat, Lon, Alt in decimal degrees. -If not defined, the RINEX context must provide it. -Either one is mandatory for the RNX2CGGTTS ops.")) - .arg(Arg::new("apc-ecef") - .long("apc-ecef") - .value_name("\"x, y, z\" coordinates triplet in ECEF [m]") - .help("Define the APC position manually as ECEF [m]. -If not defined, the RINEX context must provide it. -Either one is mandatory for the RNX2CGGTTS ops.")) - .next_help_heading("Sky tracking & Common View") - .arg(Arg::new("tracking") - .short('t') - .value_name("DURATION") - .help("Modify tracking duration: default is 780s + 3' as defined by BIPM. -You can't modify the tracking duration unless you have complete control on both remote sites.")) - .arg(Arg::new("single-sv") - .long("sv") - .value_name("SV") - .help("Track single (unique) Space Vehicle that must be in plain sight on both remote sites.")) - .next_help_heading("Setup / Hardware") - .arg(Arg::new("rfdly") - .long("rf-delay") - .action(ArgAction::Append) - .help("Specify the RF delay (frequency dependent), in nanoseconds. -Ideally, you should provide a time delay for all codes used by the solver. -For example, specify a 3.2 nanoseconds delay on C1 with: --rf-delay C1:3.2.")) - .arg(Arg::new("refdly") - .long("ref-delay") - .help("Specify the delay between the GNSS receiver external clock and its local sampling clock. -This is the delay induced by the cable on the external ref clock. Specify it in nanoseconds, for example: --ref-delay 5.0")) - .next_help_heading("Solver") - .arg(Arg::new("config") - .long("cfg") - .short('c') - .value_name("FILE") - .help("Pass Positioning configuration, refer to README.")) - .arg(Arg::new("spp") - .long("spp") - .conflicts_with("ppp") - .action(ArgAction::SetTrue) - .help("Force solving strategy to SPP (default is LSQSPP).")) - .arg(Arg::new("ppp") - .long("ppp") - .conflicts_with("spp") - .action(ArgAction::SetTrue) - .help("Force solving strategy to PPP (default is LSQSPP).")) - .next_help_heading("Preprocessing") - .arg(Arg::new("gps-filter") - .short('G') - .action(ArgAction::SetTrue) - .help("Filters out all GPS vehicles")) - .arg(Arg::new("glo-filter") - .short('R') - .action(ArgAction::SetTrue) - .help("Filters out all Glonass vehicles")) - .arg(Arg::new("gal-filter") - .short('E') - .action(ArgAction::SetTrue) - .help("Filters out all Galileo vehicles")) - .arg(Arg::new("bds-filter") - .short('C') - .action(ArgAction::SetTrue) - .help("Filters out all BeiDou vehicles")) - .arg(Arg::new("qzss-filter") - .short('J') - .action(ArgAction::SetTrue) - .help("Filters out all QZSS vehicles")) - .arg(Arg::new("irnss-filter") - .short('I') - .action(ArgAction::SetTrue) - .help("Filters out all IRNSS vehicles")) - .arg(Arg::new("sbas-filter") - .short('S') - .action(ArgAction::SetTrue) - .help("Filters out all SBAS vehicles")) - .arg(Arg::new("preprocessing") - .short('P') - .num_args(1..) - .action(ArgAction::Append) - .help("Design preprocessing operations. -Refer to rinex-cli Preprocessor documentation for more information")) - .get_matches() - }, - } - } - /// Returns list of input directories - pub fn input_directories(&self) -> Vec<&String> { - if let Some(fp) = self.matches.get_many::("directory") { - fp.collect() - } else { - Vec::new() - } - } - /// Returns individual input filepaths - pub fn input_files(&self) -> Vec<&String> { - if let Some(fp) = self.matches.get_many::("filepath") { - fp.collect() - } else { - Vec::new() - } - } - pub fn preprocessing(&self) -> Vec<&String> { - if let Some(filters) = self.matches.get_many::("preprocessing") { - filters.collect() - } else { - Vec::new() - } - } - pub fn gps_filter(&self) -> bool { - self.matches.get_flag("gps-filter") - } - pub fn glo_filter(&self) -> bool { - self.matches.get_flag("glo-filter") - } - pub fn gal_filter(&self) -> bool { - self.matches.get_flag("gal-filter") - } - pub fn bds_filter(&self) -> bool { - self.matches.get_flag("bds-filter") - } - pub fn qzss_filter(&self) -> bool { - self.matches.get_flag("qzss-filter") - } - pub fn sbas_filter(&self) -> bool { - self.matches.get_flag("sbas-filter") - } - pub fn irnss_filter(&self) -> bool { - self.matches.get_flag("irnss-filter") - } - fn get_flag(&self, flag: &str) -> bool { - self.matches.get_flag(flag) - } - /// Returns the manualy defined RFDLY (in nanoseconds!) - pub fn rf_delay(&self) -> Option> { - self.matches.get_many::("rfdly").map(|delays| { - delays - .into_iter() - .filter_map(|string| { - let items: Vec<_> = string.split(':').collect(); - if items.len() < 2 { - error!("format error, command should be --rf-delay CODE:[nanos]"); - None - } else { - let code = items[0].trim(); - let nanos = items[0].trim(); - if let Ok(code) = Observable::from_str(code) { - if let Ok(f) = nanos.parse::() { - Some((code, f)) - } else { - error!("invalid nanos: expecting valid f64"); - None - } - } else { - error!("invalid pseudo range CODE, expecting codes like \"L1C\",..."); - None - } - } - }) - .collect() - }) - } - /// Returns the manualy defined REFDLY (in nanoseconds!) - pub fn reference_time_delay(&self) -> Option { - if let Some(s) = self.matches.get_one::("refdly") { - if let Ok(f) = s.parse::() { - info!("reference time delay manually defined"); - Some(f) - } else { - error!("reference time delay should be valid nanoseconds value"); - None - } - } else { - None - } - } - fn manual_ecef(&self) -> Option<&String> { - self.matches.get_one::("antenna-ecef") - } - fn manual_geodetic(&self) -> Option<&String> { - self.matches.get_one::("antenna-geo") - } - pub fn config(&self) -> Option { - if let Some(path) = self.matches.get_one::("config") { - if let Ok(content) = std::fs::read_to_string(path) { - let opts = serde_json::from_str(&content); - if let Ok(opts) = opts { - info!("loaded rtk config: \"{}\"", path); - return Some(opts); - } else { - panic!("failed to parse config file \"{}\"", path); - } - } else { - error!("failed to read config file \"{}\"", path); - info!("using default parameters"); - } - } - None - } - pub fn tracking_duration(&self) -> Duration { - if let Some(t) = self.matches.get_one::("tracking") { - if let Ok(dt) = Duration::from_str(t.trim()) { - warn!("using custom traking duration {}", dt); - dt - } else { - panic!("incorrect tracking duration specification"); - } - } else { - Duration::from_seconds(Scheduler::BIPM_TRACKING_DURATION_SECONDS.into()) - } - } - fn utck(&self) -> Option<&String> { - self.matches.get_one::("utck") - } - fn custom_clock(&self) -> Option<&String> { - self.matches.get_one::("custom-clock") - } - /* reference time to use in header formatting */ - pub fn reference_time(&self) -> ReferenceTime { - if let Some(utck) = self.utck() { - ReferenceTime::UTCk(utck.clone()) - } else if let Some(clk) = self.custom_clock() { - ReferenceTime::Custom(clk.clone()) - } else { - ReferenceTime::Custom("Unknown".to_string()) - } - } - /* custom station name */ - pub fn custom_station(&self) -> Option<&String> { - self.matches.get_one::("station") - } - /* custom workspace */ - pub fn custom_workspace(&self) -> Option<&String> { - self.matches.get_one::("workspace") - } - /* custom filename */ - pub fn custom_filename(&self) -> Option<&String> { - self.matches.get_one::("filename") - } - /* manual APC coordinates */ - fn manual_apc_ecef(&self) -> Option<&String> { - self.matches.get_one::("apc-ecef") - } - /* manual APC coordinates */ - fn manual_apc_geodetic(&self) -> Option<&String> { - self.matches.get_one::("apc") - } - /* manual APC coordinates */ - pub fn manual_apc(&self) -> Option { - if let Some(args) = self.manual_apc_ecef() { - let content: Vec<&str> = args.split(',').collect(); - if content.len() != 3 { - panic!("expecting \"x, y, z\" description"); - } - if let Ok(pos_x) = f64::from_str(content[0].trim()) { - if let Ok(pos_y) = f64::from_str(content[1].trim()) { - if let Ok(pos_z) = f64::from_str(content[2].trim()) { - return Some(GroundPosition::from_ecef_wgs84((pos_x, pos_y, pos_z))); - } else { - panic!("pos(z) should be f64 ECEF [m]"); - } - } else { - panic!("pos(y) should be f64 ECEF [m]"); - } - } else { - panic!("pos(x) should be f64 ECEF [m]"); - } - } else if let Some(args) = self.manual_apc_geodetic() { - let content: Vec<&str> = args.split(',').collect(); - if content.len() != 3 { - panic!("expecting \"lat, lon, alt\" description"); - } - if let Ok(lat) = f64::from_str(content[0].trim()) { - if let Ok(long) = f64::from_str(content[1].trim()) { - if let Ok(alt) = f64::from_str(content[2].trim()) { - return Some(GroundPosition::from_geodetic((lat, long, alt))); - } else { - panic!("altitude should be f64 [ddeg]"); - } - } else { - panic!("altitude should be f64 [ddeg]"); - } - } else { - panic!("altitude should be f64 [ddeg]"); - } - } - - None - } -} diff --git a/rnx2cggtts/src/main.rs b/rnx2cggtts/src/main.rs deleted file mode 100644 index 76f80cdbc..000000000 --- a/rnx2cggtts/src/main.rs +++ /dev/null @@ -1,264 +0,0 @@ -#[macro_use] -extern crate log; - -extern crate gnss_rs as gnss; -extern crate gnss_rtk as rtk; - -use rinex::prelude::*; - -use cggtts::prelude::*; -use cggtts::Coordinates; - -use cli::Cli; - -use env_logger::{Builder, Target}; - -// use std::collections::HashMap; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use thiserror::Error; - -mod cli; // command line interface - -mod preprocessing; -use preprocessing::preprocess; - -mod solver; - -use std::fs::File; - -#[derive(Debug, Error)] -pub enum Error { - #[error("rinex error")] - RinexError(#[from] rinex::Error), - #[error("failed to format cggtts")] - CggttsWriteError(#[from] std::io::Error), -} - -/* - * Utility : determines the file stem of most major RINEX file in the context - */ -pub(crate) fn context_stem(ctx: &RnxContext) -> String { - let ctx_major_stem: &str = ctx - .rinex_path() - .expect("failed to determine a context name") - .file_stem() - .expect("failed to determine a context name") - .to_str() - .expect("failed to determine a context name"); - /* - * In case $FILENAME.RNX.gz gz compressed, we extract "$FILENAME". - * Can use .file_name() once https://github.com/rust-lang/rust/issues/86319 is stabilized - */ - let primary_stem: Vec<&str> = ctx_major_stem.split('.').collect(); - primary_stem[0].to_string() -} - -/* - * Workspace location is fixed to rinex-cli/product/$primary - * at the moment - */ -pub fn workspace_path(ctx: &RnxContext) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("WORKSPACE") - .join(context_stem(ctx)) -} - -/* - * Helper to create the workspace, ie.: where all reports - * get generated and saved. - */ -pub fn create_workspace(path: PathBuf) { - std::fs::create_dir_all(&path).unwrap_or_else(|_| { - panic!( - "failed to create Workspace \"{}\": permission denied!", - path.to_string_lossy() - ) - }); -} - -use walkdir::WalkDir; - -/* - * Creates File/Data context defined by user. - * Regroups all provided files/folders, - */ -fn build_context(cli: &Cli) -> RnxContext { - let mut ctx = RnxContext::default(); - /* load all directories recursively, one by one */ - for dir in cli.input_directories() { - let walkdir = WalkDir::new(dir).max_depth(5); - 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 = ctx.load(&filepath); - if ret.is_err() { - warn!("failed to load \"{}\": {}", filepath, ret.err().unwrap()); - } - } - } - } - // load individual files, if any - for filepath in cli.input_files() { - let ret = ctx.load(filepath); - if ret.is_err() { - warn!("failed to load \"{}\": {}", filepath, ret.err().unwrap()); - } - } - ctx -} - -pub fn main() -> Result<(), Error> { - let mut builder = Builder::from_default_env(); - builder - .target(Target::Stdout) - .format_timestamp_secs() - .format_module_path(false) - .init(); - - // Cli - let cli = Cli::new(); - - // Build context - let mut ctx = build_context(&cli); - - // Build position solver - if ctx.sp3_data().is_none() { - error!("SP3 must unfortunately be provided at the moment"); - return Ok(()); - } - - // Workspace - let workspace = match cli.custom_workspace() { - Some(workspace) => Path::new(workspace).join(context_stem(&ctx)).to_path_buf(), - None => workspace_path(&ctx), - }; - create_workspace(workspace.clone()); - info!("workspace is \"{}\"", workspace.to_string_lossy()); - - /* - * Verify provided context and feasibility - */ - if ctx.obs_data().is_none() { - panic!("rnx2cggtts requires Observation Data to be provided!"); - } - if ctx.nav_data().is_none() { - panic!("rnx2cggtts requires BRDC Navigation Data to be provided!"); - } - if ctx.sp3_data().is_none() { - panic!("rnx2cggtts requires SP3 Data to be provided!"); - } - if ctx.meteo_data().is_some() { - info!("meteo data loaded"); - } - if ctx.ionex_data().is_some() { - info!("ionex data loaded"); - } - - /* - * System delay(s) to be compensated - */ - if let Some(rf_delay) = cli.rf_delay() { - for (code, delay_ns) in rf_delay { - // solver.cfg.internal_delay.insert(code.clone(), delay_ns); - info!("RF delay: {} : {} [ns]", code.clone(), delay_ns); - } - } - if let Some(delay_ns) = cli.reference_time_delay() { - // solver.cfg.time_ref_delay = Some(delay_ns); - info!("REFERENCE delay: {} [ns]", delay_ns); - } - - /* - * Preprocessing - */ - preprocess(&mut ctx, &cli); - - /* - * Form CGGTTS - */ - let obs_data = ctx.obs_data().unwrap(); - - let rcvr = match &obs_data.header.rcvr { - Some(rcvr) => Rcvr { - manufacturer: String::from("XX"), - model: rcvr.model.clone(), - serial_number: rcvr.sn.clone(), - year: 0, - release: rcvr.firmware.clone(), - }, - None => Rcvr::default(), - }; - - let station: String = match cli.custom_station() { - Some(station) => station.to_string(), - None => { - let stem = context_stem(&ctx); - if let Some(index) = stem.find('_') { - stem[..index].to_string() - } else { - String::from("LAB") - } - }, - }; - - let mut cggtts = CGGTTS::default() - .station(&station) - .nb_channels(1) // TODO: improve this ? - .receiver(rcvr.clone()) - .ims(rcvr.clone()) // TODO : improve this ? - .apc_coordinates({ - if let Some(apc) = cli.manual_apc() { - let (lat, lon, _) = apc.to_geodetic(); - info!( - "manually defined APC {} (lat={:.5}°, lon={:.5}°)", - apc, lat, lon - ); - - let (x, y, z) = apc.to_ecef_wgs84(); - Coordinates { x, y, z } - } else if let Some(pos) = ctx.ground_position() { - let (lat, lon, _) = pos.to_geodetic(); - info!( - "using reference position {} (lat={:.5}°, lon={:.5}°)", - pos, lat, lon - ); - - // TODO is this correct ? - // needs proably some adjustment - let (x, y, z) = pos.to_ecef_wgs84(); - Coordinates { x, y, z } - } else { - panic!("context has undetermined APC coordinates"); - } - }) - .reference_time(cli.reference_time()) - .reference_frame("WGS84") - .comments(&format!("rnx2cggtts v{}", env!("CARGO_PKG_VERSION"))); - - /* - * Form TRACKS - */ - let tracks = solver::resolve(&mut ctx, &cli); - - if let Ok(tracks) = tracks { - for track in tracks { - cggtts.tracks.push(track); - } - } - - /* - * Create file - */ - let filename = match cli.custom_filename() { - Some(filename) => workspace.join(filename), - None => workspace.join(cggtts.filename()), - }; - - let mut fd = File::create(&filename)?; - write!(fd, "{}", cggtts)?; - info!("{} has been generated", filename.to_string_lossy()); - Ok(()) -} diff --git a/rnx2cggtts/src/preprocessing.rs b/rnx2cggtts/src/preprocessing.rs deleted file mode 100644 index 6bc1fd562..000000000 --- a/rnx2cggtts/src/preprocessing.rs +++ /dev/null @@ -1,76 +0,0 @@ -use log::error; -use std::str::FromStr; - -use crate::Cli; -use rinex::prelude::RnxContext; -use rinex::preprocessing::*; - -pub fn preprocess(ctx: &mut RnxContext, cli: &Cli) { - // GNSS filters - let mut gnss_filters: Vec<&str> = Vec::new(); - - if cli.gps_filter() { - gnss_filters.push("!=gps"); - trace!("applying -G filter.."); - } - if cli.glo_filter() { - gnss_filters.push("!=glo"); - trace!("applying -R filter.."); - } - if cli.gal_filter() { - gnss_filters.push("!=gal"); - trace!("applying -E filter.."); - } - if cli.bds_filter() { - gnss_filters.push("!=bds"); - trace!("applying -C filter.."); - } - if cli.sbas_filter() { - gnss_filters.push("!=geo"); - trace!("applying -S filter.."); - } - if cli.qzss_filter() { - gnss_filters.push("!=qzss"); - trace!("applying -J filter.."); - } - if cli.irnss_filter() { - gnss_filters.push("!=irnss"); - trace!("applying -I filter.."); - } - - for filt in gnss_filters { - let filt = Filter::from_str(filt).unwrap(); // cannot fail - if let Some(ref mut obs) = ctx.obs_data_mut() { - obs.filter_mut(filt.clone()); - } - if let Some(ref mut nav) = ctx.nav_data_mut() { - nav.filter_mut(filt.clone()); - } - } - - for filt_str in cli.preprocessing() { - /* special case : only apply to observ dataset */ - let only_obs = filt_str.starts_with("observ:"); - let offset: usize = match only_obs { - true => 7, // "observ:" - false => 0, - }; - if let Ok(filt) = Filter::from_str(&filt_str[offset..]) { - if let Some(ref mut data) = ctx.obs_data_mut() { - data.filter_mut(filt.clone()); - } - if let Some(ref mut data) = ctx.meteo_data_mut() { - data.filter_mut(filt.clone()); - } - if let Some(ref mut data) = ctx.nav_data_mut() { - data.filter_mut(filt.clone()); - } - if let Some(ref mut data) = ctx.ionex_data_mut() { - data.filter_mut(filt.clone()); - } - trace!("applied filter \"{}\"", filt_str); - } else { - error!("invalid filter description \"{}\"", filt_str); - } - } -} diff --git a/rnx2crx/Cargo.toml b/rnx2crx/Cargo.toml index e747836a4..30c6a238f 100644 --- a/rnx2crx/Cargo.toml +++ b/rnx2crx/Cargo.toml @@ -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.2", features = ["serde"] } +rinex = { path = "../rinex", version = "=0.15.3", features = ["serde"] } diff --git a/tools/test-binaries.sh b/tools/test-binaries.sh new file mode 100755 index 000000000..a1431da60 --- /dev/null +++ b/tools/test-binaries.sh @@ -0,0 +1,97 @@ +#! /bin/sh +set -e +############################################## +# binaries tester: to be used in CI and +# provide at least basic means to test our CLI +############################################## + +############ +# 1. CRX2RNX +############ +./target/release/crx2rnx \ + -f test_resources/CRNX/V3/KMS300DNK_R_20221591000_01H_30S_MO.crx.gz + +############ +# 2. RNX2CRX +############ +./target/release/rnx2crx \ + -f test_resources/OBS/V3/pdel0010.21o + +############################# +# 3. OBS RINEX identification +############################# +./target/release/rinex-cli \ + -f test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz \ + -i -a + +############################# +# 4. NAV RINEX identification +############################# +./target/release/rinex-cli \ + -f test_resources/NAV/V3/ESBC00DNK_R_20201770000_01D_MN.rnx.gz \ + -i -a + +#################### +# 5. OBS RINEX merge +#################### +./target/release/rinex-cli \ + -f test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz \ + -m test_resources/CRNX/V3/MOJN00DNK_R_20201770000_01D_30S_MO.crx.gz + +#################### +# 6. OBS RINEX split +#################### +./target/release/rinex-cli \ + -f test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz \ + -s "2020-06-25T10:00:00 UTC" + +########################### +# 7. OBS RINEX time binning +########################### +./target/release/rinex-cli \ + -f test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz \ + --tbin "4 hour" + +###################### +# 8. GNSS combinations +###################### +./target/release/rinex-cli \ + -f test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz \ + -g --if --nl --wl --mw --mp --dcb --gf + +################# +# 9. OBS RINEX QC +################# +./target/release/rinex-cli \ + -f test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz \ + -Q + +#################################### +# 9. (advanced): state of the art QC +#################################### +./target/release/rinex-cli \ + -f test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz \ + -f test_resources/NAV/V3/ESBC00DNK_R_20201770000_01D_MN.rnx.gz \ + -f test_resources/SP3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3.gz \ + -f test_resources/SP3/GRG0MGXFIN_20201770000_01D_15M_ORB.SP3.gz \ + -Q + +# #################### +# # 10. (advanced) SPP +# #################### +# ./target/release/rinex-cli \ +# -f test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz \ +# -f test_resources/NAV/V3/ESBC00DNK_R_20201770000_01D_MN.rnx.gz \ +# -f test_resources/SP3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3.gz \ +# -f test_resources/SP3/GRG0MGXFIN_20201770000_01D_15M_ORB.SP3.gz \ +# -p --spp +# +# ############################### +# # 11. (advanced) SPP RNX2CGGTTS +# ############################### +# ./target/release/rinex-cli \ +# -f test_resources/CRNX/V3/ESBC00DNK_R_20201770000_01D_30S_MO.crx.gz \ +# -f test_resources/NAV/V3/ESBC00DNK_R_20201770000_01D_MN.rnx.gz \ +# -f test_resources/SP3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3.gz \ +# -f test_resources/SP3/GRG0MGXFIN_20201770000_01D_15M_ORB.SP3.gz \ +# -p --spp --cggtts diff --git a/ublox-rnx/Cargo.toml b/ublox-rnx/Cargo.toml index a334d7e14..1218d351f 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.2", features = ["serde", "nav", "obs"] } +rinex = { path = "../rinex", version = "=0.15.3", features = ["serde", "nav", "obs"] }