diff --git a/CHANGELOG.md b/CHANGELOG.md index cea31a8dd..d697cc503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,47 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +# [4.1] 2026-01-30 + +## Added + +Ginan SouthPAN SBAS capabilities: +- Separate SBAS processing modes +- Choice of running L1 SBAS, DFMC (dual-frequency multi-constellation) and PVS (Precise Point Positioning Via SouthPAN) +- SBF (Septentrio) input +- Included sanity check configurations for SBAS, DFMC and PVS + +Additional features and fixes to the GinanUI: +- "Constellations" config tab for managing code priorities for the selected PPP provider/series/project +- "Output" config tab for specifying PEA output files +- "Reset Config" button to reset the UI and configuration to a blank state +- Support for downloading products from the REPRO3 directory for older RINEX files +- Verification of PPP product constellations against RINEX constellations using the corresponding .SP3 file +- Detection of supported code priorities for PPP products using the corresponding .BIA file +- Button to open the Ginan-UI user manual from the interface + +## Changed + +GinanUI changes: +- UI panels are now resizable +- Disabled plot visualisation when the corresponding output file is not enabled + +## Fixed + +Ginan fixes: +- Fixed SSR uploading issues - RTCM message codes were incorrectly assigned +- Fixed Code bias state errors - Force state errors to zero if corresponding states are fully constrained (zero variances). + +GinanUI fixes: +- Fixed detecting available dynamic products when version identifier is not "0". Will now search for the lowest valid version identifier +- Fixed input locking while processing is running to prevent post-hoc configuration changes + +## Deprecated + +## Removed + +## Security + # [4.0] 2025-12-16 ## Added diff --git a/Docs/announcements.md b/Docs/announcements.md index 5a53abb63..24c1ad9cf 100644 --- a/Docs/announcements.md +++ b/Docs/announcements.md @@ -1,4 +1,19 @@ +> **30 Jan 2026** - the Ginan team is pleased to release Ginan update v4.1.0 +> +> **Highlights**: +> +> * Introduction of SouthPAN SBAS capabilities into Ginan: +> * Choice of running L1 SBAS, DFMC (dual-frequency multi-constellation) and PVS (Precise Point Positioning Via SouthPAN) +> * Includes SBF (Septentrio) input +> * Included sanity check configurations for SBAS, DFMC and PVS +> * Additions to the Graphical User Interface (GUI) for Ginan - GinanUI: +> * "Constellations" config tab for managing code priorities for the selected PPP provider/series/project +> * Support for downloading products from the REPRO3 directory for older RINEX files +> * Detection of supported code priorities for PPP products using the corresponding .BIA file +> * Verification of PPP product constellations using the corresponding .SP3 file +> + > **16 Dec 2025** - the Ginan team is pleased to release v4.0.0 of the toolkit. > > **Main Highlights**: diff --git a/README.md b/README.md index e0ebba329..67ed01328 100755 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Ginan: GNSS Analysis Software Toolkit -[![Version](https://img.shields.io/badge/version-v4.0.0-blue.svg)](https://github.com/GeoscienceAustralia/ginan/releases) +[![Version](https://img.shields.io/badge/version-v4.1.0-blue.svg)](https://github.com/GeoscienceAustralia/ginan/releases) [![License](https://img.shields.io/badge/license-Apache--2.0-green.svg)](LICENSE.md) [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey.svg)](#supported-platforms) [![Docker](https://img.shields.io/badge/docker-available-blue.svg)](https://hub.docker.com/r/gnssanalysis/ginan) @@ -57,7 +57,7 @@ The fastest way to get started with Ginan is using Docker: ```bash # Pull and run the latest Ginan container -docker run -it -v $(pwd):/data gnssanalysis/ginan:v4.0.0 bash +docker run -it -v $(pwd):/data gnssanalysis/ginan:v4.1.0 bash # Verify installation pea --help @@ -120,7 +120,7 @@ Choose the installation method that best fits your needs: ```bash # Run Ginan container with data volume mounting -docker run -it -v ${pwd}:/data gnssanalysis/ginan:v4.0.0 bash +docker run -it -v ${pwd}:/data gnssanalysis/ginan:v4.1.0 bash ``` This command: @@ -294,7 +294,7 @@ cd ../../exampleConfigs Expected output: ``` -PEA starting... (main ginan-v4.0.0 from ...) +PEA starting... (main ginan-v4.1.0 from ...) Options: -h [ --help ] Help -q [ --quiet ] Less output @@ -462,4 +462,4 @@ All incorporated code has been preserved with appropriate modifications in the ` --- -**Developed by [Geoscience Australia](https://www.ga.gov.au/)** | **Version 4.0.0** | **[GitHub Repository](https://github.com/GeoscienceAustralia/ginan)** +**Developed by [Geoscience Australia](https://www.ga.gov.au/)** | **Version 4.1.0** | **[GitHub Repository](https://github.com/GeoscienceAustralia/ginan)** diff --git a/docker/Dockerfile b/docker/Dockerfile index 447cee469..25261573f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -99,6 +99,7 @@ WORKDIR /tmp/ RUN python3 -m pip install -r requirements.txt --no-cache-dir --break-system-packages ADD scripts /ginan/scripts +ADD exampleConfigs /ginan/exampleConfigs # Copy the built PEA binary from the build stage COPY --from=ginan-build /ginan/bin/ /usr/bin/ diff --git a/exampleConfigs/sbas_example_dfmc.yaml b/exampleConfigs/sbas_example_dfmc.yaml new file mode 100644 index 000000000..02ec96d6b --- /dev/null +++ b/exampleConfigs/sbas_example_dfmc.yaml @@ -0,0 +1,54 @@ +# DFMC SBAS +inputs: + inputs_root: ./products/ + atx_files: [igs20.atx] + snx_files: + - igs_satellite_metadata.snx + - tables/sat_yaw_bias_rate.snx + - AUS23907.SNX + satellite_data: + satellite_data_root: sbas/ + nav_files: + - "*.rnx" + sbas_inputs: + ems_files: + - "uraL5/*.ems" + sbas_prn: 122 + sbas_message_0: 65 # prn=65 for dfmc translates into messages type (34~36) + use_do259: true + gnss_observations: + gnss_observations_root: ../data/sbas/ + rnx_inputs: + - "*.rnx" + +outputs: + metadata: + config_description: sbas_example_ + outputs_root: ./outputs//dfmc/ + spp: + output: true + filename: -.SPP + +receiver_options: + HOB2: + antenna_type: "LEIAR25.R4 NONE" + NEBO: + antenna_type: "TRM59800.00 NONE" + OUS2: + antenna_type: "SEPCHOKE_B3E6 NONE" + TID1: + antenna_type: "AOAD/M_T NONE" + +processing_options: + process_modes: + sbas: true + epoch_control: + wait_next_epoch: 3600 + gnss_general: + sys_options: + gps: + process: true + gal: + process: true + sbas: + mode: DFMC diff --git a/exampleConfigs/sbas_example_l1.yaml b/exampleConfigs/sbas_example_l1.yaml new file mode 100644 index 000000000..fd5486195 --- /dev/null +++ b/exampleConfigs/sbas_example_l1.yaml @@ -0,0 +1,56 @@ +# L1 SBAS +inputs: + inputs_root: ./products/ + atx_files: [igs20.atx] + snx_files: + - igs_satellite_metadata.snx + - tables/sat_yaw_bias_rate.snx + - AUS23907.SNX + satellite_data: + satellite_data_root: sbas/ + nav_files: + - "*.rnx" + sbas_inputs: + ems_files: + - "uraL1/*.ems" + sbas_prn: 122 + sbas_message_0: 2 # Expected format for message type 0, if set to -1 (default) it will block use of SBAS corrections + prec_aproach: true + gnss_observations: + gnss_observations_root: ../data/sbas/ + rnx_inputs: + - "*.rnx" + +outputs: + metadata: + config_description: sbas_example_ + outputs_root: ./outputs//l1/ + spp: + output: true + filename: -.SPP + +receiver_options: + HOB2: + antenna_type: "LEIAR25.R4 NONE" + NEBO: + antenna_type: "TRM59800.00 NONE" + OUS2: + antenna_type: "SEPCHOKE_B3E6 NONE" + TID1: + antenna_type: "AOAD/M_T NONE" + +processing_options: + process_modes: + sbas: true + epoch_control: + wait_next_epoch: 3600 + gnss_general: + sys_options: + gps: + process: true + # glo: + # process: true + # sbs: + # process: true + sbas: + mode: L1 diff --git a/exampleConfigs/sbas_example_pvs.yaml b/exampleConfigs/sbas_example_pvs.yaml new file mode 100644 index 000000000..ddde41f51 --- /dev/null +++ b/exampleConfigs/sbas_example_pvs.yaml @@ -0,0 +1,86 @@ +# PPP via SouthPAN +inputs: + inputs_root: ./products/ + atx_files: [igs20.atx] + snx_files: + - igs_satellite_metadata.snx + - tables/sat_yaw_bias_rate.snx + - AUS23907.SNX + troposphere: + gpt2grid_files: [tables/gpt_25.grd] + satellite_data: + satellite_data_root: sbas/ + nav_files: + - "*.rnx" + sbas_inputs: + ems_files: + - "uraL5/*.ems" + sbas_prn: 122 + sbas_frequency: 5 + sbas_message_0: 65 # prn=65 for dfmc translates into messages type (34~36) + use_do259: true + pvs_on_dfmc: true + gnss_observations: + gnss_observations_root: ../data/sbas/ + rnx_inputs: + - "*.rnx" + +outputs: + metadata: + config_description: sbas_example_ + outputs_root: ./outputs//pvs/ + pos: + output: true + filename: -.POS + +receiver_options: + global: + code_sigma: 0.5 + phase_sigma: 0.005 + HOB2: + antenna_type: "LEIAR25.R4 NONE" + NEBO: + antenna_type: "TRM59800.00 NONE" + OUS2: + antenna_type: "SEPCHOKE_B3E6 NONE" + TID1: + antenna_type: "AOAD/M_T NONE" + +processing_options: + process_modes: + sbas: true + epoch_control: + wait_next_epoch: 3600 + sbas: + mode: PVS + +estimation_parameters: + receivers: + global: + pos: + estimated: [true] + sigma: [1000] + process_noise: [100] + clock: + estimated: [true] + sigma: [1000.0] + process_noise: [100.0] + ambiguities: + estimated: [true] + sigma: [1000] + process_noise: [0] + outage_limit: [120] + ion_stec: + estimated: [true] + sigma: [200] + process_noise: [10] + outage_limit: [120] + sigma_limit: [1000] + trop: + estimated: [true] + sigma: [0.15] + process_noise: [0.0001] + code_bias: + estimated: [true] + sigma: [200] + process_noise: [0] diff --git a/exampleConfigs/sbas_example_pvs_rt.yaml b/exampleConfigs/sbas_example_pvs_rt.yaml new file mode 100644 index 000000000..c733d71e9 --- /dev/null +++ b/exampleConfigs/sbas_example_pvs_rt.yaml @@ -0,0 +1,175 @@ +# Real-time PPP via SouthPAN +inputs: + inputs_root: ./products/ + atx_files: [igs20.atx] + snx_files: + - igs_satellite_metadata.snx + - tables/sat_yaw_bias_rate.snx + satellite_data: + rtcm_inputs: + rtcm_inputs: + - "https://:@ntrip.data.gnss.ga.gov.au/BCEP00BKG0" + sbas_inputs: + sisnet_inputs: + - "sisnet://:@sisnet.data.gnss.ga.gov.au:62005/" # Awarua L5 + # - "sisnet://:@sisnet.data.gnss.ga.gov.au:61005/" # Uralla L5 + sbas_prn: 122 + sbas_frequency: 5 + sbas_message_0: 65 # prn=65 for dfmc translates into messages type (34~36) + use_do259: true + pvs_on_dfmc: true + gnss_observations: + gnss_observations_root: "https://:@ntrip.data.gnss.ga.gov.au/" + rtcm_inputs: + - ALBY00AUS0 + - ALIC00AUS0 + - AUCK00NZL0 + - BDVL00AUS0 + - BRO100AUS0 + - BUR200AUS0 + - BURA00AUS0 + - CEDU00AUS0 + - CHTI00NZL0 + - COCO00AUS0 + - COOB00AUS0 + +outputs: + output_rotation: + period: 10800 + metadata: + config_description: sbas_example_ + outputs_root: ./outputs//pvs_rt/ + pos: + output: true + filename: -.POS + trace: + level: 2 + output_receivers: true + output_network: true + receiver_filename: _.TRACE + network_filename: _.TRACE + output_config: true + +satellite_options: + global: + models: + clock: + sources: [SBAS] + pos: + sources: [SBAS] + +receiver_options: + global: + code_sigma: 0.5 + phase_sigma: 0.005 + rec_reference_system: GPS + models: + pos: + sources: [CONFIG] + troposphere: + enable: true + models: [gpt2] + tides: + otl: false + atl: false + spole: false + opole: false + ALBY: + antenna_type: "TWIVC6050 NONE" + apriori_position: [-2441715.287, 4629128.701, -3633362.508] + ALIC: + antenna_type: "TWIVC6050 NONE" + apriori_position: [-4052052.962, 4212835.952, -2545104.266] + AUCK: + antenna_type: "TRM115000.00 NONE" + apriori_position: [-5105681.679, 461563.991, -3782180.811] + BDVL: + antenna_type: "TRM59800.00 NONE" + apriori_position: [-4355739.870, 3740194.470, -2769171.198] + BRO1: + antenna_type: "LEIAR25.R3 NONE" + apriori_position: [-3234208.403, 5134028.816, -1958814.859] + BUR2: + antenna_type: "LEIAT504 SCIS" + apriori_position: [-3989421.151, 2699532.975, -4166619.525] + BURA: + antenna_type: "JAVRINGANT_DM SCIS" + apriori_position: [-2511499.369, 4892172.270, -3220857.738] + CEDU: + antenna_type: "TWIVC6050 NONE" + apriori_position: [-3753473.444, 3912741.048, -3347959.400] + CHTI: + antenna_type: "TRM115000.00 NONE" + apriori_position: [-4607856.372, -272375.108, -4386954.026] + COCO: + antenna_type: "LEIAR25.R4 NONE" + apriori_position: [-741951.338, 6190961.750, -1337767.078] + COOB: + antenna_type: "LEIAR25.R3 LEIT" + apriori_position: [-3927331.187, 3965547.952, -3077376.513] + +processing_options: + process_modes: + sbas: true + ppp: true + epoch_control: + epoch_interval: 1 + max_rec_latency: 2 + max_epochs: 10800 + wait_next_epoch: 10 + gnss_general: + use_primary_signals: true + sys_options: + gps: + process: true + code_priorities: [L1C, L5Q, L5X] + gal: + process: true + code_priorities: [L1C, L1X, L5Q, L5X] + used_nav_type: FNAV + sbas: + mode: PVS + spp: + always_reinitialise: true + # ppp_filter: + # chunking: + # by_receiver: true + model_error_handling: + error_accumulation: + enable: true + ambiguities: + phase_reject_limit: 2 + reset_on: + lli: true + retrack: true + +estimation_parameters: + receivers: + global: + pos: + estimated: [true] + sigma: [1000] + process_noise: [100] + clock: + estimated: [true] + sigma: [1000.0] + process_noise: [100.0] + ambiguities: + estimated: [true] + sigma: [1000] + process_noise: [0] + outage_limit: [120] + ion_stec: + estimated: [true] + sigma: [200] + process_noise: [10] + outage_limit: [120] + sigma_limit: [1000] + trop: + estimated: [true] + sigma: [0.15] + process_noise: [0.0001] + code_bias: + estimated: [true] + sigma: [200] + process_noise: [0] diff --git a/inputData/data/data.list b/inputData/data/data.list index acf095d22..53f52e86d 100644 --- a/inputData/data/data.list +++ b/inputData/data/data.list @@ -100,4 +100,116 @@ ./aggo1990.19o ./alic1990.19o ./bako1990.19o -./coco1990.19o \ No newline at end of file +./coco1990.19o +./sbas/HOB200AUS_S_20253150000_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150015_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150030_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150045_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150100_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150115_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150130_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150145_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150200_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150215_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150230_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150245_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150300_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150315_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150330_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150345_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150400_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150415_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150430_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150445_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150500_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150515_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150530_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150545_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150600_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150615_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150630_15M_01S_MO.rnx +./sbas/HOB200AUS_S_20253150645_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150000_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150015_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150030_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150045_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150100_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150115_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150130_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150145_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150200_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150215_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150230_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150245_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150300_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150315_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150330_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150345_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150400_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150415_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150430_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150445_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150500_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150515_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150530_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150545_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150600_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150615_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150630_15M_01S_MO.rnx +./sbas/NEBO00AUS_S_20253150645_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150000_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150015_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150030_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150045_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150100_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150115_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150130_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150145_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150200_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150215_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150230_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150245_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150300_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150315_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150330_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150345_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150400_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150415_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150430_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150445_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150500_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150515_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150530_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150545_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150600_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150615_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150630_15M_01S_MO.rnx +./sbas/OUS200NZL_R_20253150645_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150000_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150015_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150030_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150045_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150100_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150115_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150130_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150145_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150200_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150215_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150230_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150245_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150300_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150315_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150330_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150345_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150400_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150415_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150430_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150445_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150500_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150515_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150530_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150545_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150600_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150615_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150630_15M_01S_MO.rnx +./sbas/TID100AUS_S_20253150645_15M_01S_MO.rnx \ No newline at end of file diff --git a/inputData/products/products.list b/inputData/products/products.list index 5864fe0de..5b932e679 100644 --- a/inputData/products/products.list +++ b/inputData/products/products.list @@ -1,4 +1,4 @@ -./boxwing.yaml +./AUS23907.SNX ./CAS0MGXRAP_20191990000_01D_01D_DCB.BSX ./COD0MGXFIN_20191990000_01D_05M_ORB.SP3 ./COD0R03FIN_20191990000_01D_01D_ERP.ERP @@ -77,6 +77,7 @@ ./TUG0R03FIN_20191990000_01D_30S_ATT.OBX ./TUG0R03FIN_20191990000_01D_30S_CLK.CLK ./aus20624.clk +./boxwing.yaml ./brdm1990.19p ./cod21620.snx ./code_monthly.bia @@ -241,6 +242,56 @@ ./jpl20624.clk ./meta_gather_20210721.snx ./orography_ell_5x5 +./sbas/BRDC00IGS_R_20253140000_01D_MN.rnx +./sbas/BRDC00IGS_R_20253150000_01D_MN.rnx +./sbas/uraL1/h00.ems +./sbas/uraL1/h01.ems +./sbas/uraL1/h02.ems +./sbas/uraL1/h03.ems +./sbas/uraL1/h04.ems +./sbas/uraL1/h05.ems +./sbas/uraL1/h06.ems +./sbas/uraL1/h07.ems +./sbas/uraL1/h08.ems +./sbas/uraL1/h09.ems +./sbas/uraL1/h10.ems +./sbas/uraL1/h11.ems +./sbas/uraL1/h12.ems +./sbas/uraL1/h13.ems +./sbas/uraL1/h14.ems +./sbas/uraL1/h15.ems +./sbas/uraL1/h16.ems +./sbas/uraL1/h17.ems +./sbas/uraL1/h18.ems +./sbas/uraL1/h19.ems +./sbas/uraL1/h20.ems +./sbas/uraL1/h21.ems +./sbas/uraL1/h22.ems +./sbas/uraL1/h23.ems +./sbas/uraL5/h00.ems +./sbas/uraL5/h01.ems +./sbas/uraL5/h02.ems +./sbas/uraL5/h03.ems +./sbas/uraL5/h04.ems +./sbas/uraL5/h05.ems +./sbas/uraL5/h06.ems +./sbas/uraL5/h07.ems +./sbas/uraL5/h08.ems +./sbas/uraL5/h09.ems +./sbas/uraL5/h10.ems +./sbas/uraL5/h11.ems +./sbas/uraL5/h12.ems +./sbas/uraL5/h13.ems +./sbas/uraL5/h14.ems +./sbas/uraL5/h15.ems +./sbas/uraL5/h16.ems +./sbas/uraL5/h17.ems +./sbas/uraL5/h18.ems +./sbas/uraL5/h19.ems +./sbas/uraL5/h20.ems +./sbas/uraL5/h21.ems +./sbas/uraL5/h22.ems +./sbas/uraL5/h23.ems ./slr/ILRS_Data_Handling_File_2024.02.13.snx ./slr/ITRF2014-ILRS-TRF-SSC.SNX ./slr/OLOAD_SLR.BLQ diff --git a/scripts/GinanUI/app/controllers/input_controller.py b/scripts/GinanUI/app/controllers/input_controller.py index aa109eb26..662dc7041 100644 --- a/scripts/GinanUI/app/controllers/input_controller.py +++ b/scripts/GinanUI/app/controllers/input_controller.py @@ -6,6 +6,8 @@ import os import re +import subprocess +import webbrowser from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -23,7 +25,7 @@ str_to_datetime ) from PySide6.QtCore import QObject, Signal, Qt, QDateTime, QThread -from PySide6.QtGui import QStandardItemModel, QStandardItem +from PySide6.QtGui import QStandardItemModel, QStandardItem, QColor, QBrush from PySide6.QtWidgets import ( QFileDialog, QDialog, @@ -37,15 +39,21 @@ QComboBox, QLineEdit, QPushButton, - QLabel + QLabel, + QListWidget, + QListWidgetItem, + QAbstractItemView, + QSizePolicy, + QWidget ) from scripts.GinanUI.app.models.execution import Execution, GENERATED_YAML, INPUT_PRODUCTS_PATH +from scripts.GinanUI.app.utils.common_dirs import USER_MANUAL_PATH from scripts.GinanUI.app.models.rinex_extractor import RinexExtractor from scripts.GinanUI.app.utils.cddis_credentials import save_earthdata_credentials from scripts.GinanUI.app.models.archive_manager import (archive_products_if_rinex_changed) from scripts.GinanUI.app.models.archive_manager import archive_old_outputs -from scripts.GinanUI.app.utils.workers import DownloadWorker +from scripts.GinanUI.app.utils.workers import DownloadWorker, BiasProductWorker from scripts.GinanUI.app.utils.toast import show_toast @@ -86,19 +94,20 @@ def __init__(self, ui, parent_window, execution: Execution): self.ui.outputButton.setEnabled(False) self.ui.showConfigButton.setEnabled(False) self.ui.processButton.setEnabled(False) + self.ui.stopAllButton.setEnabled(False) ### Bind: configuration drop-downs / UIs ### - self._bind_combo(self.ui.Mode, self._get_mode_items) + self._bind_combo(self.ui.modeCombo, self._get_mode_items) - # PPP_provider, project and series - self.ui.PPP_provider.currentTextChanged.connect(self._on_ppp_provider_changed) - self.ui.PPP_project.currentTextChanged.connect(self._on_ppp_project_changed) - self.ui.PPP_series.currentTextChanged.connect(self._on_ppp_series_changed) + # PPP provider, project and series + self.ui.pppProviderCombo.currentTextChanged.connect(self._on_ppp_provider_changed) + self.ui.pppProjectCombo.currentTextChanged.connect(self._on_ppp_project_changed) + self.ui.pppSeriesCombo.currentTextChanged.connect(self._on_ppp_series_changed) # Constellations self._bind_multiselect_combo( - self.ui.Constellations_2, + self.ui.constellationsCombo, self._get_constellations_items, self.ui.constellationsValue, placeholder="Select one or more", @@ -126,8 +135,30 @@ def __init__(self, ui, parent_window, execution: Execution): # CDDIS credentials dialog self.ui.cddisCredentialsButton.clicked.connect(self._open_cddis_credentials_dialog) + # Reset config button + self.ui.resetConfigButton.clicked.connect(self._on_reset_config_clicked) + + # User manual button + self.ui.userManualButton.clicked.connect(self._open_user_manual) + self.setup_tooltips() + # Initialise "Constellations" placeholder + self._setup_constellation_placeholder() + self._hide_all_constellation_widgets() + + # Track threads that are pending cleanup (threads that are cancelled but not yet finished) + self._pending_threads = [] + + # BIA code priorities cache: provider -> series -> project -> {'GPS': set(), ...} + self.bia_code_priorities = {} + self._bia_loading = False + self._bia_worker = None + self._bia_thread = None + + # Connect tab change signal to trigger BIA fetch when switching to Constellations tab + self.ui.configTabWidget.currentChanged.connect(self._on_config_tab_changed) + def setup_tooltips(self): """ UI handler: setup tooltips and visual style for key controls. @@ -155,11 +186,13 @@ def setup_tooltips(self): obs_style = self.ui.observationsButton.styleSheet() + tooltip_style out_style = self.ui.outputButton.styleSheet() + tooltip_style proc_style = self.ui.processButton.styleSheet() + tooltip_style + stop_style = self.ui.stopAllButton.styleSheet() + tooltip_style cddis_style = self.ui.cddisCredentialsButton.styleSheet() + tooltip_style self.ui.observationsButton.setStyleSheet(obs_style) self.ui.outputButton.setStyleSheet(out_style) self.ui.processButton.setStyleSheet(proc_style) + self.ui.stopAllButton.setStyleSheet(stop_style) self.ui.cddisCredentialsButton.setStyleSheet(cddis_style) # File selection buttons @@ -174,10 +207,15 @@ def setup_tooltips(self): ) self.ui.processButton.setToolTip( - "Start the Ginan (pea) PPP processing using the configured parameters.\n" + "Start the Ginan (PEA) PPP processing using the configured parameters.\n" "Ensure all required fields are filled before processing." ) + self.ui.stopAllButton.setToolTip( + "Stop the Ginan (PEA) PPP processing.\n" + "Will terminate all download threads and unlock the UI again." + ) + # Configuration buttons self.ui.showConfigButton.setToolTip( "Generate and open the YAML configuration file.\n" @@ -185,36 +223,46 @@ def setup_tooltips(self): "Note: UI defined parameters will ALWAYS override manual config edits." ) + self.ui.resetConfigButton.setToolTip( + "Delete and regenerate the YAML configuration file and start from a clean slate.\n" + "Note: Will delete all modifications to the existing file!" + ) + + self.ui.userManualButton.setToolTip( + "Open the Ginan-UI User Manual\n" + "Located in docs/USER_MANUAL.md" + ) + self.ui.cddisCredentialsButton.setToolTip( "Set your NASA Earthdata credentials for downloading PPP products\n" "Required for accessing the CDDIS archive data" ) # Input fields and combos - self.ui.Mode.setToolTip( + self.ui.modeCombo.setToolTip( "Processing mode:\n" "• Static: For stationary receivers\n" "• Kinematic: For moving receivers\n" "• Dynamic: For high-dynamic applications" ) - self.ui.Constellations_2.setToolTip( + self.ui.constellationsCombo.setToolTip( "Select which GNSS constellations to use:\n" "GPS, Galileo (GAL), GLONASS (GLO), BeiDou (BDS), QZSS (QZS)\n" "More constellations generally improve accuracy" ) - self.ui.PPP_provider.setToolTip( + self.ui.pppProviderCombo.setToolTip( "Analysis centre that provides PPP products\n" "Options populated based on your observation time window" ) - self.ui.PPP_project.setToolTip( + self.ui.pppProjectCombo.setToolTip( "PPP product project type.\n" "Different projects types offer varying GNSS constellation PPP products." ) - self.ui.PPP_series.setToolTip( + self.ui.pppSeriesCombo.setToolTip( "PPP product series:\n" "• ULT: Ultra-rapid (lower latency)\n" "• RAP: Rapid \n" @@ -222,12 +270,12 @@ def setup_tooltips(self): ) # Receiver/Antenna fields - self.ui.Receiver_type.setToolTip( + self.ui.receiverTypeCombo.setToolTip( "Receiver model extracted from RINEX header\n" "Click to manually edit if needed" ) - self.ui.Antenna_type.setToolTip( + self.ui.antennaTypeCombo.setToolTip( "Antenna model extracted from RINEX header\n" "Must match entries in the ANTEX (.atx) calibration file\n" "Click to manually edit if needed" @@ -258,6 +306,68 @@ def setup_tooltips(self): self.ui.dataIntervalValue.setToolTip("Data sampling interval") self.ui.antennaOffsetValue.setToolTip("Antenna offset: East, North, Up (metres)") + # Observation code list widget tooltips + if hasattr(self.ui, 'gpsListWidget'): + self.ui.gpsListWidget.setToolTip( + "GPS observation codes\n" + "✓ Check / uncheck to enable / disable codes\n" + "↕ Drag and drop to set priority order (top = highest priority)" + ) + if hasattr(self.ui, 'galListWidget'): + self.ui.galListWidget.setToolTip( + "Galileo observation codes\n" + "✓ Check / uncheck to enable / disable codes\n" + "↕ Drag and drop to set priority order (top = highest priority)" + ) + if hasattr(self.ui, 'gloListWidget'): + self.ui.gloListWidget.setToolTip( + "GLONASS observation codes\n" + "✓ Check / uncheck to enable / disable codes\n" + "↕ Drag and drop to set priority order (top = highest priority)" + ) + if hasattr(self.ui, 'bdsListWidget'): + self.ui.bdsListWidget.setToolTip( + "BeiDou observation codes\n" + "✓ Check / uncheck to enable / disable codes\n" + "↕ Drag and drop to set priority order (top = highest priority)" + ) + if hasattr(self.ui, 'qzsListWidget'): + self.ui.qzsListWidget.setToolTip( + "QZSS observation codes\n" + "✓ Check / uncheck to enable / disable codes\n" + "↕ Drag and drop to set priority order (top = highest priority)" + ) + + self.ui.posCheckbox.setToolTip( + "Enable / disable Ginan (PEA) PPP Processing outputting a Positioning Solution (.POS) file" + ) + + self.ui.gpxCheckbox.setToolTip( + "Enable / disable Ginan (PEA) PPP Processing outputting a GPS Exchange Format (.GPX) file" + ) + + self.ui.traceCheckbox.setToolTip( + "Enable / disable Ginan (PEA) PPP Processing outputting a trace log (.TRACE) file" + ) + + def _hide_all_constellation_widgets(self): + """ + Hide all constellation labels and list widgets on startup. + They will be shown when a RINEX file is loaded and constellations are selected. + """ + widget_names = [ + 'gpsLabel', 'gpsListWidget', + 'galLabel', 'galListWidget', + 'gloLabel', 'gloListWidget', + 'bdsLabel', 'bdsListWidget', + 'qzsLabel', 'qzsListWidget', + ] + + for widget_name in widget_names: + if hasattr(self.ui, widget_name): + widget = getattr(self.ui, widget_name) + widget.setVisible(False) + def _open_cddis_credentials_dialog(self): """ UI handler: open the CDDIS credentials dialog for Earthdata login. @@ -283,8 +393,15 @@ def load_rnx_file(self) -> ExtractedInputs | None: # Disable until new providers found if current_rinex_path != getattr(self, "last_rinex_path", None): self.ui.processButton.setEnabled(False) + self.ui.stopAllButton.setEnabled(False) self._on_cddis_ready(pd.DataFrame(), False) # Clears providers until worker completes + # Stop any running BIA worker before clearing cache + self._stop_bia_worker() + # Clear BIA code priorities cache when RINEX file changes + self.bia_code_priorities = {} + self._reset_constellation_list_styling() + self.last_rinex_path = current_rinex_path self.rnx_file = str(current_rinex_path) @@ -312,15 +429,24 @@ def load_rnx_file(self) -> ExtractedInputs | None: # Retrieve valid analysis centers start_epoch = str_to_datetime(result['start_epoch']) end_epoch = str_to_datetime(result['end_epoch']) + + # Clean up any existing analysis centre threads before starting a new one + self._cleanup_analysis_thread() + self.worker = DownloadWorker(start_epoch=start_epoch, end_epoch=end_epoch, analysis_centers=True) self.metadata_thread = QThread() self.worker.moveToThread(self.metadata_thread) self.worker.finished.connect(self._on_cddis_ready) - self.worker.finished.connect(self._restore_cursor) # Restore cursor when done - self.worker.finished.connect(self.worker.deleteLater) + self.worker.finished.connect(self._restore_cursor) + self.worker.cancelled.connect(self._on_cddis_cancelled) + self.worker.cancelled.connect(self._restore_cursor) + self.worker.constellation_info.connect(self._on_constellation_info_received) + + # Connect both finished and cancelled to thread quit self.worker.finished.connect(self.metadata_thread.quit) - self.metadata_thread.finished.connect(self.metadata_thread.deleteLater) + self.worker.cancelled.connect(self.metadata_thread.quit) + self.metadata_thread.finished.connect(self._on_analysis_thread_finished) self.metadata_thread.started.connect(self.worker.run) self.metadata_thread.start() @@ -334,18 +460,21 @@ def load_rnx_file(self) -> ExtractedInputs | None: self.ui.antennaOffsetValue.setText(", ".join(map(str, result["antenna_offset"]))) self.ui.antennaOffsetButton.setText(", ".join(map(str, result["antenna_offset"]))) - self.ui.Receiver_type.clear() - self.ui.Receiver_type.addItem(result["receiver_type"]) - self.ui.Receiver_type.setCurrentIndex(0) - self.ui.Receiver_type.lineEdit().setText(result["receiver_type"]) + self.ui.receiverTypeCombo.clear() + self.ui.receiverTypeCombo.addItem(result["receiver_type"]) + self.ui.receiverTypeCombo.setCurrentIndex(0) + self.ui.receiverTypeCombo.lineEdit().setText(result["receiver_type"]) - self.ui.Antenna_type.clear() - self.ui.Antenna_type.addItem(result["antenna_type"]) - self.ui.Antenna_type.setCurrentIndex(0) - self.ui.Antenna_type.lineEdit().setText(result["antenna_type"]) + self.ui.antennaTypeCombo.clear() + self.ui.antennaTypeCombo.addItem(result["antenna_type"]) + self.ui.antennaTypeCombo.setCurrentIndex(0) + self.ui.antennaTypeCombo.lineEdit().setText(result["antenna_type"]) self._update_constellations_multiselect(result["constellations"]) + # Populate observation code combos if available + self._populate_observation_code_combos(result) + self.ui.outputButton.setEnabled(True) self.ui.showConfigButton.setEnabled(True) @@ -365,6 +494,76 @@ def load_rnx_file(self) -> ExtractedInputs | None: return result + def _populate_observation_code_combos(self, result: dict): + """ + Populate the observation code list widgets with available codes from RINEX. + + Arguments: + result (dict): Dictionary containing observation code lists for each constellation + """ + # Map constellation names to list widgets and result keys + list_widget_mapping = { + 'GPS': ('obs_types_gps', 'enabled_gps', 'gpsListWidget'), + 'GAL': ('obs_types_gal', 'enabled_gal', 'galListWidget'), + 'GLO': ('obs_types_glo', 'enabled_glo', 'gloListWidget'), + 'BDS': ('obs_types_bds', 'enabled_bds', 'bdsListWidget'), + 'QZS': ('obs_types_qzs', 'enabled_qzs', 'qzsListWidget') + } + + populated_constellations = [] + + for const_name, (result_key, enabled_key, widget_name) in list_widget_mapping.items(): + if not hasattr(self.ui, widget_name): + continue + + list_widget = getattr(self.ui, widget_name) + codes = result.get(result_key, []) + enabled_codes = result.get(enabled_key, set()) + + if codes and len(codes) > 0: + self._setup_observation_code_list_widget(list_widget, codes, enabled_codes) + populated_constellations.append(const_name) + else: + # Clear and disable list widget if no codes available + list_widget.clear() + list_widget.setEnabled(False) + + # Log summary message + if populated_constellations: + Logger.terminal(f"✅ Populated observation codes for {', '.join(populated_constellations)}") + else: + Logger.terminal("⚠️ No observation codes found in RINEX") + + def _setup_observation_code_list_widget(self, list_widget: QListWidget, codes: List[str], enabled_codes: set): + """ + Set up a list widget with drag-drop reordering and checkboxes for observation codes. + + Arguments: + list_widget (QListWidget): The list widget to set up + codes (List[str]): List of observation codes to populate (in priority order) + enabled_codes (set): Set of codes that should be checked by default + """ + list_widget.setEnabled(True) + list_widget.clear() + + # Enable drag and drop for reordering + list_widget.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + list_widget.setDefaultDropAction(Qt.DropAction.MoveAction) + list_widget.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + + # Add items with checkboxes + for code in codes: + item = QListWidgetItem(code) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled) + + # Check if this code is in the enabled set (from template priorities) + if code in enabled_codes: + item.setCheckState(Qt.CheckState.Checked) # Priority codes: checked + else: + item.setCheckState(Qt.CheckState.Unchecked) # Extra codes: unchecked + + list_widget.addItem(item) + def verify_antenna_type(self, result: List[str]): """ UI handler: verify that the RINEX antenna_type exists in the selected ANTEX (.atx) file. @@ -448,7 +647,7 @@ def _update_constellations_multiselect(self, constellation_str: str): from PySide6.QtGui import QStandardItemModel, QStandardItem constellations = [c.strip() for c in constellation_str.split(",") if c.strip()] - combo = self.ui.Constellations_2 + combo = self.ui.constellationsCombo # Remove previous bindings if hasattr(combo, '_old_showPopup'): @@ -476,6 +675,7 @@ def on_item_changed(_item): label = ", ".join(selected) if selected else "Select one or more" combo.lineEdit().setText(label) self.ui.constellationsValue.setText(label) + self._sync_constellation_list_widgets_to_selection() model.itemChanged.connect(on_item_changed) combo.setModel(model) @@ -498,6 +698,139 @@ def show_popup_constellation(): combo.lineEdit().setText(", ".join(constellations)) self.ui.constellationsValue.setText(", ".join(constellations)) + # Initial sync of list widgets + self._sync_constellation_list_widgets_to_selection() + + def _sync_constellation_list_widgets_to_selection(self): + """ + Show / hide constellation list widgets and labels based on the "General" tab's + constellation multi-select. Called when constellation selection changes. + Shows a placeholder message when no constellations are selected. + """ + # Get currently selected constellations from the General tab combo + selected_constellations = set() + combo = self.ui.constellationsCombo + if hasattr(combo, '_constellation_model') and combo._constellation_model: + model = combo._constellation_model + for i in range(model.rowCount()): + if model.item(i).checkState() == Qt.Checked: + selected_constellations.add(model.item(i).text().upper()) + + # Map constellation names to their UI widgets + widget_mapping = { + 'GPS': ('gpsLabel', 'gpsListWidget'), + 'GAL': ('galLabel', 'galListWidget'), + 'GLO': ('gloLabel', 'gloListWidget'), + 'BDS': ('bdsLabel', 'bdsListWidget'), + 'QZS': ('qzsLabel', 'qzsListWidget'), + } + + for const_name, (label_name, list_widget_name) in widget_mapping.items(): + is_enabled = const_name in selected_constellations + + # Show / hide label + if hasattr(self.ui, label_name): + label = getattr(self.ui, label_name) + label.setVisible(is_enabled) + + # Show / hide list widget + if hasattr(self.ui, list_widget_name): + list_widget = getattr(self.ui, list_widget_name) + list_widget.setVisible(is_enabled) + + # Show / hide placeholder message + self._update_constellation_placeholder(len(selected_constellations) == 0) + + def _setup_constellation_placeholder(self): + """ + Create a placeholder label for the Constellations tab that shows when + no constellations are selected or no RINEX file is loaded. + """ + # Create the placeholder label + self._constellation_placeholder = QLabel( + "No constellations available!\n\n" + "Load a RINEX observation file and select constellations\n" + "in the General tab to configure observation codes" + ) + self._constellation_placeholder.setAlignment(Qt.AlignCenter) + self._constellation_placeholder.setWordWrap(True) + self._constellation_placeholder.setMinimumWidth(250) + self._constellation_placeholder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._constellation_placeholder.setStyleSheet( + "color: #bfbfbf; font-size: 13pt; margin: 15px;" + ) + + # Add to the constellations tab layout + if hasattr(self.ui, 'constellationsGridLayout'): + self.ui.constellationsGridLayout.addWidget( + self._constellation_placeholder, 0, 0, 10, 1, Qt.AlignCenter + ) + + # Initially visible + self._constellation_placeholder.setVisible(True) + + # Create explanation label for the Constellations tab + self._constellation_explanation_label = QLabel( + "Select observation codes and set priorities for each active constellation below.
" + "These observation codes are extracted from the loaded RINEX file.
" + "Red strikethrough = missing from .BIA file" + ) + self._constellation_explanation_label.setTextFormat(Qt.RichText) + self._constellation_explanation_label.setWordWrap(True) + self._constellation_explanation_label.setStyleSheet( + "color: #bfbfbf; font-size: 11pt; font-style: italic; margin-bottom: 6x; line-height: 1.4;" + ) + self._constellation_explanation_label.setVisible(False) + + # Create BIA warning label (shown when BIA fetch fails) + self._bia_warning_label = QLabel( + "⚠️ Failed to fetch BIA file for selected PPP products - unable to validate codes" + ) + self._bia_warning_label.setWordWrap(True) + self._bia_warning_label.setStyleSheet( + "QLabel { background-color: #8B4513; color: white; padding: 6px 12px; " + "border-radius: 4px; font: 10pt 'Segoe UI'; }" + ) + self._bia_warning_label.setAlignment(Qt.AlignCenter) + self._bia_warning_label.setVisible(False) + + # Create BIA loading label + self._bia_loading_label = QLabel("⏳ Loading code priorities from .BIA file...") + self._bia_loading_label.setWordWrap(True) + self._bia_loading_label.setStyleSheet( + "QLabel { background-color: #2c5d7c; color: white; padding: 8px 16px; " + "border-radius: 4px; font: 12pt 'Segoe UI'; }" + ) + self._bia_loading_label.setAlignment(Qt.AlignCenter) + self._bia_loading_label.setVisible(False) + + # Create a container widget with vertical layout for the status labels + self._constellation_status_container = QWidget() + status_layout = QVBoxLayout(self._constellation_status_container) + status_layout.setContentsMargins(0, 0, 0, 8) + status_layout.setSpacing(4) + status_layout.addWidget(self._constellation_explanation_label) + status_layout.addWidget(self._bia_warning_label) + status_layout.addWidget(self._bia_loading_label) + + # Add the status container to row 0 of the constellations grid layout + # (existing widgets start at row 1, so row 0 is available) + if hasattr(self.ui, 'constellationsGridLayout'): + self.ui.constellationsGridLayout.addWidget(self._constellation_status_container, 0, 0) + + def _update_constellation_placeholder(self, show_placeholder: bool): + """ + Show or hide the constellation placeholder message. + + Arguments: + show_placeholder (bool): True to show placeholder, False to hide it. + """ + if hasattr(self, '_constellation_placeholder'): + self._constellation_placeholder.setVisible(show_placeholder) + # Show explanation label when placeholder is hidden (i.e., constellations are visible) + if hasattr(self, '_constellation_explanation_label'): + self._constellation_explanation_label.setVisible(not show_placeholder) + def _on_cddis_ready(self, data: pd.DataFrame, log_messages: bool = True): """ UI handler: receive PPP products DataFrame from worker and populate provider/project/series combos. @@ -506,30 +839,28 @@ def _on_cddis_ready(self, data: pd.DataFrame, log_messages: bool = True): if data.empty: self.valid_analysis_centers = [] - self.ui.PPP_provider.clear() - self.ui.PPP_provider.addItem("None") - self.ui.PPP_series.clear() - self.ui.PPP_series.addItem("None") + self.ui.pppProviderCombo.clear() + self.ui.pppProviderCombo.addItem("None") + self.ui.pppSeriesCombo.clear() + self.ui.pppSeriesCombo.addItem("None") return self.valid_analysis_centers = list(get_valid_analysis_centers(self.products_df)) if len(self.valid_analysis_centers) == 0: - if log_messages: - Logger.terminal("⚠️ No valid PPP providers found.") - self.ui.PPP_provider.clear() - self.ui.PPP_provider.addItem("None") - self.ui.PPP_series.clear() - self.ui.PPP_series.addItem("None") + self.ui.pppProviderCombo.clear() + self.ui.pppProviderCombo.addItem("None") + self.ui.pppSeriesCombo.clear() + self.ui.pppSeriesCombo.addItem("None") return - self.ui.PPP_provider.blockSignals(True) - self.ui.PPP_provider.clear() - self.ui.PPP_provider.addItems(self.valid_analysis_centers) - self.ui.PPP_provider.setCurrentIndex(0) + self.ui.pppProviderCombo.blockSignals(True) + self.ui.pppProviderCombo.clear() + self.ui.pppProviderCombo.addItems(self.valid_analysis_centers) + self.ui.pppProviderCombo.setCurrentIndex(0) - # Update PPP_series based on default PPP_provider - self.ui.PPP_provider.blockSignals(False) + # Update PPP series based on default PPP provider + self.ui.pppProviderCombo.blockSignals(False) self.try_enable_process_button() self._on_ppp_provider_changed(self.valid_analysis_centers[0]) if log_messages: @@ -543,8 +874,8 @@ def _on_cddis_error(self, msg): UI handler: report CDDIS worker error to the UI. """ Logger.terminal(f"Error loading CDDIS data: {msg}") - self.ui.PPP_provider.clear() - self.ui.PPP_provider.addItem("None") + self.ui.pppProviderCombo.clear() + self.ui.pppProviderCombo.addItem("None") # Restore cursor in case of error self.parent.setCursor(Qt.CursorShape.ArrowCursor) # Show error toast @@ -556,6 +887,527 @@ def _restore_cursor(self): """ self.parent.setCursor(Qt.CursorShape.ArrowCursor) + def _cleanup_analysis_thread(self): + """ + Request any running analysis centre threads to cancel + Moves the thread to _pending_threads list so it isn't destroyed while running. + """ + if hasattr(self, 'worker') and self.worker is not None: + self.worker.stop() + + if hasattr(self, 'metadata_thread') and self.metadata_thread is not None: + if self.metadata_thread.isRunning(): + # Disconnect old signals to prevent callbacks to stale state + try: + self.worker.finished.disconnect() + self.worker.cancelled.disconnect() + except (TypeError, RuntimeError): + pass # Already disconnected or object deleted + + # Keep reference alive until thread actually finishes + old_thread = self.metadata_thread + + def cleanup_old_thread(): + if old_thread in self._pending_threads: + self._pending_threads.remove(old_thread) + + old_thread.finished.connect(cleanup_old_thread) + self._pending_threads.append(old_thread) + + # Clear current references so new thread can be created + self.worker = None + self.metadata_thread = None + + def _on_cddis_cancelled(self): + """ + UI handler: handle cancellation of CDDIS worker. + """ + Logger.terminal("📦 PPP provider scan was cancelled") + + def _on_constellation_info_received(self, provider_constellations: dict): + """ + UI handler: receive and store constellation information for each PPP provider/series/project + This is emitted by the DownloadWorker after fetching the SP3 headers + + Arguments: + provider_constellations (dict): Nested dictionary mapping "provider -> series -> project -> constellations" + e.g., { + 'COD': { + 'FIN': {'OPS': {'GPS', 'GLO', 'GAL'}, 'MGX': {'GPS', 'GLO', 'GAL', 'BDS', 'QZS'}}, + 'RAP': {'OPS': {'GPS', 'GLO', 'GAL'}} + }, ... + } + """ + # Store for later use when filtering constellations UI based on selected provider/series/project + self.provider_constellations = provider_constellations + + # Log the received constellation info + Logger.console("📡 Provider constellation information received") + + # Update constellations combobox based on current PPP selection + self._update_constellations_for_ppp_selection() + + # If already on Constellations tab, trigger BIA fetch + if self.ui.configTabWidget.currentIndex() == 1: + self._on_config_tab_changed(1) + + def _update_constellations_for_ppp_selection(self): + """ + Update the constellations combobox to enable / disable items based on the + currently selected PPP provider/series/project combination. + Constellations supported by the selected combination are enabled and checked, + unsupported constellations are disabled and unchecked. + """ + combo = self.ui.constellationsCombo + if not hasattr(combo, '_constellation_model') or combo._constellation_model is None: + return + + model = combo._constellation_model + + # Get current PPP selection + provider = self.ui.pppProviderCombo.currentText() + series = self.ui.pppSeriesCombo.currentText() + project = self.ui.pppProjectCombo.currentText() + + # Get available constellations for this combination + available_constellations = set() + if hasattr(self, 'provider_constellations') and self.provider_constellations: + try: + available_constellations = self.provider_constellations.get(provider, {}).get(series, {}).get(project, set()) + except (KeyError, AttributeError): + available_constellations = set() + + # If no constellation info available, enable all (fallback behaviour) + if not available_constellations: + for i in range(model.rowCount()): + item = model.item(i) + item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable) + return + + # Block signals to prevent triggering on_item_changed multiple times + model.blockSignals(True) + + # Update each constellation item + for i in range(model.rowCount()): + item = model.item(i) + constellation_name = item.text().upper() + + if constellation_name in available_constellations: + # Enable and check this constellation + #item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable) # Un-comment to also disable checkability + item.setCheckState(Qt.Checked) + else: + # Disable and uncheck this constellation + #item.setFlags(Qt.ItemIsUserCheckable) # Un-comment to also disable checkability + item.setCheckState(Qt.Unchecked) + + model.blockSignals(False) + + # Update the label text to show only enabled/checked constellations + selected = [ + model.item(i).text() + for i in range(model.rowCount()) + if model.item(i).checkState() == Qt.Checked + ] + label = ", ".join(selected) if selected else "Select one or more" + combo.lineEdit().setText(label) + self.ui.constellationsValue.setText(label) + + # Sync the constellation list widgets + self._sync_constellation_list_widgets_to_selection() + + def _on_config_tab_changed(self, index: int): + """ + UI handler: triggered when the config tab widget changes tabs. + When switching to the Constellations tab (index 1), fetch .BIA code priorities + for the current PPP selection if not already cached + + Arguments: + index (int): The index of the newly selected tab + """ + # Constellations tab is index 1 + if index != 1: + return + + # Check if we have valid PPP selection + provider = self.ui.pppProviderCombo.currentText() + series = self.ui.pppSeriesCombo.currentText() + project = self.ui.pppProjectCombo.currentText() + + # Guard: Skip if any combo is empty or has placeholder values + if not provider or not series or not project: + return + if provider in ("", "None", "Select one") or series in ("", "None", "Select one") or project in ("", "None", "Select one"): + return + + # Guard: Skip if products_df is empty (happens during RINEX file change) + if self.products_df.empty: + return + + # Check if we already have cached BIA data for this combination + if self._is_bia_cached(provider, series, project): + # Already have the data, just validate + self._validate_constellation_codes_against_bia() + return + + # Check if we are already loading the same combination + if self._bia_loading: + # Check if it is for a different combination - if so, restart + if (hasattr(self, '_bia_current_provider') and + (self._bia_current_provider != provider or + self._bia_current_series != series or + self._bia_current_project != project)): + # Different combination requested, stop current and start new + Logger.console(f"🔄 BIA fetch interrupted - switching to {provider}/{series}/{project}") + else: + # Same combination, let it continue + return + + # Start BIA fetch (will stop any existing worker first) + self._fetch_bia_code_priorities(provider, series, project) + + def _is_bia_cached(self, provider: str, series: str, project: str) -> bool: + """ + Check if BIA code priorities are cached for the given combination. + + Arguments: + provider (str) + series (str) + project (str) + + Returns: + bool: True if cached, False otherwise + """ + try: + return (provider in self.bia_code_priorities and + series in self.bia_code_priorities[provider] and + project in self.bia_code_priorities[provider][series]) + except (KeyError, TypeError): + return False + + def _fetch_bia_code_priorities(self, provider: str, series: str, project: str): + """ + Start background worker to fetch and parse BIA file for code priorities. + + Arguments: + provider (str) + series (str) + project (str) + """ + # Safety guard: don't start worker with invalid parameters + if not provider or not series or not project: + Logger.console(f"⚠️ BIA fetch skipped: invalid parameters provider='{provider}' series='{series}' project='{project}'") + return + if provider in ("", "None", "Select one") or series in ("", "None", "Select one") or project in ("", "None", "Select one"): + Logger.console(f"⚠️ BIA fetch skipped: placeholder values in parameters") + return + if self.products_df.empty: + Logger.console(f"⚠️ BIA fetch skipped: products_df is empty") + return + + # Stop any existing BIAProductWorker before starting a new one + self._stop_bia_worker() + + self._bia_loading = True + + # Show loading indicator + self._show_bia_loading_indicator(True) + + # Create worker and thread + self._bia_thread = QThread() + self._bia_worker = BiasProductWorker(self.products_df, provider, series, project) + self._bia_worker.moveToThread(self._bia_thread) + + # Connect signals + self._bia_thread.started.connect(self._bia_worker.run) + self._bia_worker.finished.connect(self._on_bia_finished) + self._bia_worker.error.connect(self._on_bia_error) + self._bia_worker.progress.connect(self._on_bia_progress) + self._bia_worker.finished.connect(self._bia_thread.quit) + self._bia_worker.error.connect(self._bia_thread.quit) + self._bia_thread.finished.connect(self._on_bia_thread_finished) + + # Store current selection for when results come back + self._bia_current_provider = provider + self._bia_current_series = series + self._bia_current_project = project + + # Start the thread + self._bia_thread.start() + + def _stop_bia_worker(self): + """ + Stop any running BIA worker and clean up thread resources. + """ + if self._bia_worker is not None: + # Disconnect signals to prevent callbacks after cleanup + try: + self._bia_worker.finished.disconnect() + self._bia_worker.error.disconnect() + self._bia_worker.progress.disconnect() + except (RuntimeError, TypeError): + # Signals may not be connected or already disconnected + pass + # Signal the worker to stop + self._bia_worker.stop() + + if self._bia_thread is not None: + # Disconnect thread signals + try: + self._bia_thread.started.disconnect() + self._bia_thread.finished.disconnect() + except (RuntimeError, TypeError): + pass + + if self._bia_thread.isRunning(): + # Ask thread to quit and wait briefly + self._bia_thread.quit() + # Wait up to 2 seconds for thread to finish + if not self._bia_thread.wait(2000): + # Force terminate if it doesn't stop gracefully + Logger.console("⚠️ BIA thread did not stop gracefully, forcing termination") + self._bia_thread.terminate() + self._bia_thread.wait(1000) + + # Clean up references + self._bia_worker = None + self._bia_thread = None + self._bia_loading = False + + def _on_bia_progress(self, description: str, percent: int): + """ + UI handler: update progress during BIA fetch. + + Arguments: + description (str): Progress description + percent (int): Progress percentage (-1 for indeterminate) + """ + # Update the loading label if it exists + if hasattr(self, '_bia_loading_label') and self._bia_loading_label: + self._bia_loading_label.setText(f"⏳ {description}") + + def _on_bia_finished(self, code_priorities: dict): + """ + UI handler: BIA fetch completed successfully. + + Arguments: + code_priorities (dict): Dictionary mapping constellation names to sets of code priorities + e.g., {'GPS': {'L1C', 'L2W'}, 'GAL': {'L1C', 'L5Q'}, ...} + """ + self._bia_loading = False + self._show_bia_loading_indicator(False) + + # Hide any previous BIA warning since we now have valid data + self._show_bia_warning(False) + + # Cache the results + provider = self._bia_current_provider + series = self._bia_current_series + project = self._bia_current_project + + if provider not in self.bia_code_priorities: + self.bia_code_priorities[provider] = {} + if series not in self.bia_code_priorities[provider]: + self.bia_code_priorities[provider][series] = {} + self.bia_code_priorities[provider][series][project] = code_priorities + + Logger.terminal(f"✅ BIA code priorities cached for {provider}/{series}/{project}") + + # Validate the constellation codes against BIA + self._validate_constellation_codes_against_bia() + + def _on_bia_error(self, error_msg: str): + """ + UI handler: BIA fetch failed. + + Arguments: + error_msg (str): Error message describing the failure + """ + self._bia_loading = False + self._show_bia_loading_indicator(False) + + Logger.console(f"⚠️ BIA fetch error: {error_msg}") + + # Don't show warnings for cancelled fetches (user-initiated) + if "cancelled" in error_msg.lower(): + return + + # Mark all codes as invalid (red strikethrough) + self._mark_all_codes_invalid() + + # Show BIA warning label + self._show_bia_warning(True) + + # Log to terminal (workflow tab) so user is aware BIA validation is unavailable + Logger.terminal(f"⚠️ Failed to fetch BIA file for selected PPP products - unable to validate codes") + + show_toast(self.parent, f"⚠️ Could not fetch BIA data: {error_msg}", duration=3000) + + def _on_bia_thread_finished(self): + """ + Slot called when the BIA thread has fully finished. + Safe to clean up references here. + """ + self._bia_worker = None + self._bia_thread = None + + def _show_bia_loading_indicator(self, show: bool): + """ + Show or hide a loading indicator on the Constellations tab. + + Arguments: + show (bool): True to show, False to hide + """ + if not hasattr(self, '_bia_loading_label') or self._bia_loading_label is None: + return + + # Reset to initial text when showing (in case it was changed by progress updates) + if show: + self._bia_loading_label.setText("⏳ Loading code priorities from .BIA file...") + + self._bia_loading_label.setVisible(show) + + def _validate_constellation_codes_against_bia(self): + """ + Validate the codes in each constellation list widget against the cached BIA codes. + Codes that are NOT in the .BIA file are marked with strikethrough and a different colour. + """ + # Get current PPP selection + provider = self.ui.pppProviderCombo.currentText() + series = self.ui.pppSeriesCombo.currentText() + project = self.ui.pppProjectCombo.currentText() + + # Get cached BIA codes for this selection + bia_codes = None + try: + bia_codes = self.bia_code_priorities.get(provider, {}).get(series, {}).get(project, None) + except (KeyError, TypeError, AttributeError): + pass + + if not bia_codes: + # No BIA data available, reset all items to normal styling + self._reset_constellation_list_styling() + return + + # Map widget names to constellation keys + widget_mapping = { + 'gpsListWidget': 'GPS', + 'galListWidget': 'GAL', + 'gloListWidget': 'GLO', + 'bdsListWidget': 'BDS', + 'qzsListWidget': 'QZS', + } + + # Colours for codes + valid_color = QColor('white') # White for valid + invalid_color = QColor('#FF6B6B') # Red for invalid + + for widget_name, constellation in widget_mapping.items(): + if not hasattr(self.ui, widget_name): + continue + + list_widget = getattr(self.ui, widget_name) + constellation_bia_codes = bia_codes.get(constellation, set()) + + for i in range(list_widget.count()): + item = list_widget.item(i) + if item is None: + continue + + # Get the code text (e.g., "L1C", "L2W") + code = item.text().strip() + + # Get current font + font = item.font() + + if code in constellation_bia_codes: + # Valid code - normal styling + font.setStrikeOut(False) + item.setFont(font) + item.setForeground(QBrush(valid_color)) + else: + # Invalid code - strikethrough + colour + font.setStrikeOut(True) + item.setFont(font) + item.setForeground(QBrush(invalid_color)) + + Logger.terminal(f"✅ Validated constellation codes against BIA for {provider}/{series}/{project}") + + def _reset_constellation_list_styling(self): + """ + Reset all constellation list widget items to normal styling (no strikethrough, white colour). + Called when BIA data is not available. + """ + widget_names = ['gpsListWidget', 'galListWidget', 'gloListWidget', 'bdsListWidget', 'qzsListWidget'] + normal_color = QColor('white') + + for widget_name in widget_names: + if not hasattr(self.ui, widget_name): + continue + + list_widget = getattr(self.ui, widget_name) + + for i in range(list_widget.count()): + item = list_widget.item(i) + if item is None: + continue + + font = item.font() + font.setStrikeOut(False) + item.setFont(font) + item.setForeground(QBrush(normal_color)) + + # Also hide BIA warning when resetting + self._show_bia_warning(False) + + def _mark_all_codes_invalid(self): + """ + Mark all constellation list widget items as invalid (red strikethrough). + Called when BIA file fetch fails. + """ + widget_names = ['gpsListWidget', 'galListWidget', 'gloListWidget', 'bdsListWidget', 'qzsListWidget'] + invalid_color = QColor('#ff6b6b') + + for widget_name in widget_names: + if not hasattr(self.ui, widget_name): + continue + + list_widget = getattr(self.ui, widget_name) + + for i in range(list_widget.count()): + item = list_widget.item(i) + if item is None: + continue + + font = item.font() + font.setStrikeOut(True) + item.setFont(font) + item.setForeground(QBrush(invalid_color)) + + def _show_bia_warning(self, show: bool): + """ + Show or hide the BIA warning label on the Constellations tab. + + Arguments: + show (bool): True to show warning, False to hide it. + """ + if hasattr(self, '_bia_warning_label'): + self._bia_warning_label.setVisible(show) + + def _on_analysis_thread_finished(self): + """ + Slot called when the analysis thread has fully finished. + Safe to clean up references here. + """ + # Clean up current thread references if it's no longer running + if hasattr(self, 'metadata_thread') and self.metadata_thread is not None: + if not self.metadata_thread.isRunning(): + self.worker = None + self.metadata_thread = None + + # Also clean any finished pending threads + self._pending_threads = [t for t in self._pending_threads if t.isRunning()] + def _on_ppp_provider_changed(self, provider_name: str): """ UI handler: when PPP provider changes, refresh project and series options. @@ -587,27 +1439,34 @@ def _on_ppp_provider_changed(self, provider_name: str): series_options = sorted(df['solution_type'].unique()) # Block signals before clearing and populating to prevent any duplicates in dropdown - self.ui.PPP_project.blockSignals(True) - self.ui.PPP_series.blockSignals(True) + self.ui.pppProjectCombo.blockSignals(True) + self.ui.pppSeriesCombo.blockSignals(True) - self.ui.PPP_project.clear() - self.ui.PPP_series.clear() + self.ui.pppProjectCombo.clear() + self.ui.pppSeriesCombo.clear() - self.ui.PPP_project.addItems(project_options) - self.ui.PPP_series.addItems(series_options) + self.ui.pppProjectCombo.addItems(project_options) + self.ui.pppSeriesCombo.addItems(series_options) - self.ui.PPP_project.setCurrentIndex(0) - self.ui.PPP_series.setCurrentIndex(0) + self.ui.pppProjectCombo.setCurrentIndex(0) + self.ui.pppSeriesCombo.setCurrentIndex(0) # Unblock signals now that the population is complete - self.ui.PPP_project.blockSignals(False) - self.ui.PPP_series.blockSignals(False) + self.ui.pppProjectCombo.blockSignals(False) + self.ui.pppSeriesCombo.blockSignals(False) + + # Update constellations combobox based on new PPP selection + self._update_constellations_for_ppp_selection() + + # If we're on the Constellations tab, trigger BIA fetch for new selection + if self.ui.configTabWidget.currentIndex() == 1: + self._on_config_tab_changed(1) except Exception as e: - self.ui.PPP_series.clear() - self.ui.PPP_series.addItem("None") - self.ui.PPP_project.clear() - self.ui.PPP_project.addItem("None") + self.ui.pppSeriesCombo.clear() + self.ui.pppSeriesCombo.addItem("None") + self.ui.pppProjectCombo.clear() + self.ui.pppProjectCombo.addItem("None") def _on_ppp_series_changed(self, selected_series: str): """ @@ -623,11 +1482,19 @@ def _on_ppp_series_changed(self, selected_series: str): filtered_df = df[df["solution_type"] == selected_series] valid_projects = sorted(filtered_df["project"].unique()) - self.ui.PPP_project.blockSignals(True) - self.ui.PPP_project.clear() - self.ui.PPP_project.addItems(valid_projects) - self.ui.PPP_project.setCurrentIndex(0) - self.ui.PPP_project.blockSignals(False) + self.ui.pppProjectCombo.blockSignals(True) + self.ui.pppProjectCombo.clear() + self.ui.pppProjectCombo.addItems(valid_projects) + self.ui.pppProjectCombo.setCurrentIndex(0) + self.ui.pppProjectCombo.blockSignals(False) + + # Update constellations combobox based on new PPP selection + self._update_constellations_for_ppp_selection() + + # If we are on the Constellations tab, trigger BIA fetch for new selection + # This may occur if the user is on this tab while PPP products are being fetched + if self.ui.configTabWidget.currentIndex() == 1: + self._on_config_tab_changed(1) def _on_ppp_project_changed(self, selected_project: str): """ @@ -645,13 +1512,21 @@ def _on_ppp_project_changed(self, selected_project: str): if hasattr(self, "_valid_series_for_provider"): valid_series = [s for s in valid_series if s in self._valid_series_for_provider] - self.ui.PPP_series.blockSignals(True) - self.ui.PPP_series.clear() - self.ui.PPP_series.addItems(valid_series) - self.ui.PPP_series.setCurrentIndex(0) - self.ui.PPP_series.blockSignals(False) + self.ui.pppSeriesCombo.blockSignals(True) + self.ui.pppSeriesCombo.clear() + self.ui.pppSeriesCombo.addItems(valid_series) + self.ui.pppSeriesCombo.setCurrentIndex(0) + self.ui.pppSeriesCombo.blockSignals(False) + + # Update constellations combobox based on new PPP selection + self._update_constellations_for_ppp_selection() - Logger.terminal(f"[UI] Filtered PPP_series for project '{selected_project}': {valid_series}") + Logger.terminal(f"✅ Filtered PPP series for project '{selected_project}': {valid_series}") + + # If we are on the Constellations tab, trigger BIA fetch for new selection + # This may occur if the user is on this tab while PPP products are being fetched + if self.ui.configTabWidget.currentIndex() == 1: + self._on_config_tab_changed(1) def load_output_dir(self): """ @@ -795,44 +1670,44 @@ def _enable_free_text_for_receiver_and_antenna(self): """ Allow users to enter custom receiver/antenna types via popup, mirroring to UI. """ - self.ui.Receiver_type.setEditable(True) - self.ui.Receiver_type.lineEdit().setReadOnly(True) - self.ui.Antenna_type.setEditable(True) - self.ui.Antenna_type.lineEdit().setReadOnly(True) + self.ui.receiverTypeCombo.setEditable(True) + self.ui.receiverTypeCombo.lineEdit().setReadOnly(True) + self.ui.antennaTypeCombo.setEditable(True) + self.ui.antennaTypeCombo.lineEdit().setReadOnly(True) # Receiver type free text def _ask_receiver_type(): - current_text = self.ui.Receiver_type.currentText().strip() + current_text = self.ui.receiverTypeCombo.currentText().strip() text, ok = QInputDialog.getText( - self.ui.Receiver_type, + self.ui.receiverTypeCombo, "Receiver Type", "Enter receiver type:", text=current_text # prefill with current ) if ok and text: - self.ui.Receiver_type.clear() - self.ui.Receiver_type.addItem(text) - self.ui.Receiver_type.lineEdit().setText(text) + self.ui.receiverTypeCombo.clear() + self.ui.receiverTypeCombo.addItem(text) + self.ui.receiverTypeCombo.lineEdit().setText(text) self.ui.receiverTypeValue.setText(text) - self.ui.Receiver_type.showPopup = _ask_receiver_type + self.ui.receiverTypeCombo.showPopup = _ask_receiver_type # Antenna type free text def _ask_antenna_type(): - current_text = self.ui.Antenna_type.currentText().strip() + current_text = self.ui.antennaTypeCombo.currentText().strip() text, ok = QInputDialog.getText( - self.ui.Antenna_type, + self.ui.antennaTypeCombo, "Antenna Type", "Enter antenna type:", text=current_text # prefill with current ) if ok and text: - self.ui.Antenna_type.clear() - self.ui.Antenna_type.addItem(text) - self.ui.Antenna_type.lineEdit().setText(text) + self.ui.antennaTypeCombo.clear() + self.ui.antennaTypeCombo.addItem(text) + self.ui.antennaTypeCombo.lineEdit().setText(text) self.ui.antennaTypeValue.setText(text) - self.ui.Antenna_type.showPopup = _ask_antenna_type + self.ui.antennaTypeCombo.showPopup = _ask_antenna_type # ========================================================== # Antenna offset popup @@ -1026,11 +1901,11 @@ def extract_ui_values(self, rnx_path): """ # Extract user input from the UI and assign it to class variables. - mode_raw = self.ui.Mode.currentText() if self.ui.Mode.currentText() != "Select one" else "Static" + mode_raw = self.ui.modeCombo.currentText() if self.ui.modeCombo.currentText() != "Select one" else "Static" # Get constellations from the actual dropdown selections, not the label constellations_raw = "" - combo = self.ui.Constellations_2 + combo = self.ui.constellationsCombo if hasattr(combo, '_constellation_model') and combo._constellation_model: model = combo._constellation_model selected = [model.item(i).text() for i in range(model.rowCount()) if @@ -1044,9 +1919,12 @@ def extract_ui_values(self, rnx_path): receiver_type = self.ui.receiverTypeValue.text() antenna_type = self.ui.antennaTypeValue.text() antenna_offset_raw = self.ui.antennaOffsetButton.text() # Get from button, not value label - ppp_provider = self.ui.PPP_provider.currentText() if self.ui.PPP_provider.currentText() != "Select one" else "" - ppp_series = self.ui.PPP_series.currentText() if self.ui.PPP_series.currentText() != "Select one" else "" - ppp_project = self.ui.PPP_project.currentText() if self.ui.PPP_project.currentText() != "Select one" else "" + ppp_provider = self.ui.pppProviderCombo.currentText() if self.ui.pppProviderCombo.currentText() != "Select one" else "" + ppp_series = self.ui.pppSeriesCombo.currentText() if self.ui.pppSeriesCombo.currentText() != "Select one" else "" + ppp_project = self.ui.pppProjectCombo.currentText() if self.ui.pppProjectCombo.currentText() != "Select one" else "" + + # Extract observation codes from combos + obs_codes = self._extract_observation_codes() # Parsed values start_epoch, end_epoch = self.parse_time_window(time_window_raw) @@ -1055,6 +1933,11 @@ def extract_ui_values(self, rnx_path): marker_name = self.extract_marker_name(rnx_path) mode = self.determine_mode_value(mode_raw) + # Output toggles + gpx_output = self.ui.gpxCheckbox.isChecked() if hasattr(self.ui, "gpxCheckbox") else True + pos_output = self.ui.posCheckbox.isChecked() if hasattr(self.ui, "posCheckbox") else True + trace_output_network = self.ui.traceCheckbox.isChecked() if hasattr(self.ui, "traceCheckbox") else False + # Returned the values found as a dataclass for easier access return self.ExtractedInputs( marker_name=marker_name, @@ -1071,8 +1954,51 @@ def extract_ui_values(self, rnx_path): ppp_project=ppp_project, rnx_path=rnx_path, output_path=str(self.output_dir), + gps_codes=obs_codes.get('gps', []), + gal_codes=obs_codes.get('gal', []), + glo_codes=obs_codes.get('glo', []), + bds_codes=obs_codes.get('bds', []), + qzs_codes=obs_codes.get('qzs', []), + gpx_output=gpx_output, + pos_output=pos_output, + trace_output_network=trace_output_network, ) + def _extract_observation_codes(self) -> dict: + """ + Extract selected observation codes from all constellation list widgets in priority order. + + Returns: + dict: Dictionary mapping constellation names to lists of selected codes in order + """ + obs_codes = {} + + list_widget_mapping = { + 'gps': 'gpsListWidget', + 'gal': 'galListWidget', + 'glo': 'gloListWidget', + 'bds': 'bdsListWidget', + 'qzs': 'qzsListWidget' + } + + for const_name, widget_name in list_widget_mapping.items(): + if not hasattr(self.ui, widget_name): + obs_codes[const_name] = [] + continue + + list_widget = getattr(self.ui, widget_name) + + # Extract checked items in their current order (priority order) + selected = [] + for i in range(list_widget.count()): + item = list_widget.item(i) + if item.checkState() == Qt.CheckState.Checked: + selected.append(item.text()) + + obs_codes[const_name] = selected + + return obs_codes + def on_show_config(self): """ UI handler: reload config, apply UI values, write changes, then open the YAML. @@ -1185,6 +2111,232 @@ def on_run_pea(self): # --- Emit signal for MainWindow --- self.pea_ready.emit() + def _on_reset_config_clicked(self): + """ + UI handler: reset the configuration file and UI to defaults. + Shows a confirmation dialog before proceeding. + """ + # Show confirmation dialog + reply = QMessageBox.question( + self.parent, + "Reset Configuration", + "This will reset all settings to their defaults.\n\n" + "• The configuration file will be regenerated from the template\n" + "• All UI fields will be cleared\n" + "• You will need to re-select your RINEX file and output directory\n\n" + "Are you sure you want to continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply != QMessageBox.StandardButton.Yes: + return + + try: + # Stop any running background workers first + self.stop_all() + + # Reset the config file + self.execution.reset_config() + + # Reset the UI to defaults + self.reset_ui_to_defaults() + + Logger.terminal("🔄 Configuration and UI reset to defaults") + show_toast(self.parent, "🔄 Configuration and UI reset to defaults", duration=3000) + + except Exception as e: + Logger.terminal(f"⚠️ Failed to reset configuration: {e}") + QMessageBox.critical( + self.parent, + "Reset Failed", + f"Failed to reset configuration:\n{e}" + ) + + def _open_user_manual(self): + """ + Open the USER_MANUAL.md file + Attempts to open the file in the system's default markdown viewer / browser + """ + try: + # Get the path from common_dirs + manual_path = USER_MANUAL_PATH + + if not manual_path.exists(): + Logger.terminal(f"⚠️ User manual not found at: {manual_path}") + QMessageBox.warning( + self.parent, + "User Manual Not Found", + f"Could not find the user manual at:\n{manual_path}\n\n" + "Please ensure the file exists at /docs/USER_MANUAL.md" + ) + return + + Logger.terminal(f"📖 Opening user manual: {manual_path}") + + # Try to open the file with the default application + if os.name == 'nt': # Windows + os.startfile(manual_path) + elif os.name == 'posix': # macOS and Linux + if subprocess.call(['which', 'xdg-open'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: + subprocess.Popen(['xdg-open', str(manual_path)]) + elif subprocess.call(['which', 'open'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: + subprocess.Popen(['open', str(manual_path)]) + else: + # Fall back to browser + webbrowser.open(f'file://{manual_path.absolute()}') + else: + # Fall back to browser for other platforms + webbrowser.open(f'file://{manual_path.absolute()}') + + Logger.terminal("✅ User manual opened successfully") + + except Exception as e: + Logger.terminal(f"⚠️ Failed to open user manual: {e}") + QMessageBox.critical( + self.parent, + "Error Opening Manual", + f"Failed to open the user manual:\n{e}" + ) + + def reset_ui_to_defaults(self): + """ + Reset all UI fields to their default/initial states. + This is the "start from scratch" reset that clears all user inputs. + """ + # Clear internal state + self.rnx_file = None + self.output_dir = None + self.products_df = pd.DataFrame() + if hasattr(self, 'last_rinex_path'): + delattr(self, 'last_rinex_path') + if hasattr(self, 'valid_analysis_centers'): + self.valid_analysis_centers = [] + if hasattr(self, '_valid_project_series_df'): + delattr(self, '_valid_project_series_df') + if hasattr(self, '_valid_series_for_provider'): + delattr(self, '_valid_series_for_provider') + if hasattr(self, 'start_time'): + delattr(self, 'start_time') + if hasattr(self, 'end_time'): + delattr(self, 'end_time') + + # Reset MainWindow state + self.parent.rnx_file = None + self.parent.output_dir = None + + # Reset General Tab + + # Mode combo - reset to placeholder + self.ui.modeCombo.clear() + self.ui.modeCombo.addItem("Select one") + self.ui.modeCombo.setCurrentIndex(0) + + # Constellations combo - reset to placeholder + self.ui.constellationsCombo.clear() + self.ui.constellationsCombo.setEditable(True) + self.ui.constellationsCombo.lineEdit().clear() + self.ui.constellationsCombo.lineEdit().setPlaceholderText("Select one or more") + self.ui.constellationsValue.setText("Constellations") + # Clear any custom model + if hasattr(self.ui.constellationsCombo, '_constellation_model'): + delattr(self.ui.constellationsCombo, '_constellation_model') + if hasattr(self.ui.constellationsCombo, '_constellation_on_item_changed'): + delattr(self.ui.constellationsCombo, '_constellation_on_item_changed') + + # Time window - reset to placeholder text + self.ui.timeWindowButton.setText("Start / End") + self.ui.timeWindowValue.setText("Time Window") + + # Data interval - reset to placeholder + self.ui.dataIntervalButton.setText("Interval (Seconds)") + self.ui.dataIntervalValue.setText("Data interval") + + # Receiver type - reset to placeholder + self.ui.receiverTypeCombo.clear() + self.ui.receiverTypeCombo.addItem("Import text") + self.ui.receiverTypeCombo.setCurrentIndex(0) + if self.ui.receiverTypeCombo.lineEdit(): + self.ui.receiverTypeCombo.lineEdit().setText("Import text") + self.ui.receiverTypeValue.setText("Receiver Type") + + # Antenna type - reset to placeholder + self.ui.antennaTypeCombo.clear() + self.ui.antennaTypeCombo.addItem("Import text") + self.ui.antennaTypeCombo.setCurrentIndex(0) + if self.ui.antennaTypeCombo.lineEdit(): + self.ui.antennaTypeCombo.lineEdit().setText("Import text") + self.ui.antennaTypeValue.setText("") + + # Antenna offset - reset to default + self.ui.antennaOffsetButton.setText("0.0, 0.0, 0.0") + self.ui.antennaOffsetValue.setText("0.0, 0.0, 0.0") + + # PPP Provider - reset to placeholder + self.ui.pppProviderCombo.clear() + self.ui.pppProviderCombo.addItem("Select one") + self.ui.pppProviderCombo.setCurrentIndex(0) + + # PPP Series - reset to placeholder + self.ui.pppSeriesCombo.clear() + self.ui.pppSeriesCombo.addItem("Select one") + self.ui.pppSeriesCombo.setCurrentIndex(0) + + # PPP Project - reset to placeholder + self.ui.pppProjectCombo.clear() + self.ui.pppProjectCombo.addItem("Select one") + self.ui.pppProjectCombo.setCurrentIndex(0) + + # Reset Constellations Tab + + # Clear all constellation list widgets + list_widgets = ['gpsListWidget', 'galListWidget', 'gloListWidget', 'bdsListWidget', 'qzsListWidget'] + for widget_name in list_widgets: + if hasattr(self.ui, widget_name): + list_widget = getattr(self.ui, widget_name) + list_widget.clear() + list_widget.setEnabled(False) + + # Hide all constellation widgets and show placeholder + self._hide_all_constellation_widgets() + self._update_constellation_placeholder(True) + + # Reset Output Tab + + # Reset output checkboxes to defaults (POS and GPX true, TRACE false) + if hasattr(self.ui, 'posCheckbox'): + self.ui.posCheckbox.setChecked(True) + if hasattr(self.ui, 'gpxCheckbox'): + self.ui.gpxCheckbox.setChecked(True) + if hasattr(self.ui, 'traceCheckbox'): + self.ui.traceCheckbox.setChecked(False) + + # Reset Button States and Locks + + # Disable buttons that should be locked on startup + self.ui.outputButton.setEnabled(False) + self.ui.showConfigButton.setEnabled(False) + self.ui.processButton.setEnabled(False) + self.ui.stopAllButton.setEnabled(False) + + # Ensure launch buttons are enabled + self.ui.observationsButton.setEnabled(True) + self.ui.cddisCredentialsButton.setEnabled(True) + + # Reset Visualisation Panel + # Clear the visualisation panel + if hasattr(self.parent, 'visCtrl'): + self.parent.visCtrl.set_html_files([]) + # Clear the web view + if hasattr(self.ui, 'webEngineView'): + self.ui.webEngineView.setHtml("") + + # Reset config tab to General + # Not really needed since the "Reset Config" button is in General, + # But just in case for the future / aesthetics + if hasattr(self.ui, 'configTabWidget'): + self.ui.configTabWidget.setCurrentIndex(0) + # endregion # region Utility Functions @@ -1355,6 +2507,18 @@ class ExtractedInputs: rnx_path: str output_path: str + # Observation codes for each constellation + gps_codes: list[str] = None + gal_codes: list[str] = None + glo_codes: list[str] = None + bds_codes: list[str] = None + qzs_codes: list[str] = None + + # Output toggles + gpx_output: bool = True + pos_output: bool = True + trace_output_network: bool = False + # endregion # region Statics @@ -1503,14 +2667,14 @@ def stop_all(self): self (InputController): Controller instance owning the worker/thread. """ try: - if hasattr(self, "worker"): - _safe_call_stop(self.worker) + # Request the worker to stop - it will emit cancelled signal when done + if hasattr(self, "worker") and self.worker is not None: + self.worker.stop() # Restore cursor when stopping if hasattr(self, "parent"): self.parent.setCursor(Qt.CursorShape.ArrowCursor) except Exception: pass - # Bind without touching existing class body setattr(InputController, "stop_all", stop_all) \ No newline at end of file diff --git a/scripts/GinanUI/app/controllers/visualisation_controller.py b/scripts/GinanUI/app/controllers/visualisation_controller.py index 0519207d2..85a9c0bb6 100644 --- a/scripts/GinanUI/app/controllers/visualisation_controller.py +++ b/scripts/GinanUI/app/controllers/visualisation_controller.py @@ -88,7 +88,7 @@ def set_html_files(self, paths: Sequence[str]): >>> controller.current_index 0 """ - self.html_files = list(paths) + self.html_files = list(dict.fromkeys(paths)) # Refresh selector if bound if self._selector: self._refresh_selector() @@ -283,13 +283,10 @@ def build_from_execution(self): new_html_paths = exec_obj.build_pos_plots() # default output to tests/resources/outputData/visual - existing_html_paths = self._find_existing_html_files() + # Only use newly generated plots, not old ones from previous runs + new_html_paths.sort(key=lambda x: os.path.basename(x)) - all_html_paths = list(set(new_html_paths + existing_html_paths)) - - all_html_paths.sort(key=lambda x: os.path.basename(x)) - - self.set_html_files(all_html_paths) + self.set_html_files(new_html_paths) except Exception as e: from PySide6.QtWidgets import QMessageBox diff --git a/scripts/GinanUI/app/main_window.py b/scripts/GinanUI/app/main_window.py index 430b9d47a..f331c9225 100644 --- a/scripts/GinanUI/app/main_window.py +++ b/scripts/GinanUI/app/main_window.py @@ -1,9 +1,9 @@ from pathlib import Path from typing import Optional -from PySide6.QtCore import QUrl, Signal, QThread, Slot, Qt, QRegularExpression +from PySide6.QtCore import QUrl, Signal, QThread, Slot, Qt, QRegularExpression, QCoreApplication from scripts.GinanUI.app.utils.logger import Logger -from PySide6.QtWidgets import QMainWindow, QDialog, QVBoxLayout, QPushButton, QComboBox +from PySide6.QtWidgets import QMainWindow, QDialog, QVBoxLayout, QPushButton, QComboBox, QMessageBox from PySide6.QtWebEngineWidgets import QWebEngineView from PySide6.QtGui import QTextCursor, QTextDocument @@ -17,7 +17,6 @@ from scripts.GinanUI.app.utils.workers import PeaExecutionWorker, DownloadWorker from scripts.GinanUI.app.models.archive_manager import archive_products_if_selection_changed, archive_products, archive_old_outputs from scripts.GinanUI.app.models.execution import INPUT_PRODUCTS_PATH -from scripts.GinanUI.app.utils.logger import Logger # Optional toggle for development visualization testing test_visualisation = False @@ -46,19 +45,32 @@ def __init__(self): # Add rounded corners to UI elements self.setStyleSheet(""" - QPushButton { - border-radius: 4px; - } - QPushButton:disabled { - border-radius: 4px; - } - QTextEdit { - border-radius: 4px; - } - QComboBox { - border-radius: 4px; - } - """) + QPushButton { + border-radius: 4px; + } + QPushButton:disabled { + border-radius: 4px; + } + QTextEdit { + border-radius: 4px; + } + QComboBox { + border-radius: 4px; + } + QMessageBox QPushButton { + border-radius: 4px; + background-color: #2c5d7c; + color: white; + padding: 6px 16px; + min-width: 60px; + } + QMessageBox QPushButton:hover { + background-color: #214861; + } + QMessageBox QPushButton:pressed { + background-color: #1a3649; + } + """) # Fix macOS tab widget styling self._fix_macos_tab_styling() @@ -83,12 +95,13 @@ def __init__(self): self.atx_required_for_rnx_extraction = False # File required to extract info from RINEX self.metadata_downloaded = False self.offline_mode = False # Track if running without internet + self._pending_threads = [] # Track threads pending cleanup # Visualisation widgets - self.visCtrl.bind_open_button(self.ui.openInBrowserBtn) + self.visCtrl.bind_open_button(self.ui.openInBrowserButton) - self.visCtrl.bind_selector(self.ui.visSelector) + self.visCtrl.bind_selector(self.ui.visualisationSelectorCombo) archive_products(INPUT_PRODUCTS_PATH, "startup_archival", True) @@ -97,20 +110,19 @@ def __init__(self): # Only start metadata download if we have internet connection if not self.offline_mode: - self.metadata_thread = QThread() self.metadata_worker = DownloadWorker() - self.metadata_worker.moveToThread(self.metadata_thread) + self.metadata_thread, _ = self._setup_worker_thread( + self.metadata_worker, + self._on_metadata_download_finished, + self._on_download_progress, + thread_attr='metadata_thread', + worker_attr='metadata_worker' + ) - # Signals - self.metadata_thread.started.connect(self.metadata_worker.run) - self.metadata_worker.progress.connect(self._on_download_progress) - self.metadata_worker.finished.connect(self._on_metadata_download_finished) - self.metadata_worker.atx_downloaded.connect(self._on_atx_downloaded) + self.metadata_thread.setObjectName("MetadataDownloadWorker") - # Cleanup - self.metadata_worker.finished.connect(self.metadata_thread.quit) - self.metadata_worker.finished.connect(self.metadata_worker.deleteLater) - self.metadata_thread.finished.connect(self.metadata_thread.deleteLater) + # Connect additional signal specific to metadata download + self.metadata_worker.atx_downloaded.connect(self._on_atx_downloaded) self.metadata_thread.start() else: Logger.terminal("⚠️ Skipping metadata download - running in offline mode") @@ -131,16 +143,44 @@ def log_message(self, msg: str, channel = "terminal"): raise ValueError("[MainWindow] Invalid channel for log_message") def _set_processing_state(self, processing: bool): - """Enable/disable UI elements during processing""" + """Enable / disable UI elements during processing""" self.is_processing = processing - - # Disable/enable the process button - self.ui.processButton.setEnabled(not processing) - - # Optionally disable other critical UI elements during processing - self.ui.observationsButton.setEnabled(not processing) - self.ui.outputButton.setEnabled(not processing) - self.ui.showConfigButton.setEnabled(not processing) + enabled = not processing + + # Control buttons + self.ui.processButton.setEnabled(enabled) + self.ui.stopAllButton.setEnabled(processing) + self.ui.cddisCredentialsButton.setEnabled(enabled) + self.ui.observationsButton.setEnabled(enabled) + self.ui.outputButton.setEnabled(enabled) + self.ui.showConfigButton.setEnabled(enabled) + self.ui.resetConfigButton.setEnabled(enabled) + + # PPP provider / series / project combos + self.ui.pppProviderCombo.setEnabled(enabled) + self.ui.pppProjectCombo.setEnabled(enabled) + self.ui.pppSeriesCombo.setEnabled(enabled) + + # "General" config tab + self.ui.modeCombo.setEnabled(enabled) + self.ui.constellationsCombo.setEnabled(enabled) + self.ui.receiverTypeCombo.setEnabled(enabled) + self.ui.antennaTypeCombo.setEnabled(enabled) + self.ui.antennaOffsetButton.setEnabled(enabled) + self.ui.timeWindowButton.setEnabled(enabled) + self.ui.dataIntervalButton.setEnabled(enabled) + + # "Constellations" config tab + self.ui.gpsListWidget.setEnabled(enabled) + self.ui.galListWidget.setEnabled(enabled) + self.ui.gloListWidget.setEnabled(enabled) + self.ui.bdsListWidget.setEnabled(enabled) + self.ui.qzsListWidget.setEnabled(enabled) + + # "Output" config tab + self.ui.posCheckbox.setEnabled(enabled) + self.ui.gpxCheckbox.setEnabled(enabled) + self.ui.traceCheckbox.setEnabled(enabled) # Update button text to show processing state if processing: @@ -151,29 +191,117 @@ def _set_processing_state(self, processing: bool): self.ui.processButton.setText("Process") self.setCursor(Qt.CursorShape.ArrowCursor) + def _setup_worker_thread(self, worker, finished_callback, progress_callback=None, thread_attr=None, worker_attr=None): + """ + Helper method to set up a worker in a QThread with standard cleanup. + + Args: + worker: The worker object to run in the thread + finished_callback: Callback to connect to worker.finished signal + progress_callback: Optional callback to connect to worker.progress signal + thread_attr: Optional attribute name to clear when thread finishes + worker_attr: Optional attribute name to clear when thread finishes + + Returns: + tuple: (thread, worker) for storing references + """ + thread = QThread() + worker.moveToThread(thread) + + # Connect started signal + thread.started.connect(worker.run) + + # Connect finished signal + worker.finished.connect(finished_callback) + + # Connect progress signal if provided + if progress_callback and hasattr(worker, 'progress'): + worker.progress.connect(progress_callback) + + # Connect cleanup signals + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + + # Clear our references when thread finishes to avoid accessing deleted objects + if thread_attr and worker_attr: + def clear_references(): + if hasattr(self, thread_attr) and getattr(self, thread_attr) is thread: + setattr(self, thread_attr, None) + if hasattr(self, worker_attr) and getattr(self, worker_attr) is worker: + setattr(self, worker_attr, None) + + thread.finished.connect(clear_references) + + return thread, worker + + def _cleanup_thread(self, thread_attr: str, worker_attr: str): + """ + Request cancellation of a running thread and move it to _pending_threads + + Args: + thread_attr: Name of the thread attribute (e.g., 'download_thread') + worker_attr: Name of the worker attribute (e.g., 'download_worker') + """ + worker = getattr(self, worker_attr, None) + thread = getattr(self, thread_attr, None) + + # Try to stop the worker + try: + if worker is not None and hasattr(worker, 'stop'): + worker.stop() + except RuntimeError: + pass # Object already deleted + + # Check if thread is still running (with safety check for deleted objects) + thread_running = False + try: + if thread is not None: + thread_running = thread.isRunning() + except RuntimeError: + pass # C++ object already deleted + + if thread_running: + # Disconnect old signals to prevent callbacks to stale state + try: + worker.finished.disconnect() + if hasattr(worker, 'cancelled'): + worker.cancelled.disconnect() + if hasattr(worker, 'progress'): + worker.progress.disconnect() + except (TypeError, RuntimeError): + pass # Already disconnected or object deleted + + # Keep reference alive until thread actually finishes + old_thread = thread + + def cleanup_old_thread(): + if old_thread in self._pending_threads: + self._pending_threads.remove(old_thread) + + try: + old_thread.finished.connect(cleanup_old_thread) + self._pending_threads.append(old_thread) + except RuntimeError: + pass # Object already deleted + + # Clear current references so new thread can be created + setattr(self, worker_attr, None) + setattr(self, thread_attr, None) + def on_files_ready(self, rnx_path: str, out_path: str): self.rnx_file = rnx_path self.output_dir = out_path def _on_process_clicked(self): if not self.rnx_file or not self.output_dir: - Logger.terminal("⚠️ Please select RINEX and output directory first.") + Logger.terminal("⚠️ Please select RINEX and output directory first") return # Check if in offline mode if self.offline_mode: Logger.terminal("⚠️ Cannot process: Ginan-UI is running in offline mode (no internet connection)") - from scripts.GinanUI.app.utils.toast import show_toast - show_toast(self, "⚠️ Processing requires internet connection", 4000) - - from PySide6.QtWidgets import QMessageBox - msg = QMessageBox(self) - msg.setIcon(QMessageBox.Icon.Warning) - msg.setWindowTitle("Offline Mode") - msg.setText("Processing requires an internet connection to download PPP products from CDDIS.") - msg.setInformativeText("Please check your internet connection and restart Ginan-UI.") - msg.setStandardButtons(QMessageBox.StandardButton.Ok) - msg.exec() + self._show_processing_offline_warning() return # Prevent multiple simultaneous processing @@ -185,9 +313,9 @@ def _on_process_clicked(self): self._set_processing_state(True) # Get PPP params from UI - ac = self.ui.PPP_provider.currentText() - project = self.ui.PPP_project.currentText() - series = self.ui.PPP_series.currentText() + ac = self.ui.pppProviderCombo.currentText() + project = self.ui.pppProjectCombo.currentText() + series = self.ui.pppSeriesCombo.currentText() # Archive old products if needed current_selection = {"ppp_provider": ac, "ppp_project": project, "ppp_series": series} @@ -198,9 +326,10 @@ def _on_process_clicked(self): if archive_dir: Logger.terminal(f"📦 Archived old PPP products → {archive_dir}") - output_archive = archive_old_outputs(Path(self.output_dir), archive_dir) + visual_dir = Path(self.output_dir) / "visual" + output_archive = archive_old_outputs(Path(self.output_dir), visual_dir) if output_archive: - Logger.terminal(f" Archived old outputs → {output_archive}") + Logger.terminal(f"📦 Archived old outputs → {output_archive}") # List products to be downloaded x = self.inputCtrl.products_df @@ -209,20 +338,20 @@ def _on_process_clicked(self): # Reset progress self.download_progress.clear() + # Clean up any existing download thread before starting a new one + self._cleanup_thread('download_thread', 'download_worker') + # Start download in background - self.download_thread = QThread() self.download_worker = DownloadWorker(products=products, start_epoch=self.inputCtrl.start_time, end_epoch=self.inputCtrl.end_time) - self.download_worker.moveToThread(self.download_thread) - - # Signals - self.download_thread.started.connect(self.download_worker.run) - self.download_worker.progress.connect(self._on_download_progress) - self.download_worker.finished.connect(self._on_download_finished) + self.download_thread, _ = self._setup_worker_thread( + self.download_worker, + self._on_download_finished, + self._on_download_progress, + thread_attr='download_thread', + worker_attr='download_worker' + ) - # Cleanup - self.download_worker.finished.connect(self.download_thread.quit) - self.download_worker.finished.connect(self.download_worker.deleteLater) - self.download_thread.finished.connect(self.download_thread.deleteLater) + self.download_thread.setObjectName("ProductDownloadWorker") Logger.terminal("📡 Starting PPP product downloads...") self.download_thread.start() @@ -258,7 +387,7 @@ def _on_download_progress(self, filename: str, percent: int): def _on_atx_downloaded(self, filename: str): self.atx_required_for_rnx_extraction = True - Logger.terminal(f"✅ ATX file {filename} installed - ready for RINEX parsing.") + Logger.terminal(f"✅ ATX file {filename} installed - ready for RINEX parsing") def _on_metadata_download_finished(self, message): Logger.terminal(message) @@ -276,21 +405,26 @@ def _on_download_error(self, msg): def _start_pea_execution(self): Logger.terminal("⚙️ Starting PEA execution in background...") - self.thread = QThread() - self.worker = PeaExecutionWorker(self.execution) - self.worker.moveToThread(self.thread) + # Clean up any existing PEA thread before starting a new one + self._cleanup_thread('pea_thread', 'pea_worker') + + # Reset stop flag for new execution + self.execution.reset_stop_flag() - self.thread.started.connect(self.worker.run) - self.worker.finished.connect(self._on_pea_finished) + self.pea_worker = PeaExecutionWorker(self.execution) + self.pea_thread, _ = self._setup_worker_thread( + self.pea_worker, + self._on_pea_finished, + thread_attr='pea_thread', + worker_attr='pea_worker' + ) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) + self.pea_thread.setObjectName("PeaExecutionWorker") - self.thread.start() + self.pea_thread.start() def _on_pea_finished(self): - Logger.terminal("✅ PEA processing completed.") + Logger.terminal("✅ PEA processing completed") show_toast(self, "✅ PEA Processing complete!", 3000) self._run_visualisation() self._set_processing_state(False) @@ -300,26 +434,43 @@ def _on_pea_error(self, msg: str): self._set_processing_state(False) def _run_visualisation(self): - try: - Logger.terminal("📊 Generating plots from PEA output...") - html_files = self.execution.build_pos_plots() - if html_files: - self.visCtrl.set_html_files(html_files) - else: - Logger.terminal("⚠️ No plots found.") - except Exception as err: - Logger.terminal(f"⚠️ Plot generation failed: {err}") - - if test_visualisation: + all_html_files = [] + + # Check checkbox states to determine which visualisations to generate + pos_enabled = self.ui.posCheckbox.isChecked() if hasattr(self.ui, "posCheckbox") else True + trace_enabled = self.ui.traceCheckbox.isChecked() if hasattr(self.ui, "traceCheckbox") else False + + # Generate POS plots + if pos_enabled: try: - Logger.terminal("[Dev] Testing static visualisation...") - test_output_dir = Path(__file__).resolve().parents[1] / "tests" / "resources" / "outputData" - test_visual_dir = test_output_dir / "visual" - test_visual_dir.mkdir(parents=True, exist_ok=True) - self.visCtrl.build_from_execution() - Logger.terminal("[Dev] Static plot generation complete.") + Logger.terminal("📊 Generating position plots from PEA output...") + pos_html_files = self.execution.build_pos_plots() + if pos_html_files: + all_html_files.extend(pos_html_files) + else: + Logger.terminal("⚠️ No position plots found") except Exception as err: - Logger.terminal(f"[Dev] Test plot generation failed: {err}") + Logger.terminal(f"⚠️ Position plot generation failed: {err}") + + # Generate TRACE residual plots + if trace_enabled: + try: + Logger.terminal("📊 Generating trace residual plots from PEA output...") + trace_html_files = self.execution.build_trace_plots() + if trace_html_files: + all_html_files.extend(trace_html_files) + else: + Logger.terminal("⚠️ No trace plots found") + except Exception as err: + Logger.terminal(f"⚠️ Trace plot generation failed: {err}") + + # Update the visualisation panel with all generated plots (or empty list) + self.visCtrl.set_html_files(all_html_files) + + if not all_html_files: + Logger.terminal("📊 No visualisations generated - selector disabled") + # Clear the web view display + self.ui.webEngineView.setHtml("") def _validate_cddis_credentials_once(self): """ @@ -328,7 +479,7 @@ def _validate_cddis_credentials_once(self): """ ok, where = gui_validate_netrc() if not ok and hasattr(self.ui, "cddisCredentialsButton"): - Logger.terminal("⚠️ No Earthdata credentials. Opening CDDIS Credentials dialog…") + Logger.terminal("⚠️ No Earthdata credentials. Opening CDDIS Credentials dialog...") self.ui.cddisCredentialsButton.click() ok, where = gui_validate_netrc() if not ok: @@ -346,11 +497,11 @@ def _validate_cddis_credentials_once(self): ok_conn, why = test_cddis_connection() if not ok_conn: Logger.terminal( - f"❌ CDDIS connectivity check failed: {why}. Please verify Earthdata credentials via the CDDIS Credentials dialog." + f"❌ CDDIS connectivity check failed: {why}. Please verify Earthdata credentials via the CDDIS Credentials dialog" ) self._show_offline_warning("Connection test failed", why) return - Logger.terminal(f"✅ CDDIS connectivity check passed in {why.split(' ')[-2]} seconds.") + Logger.terminal(f"✅ CDDIS connectivity check passed in {why.split(' ')[-2]} seconds") # Connection successful - set email write_email(email_candidate) @@ -363,13 +514,25 @@ def _validate_cddis_credentials_once(self): self._show_offline_warning("No internet connection", error_msg) return + def _show_processing_offline_warning(self): + """ + Show a warning when attempting to process while in offline mode. + """ + show_toast(self, "⚠️ Processing requires internet connection", 4000) + + msg = QMessageBox(self) + msg.setIcon(QMessageBox.Icon.Warning) + msg.setWindowTitle("Offline Mode") + msg.setText("Processing requires an internet connection to download PPP products from CDDIS") + msg.setInformativeText("Please check your internet connection and restart Ginan-UI") + msg.setStandardButtons(QMessageBox.StandardButton.Ok) + msg.exec() + def _show_offline_warning(self, title: str, details: str): """ Show a warning dialog when Ginan-UI starts without internet. The app can continue to run, but very limited (some features are unavailable) """ - from PySide6.QtWidgets import QMessageBox - # Mark as offline mode self.offline_mode = True @@ -385,7 +548,7 @@ def _show_offline_warning(self, title: str, details: str): "- Scanning for available analysis centers
" "- Retrieving GNSS data products

" "The application will continue to run in offline mode.
" - "You can still view configurations and access local files." + "You can still view configurations and access local files" ) msg.setDetailedText(f"Error details:\n{title}: {details}") msg.setStandardButtons(QMessageBox.StandardButton.Ok) @@ -395,13 +558,12 @@ def _show_offline_warning(self, title: str, details: str): msg.exec() # Also show a toast notification - from scripts.GinanUI.app.utils.toast import show_toast show_toast(self, "⚠️ Running in offline mode - limited functionality", 8000) # Added: unified stop entry, wired to an optional UI button @Slot() def on_stopAllClicked(self): - Logger.terminal("🛑 Stop requested — stopping all running tasks...") + Logger.terminal("🛑 Stop requested - stopping all running tasks...") # Stop the metadata worker in InputController, if present try: @@ -412,15 +574,13 @@ def on_stopAllClicked(self): # Stop PPP downloads, if running try: - if hasattr(self, "download_worker") and self.download_worker is not None and hasattr(self.download_worker, "stop"): - self.download_worker.stop() + self._cleanup_thread('download_thread', 'download_worker') except Exception: pass # Stop PEA execution, if running try: - if hasattr(self, "worker") and self.worker is not None and hasattr(self.worker, "stop"): - self.worker.stop() + self._cleanup_thread('pea_thread', 'pea_worker') except Exception: pass @@ -433,10 +593,28 @@ def on_stopAllClicked(self): # Restore UI state immediately try: - self._set_processing_state(False) + if self.is_processing: + self._set_processing_state(False) except Exception: pass + def closeEvent(self, event): + """ + Handle window close event - makes sure that all threads are terminated cleanly + """ + # Stop all running threads + self.on_stopAllClicked() + + # Also stop InputController threads + if hasattr(self, 'inputCtrl'): + self.inputCtrl.stop_all() + + # Give threads a moment to clean up + QCoreApplication.processEvents() + + # Accept the close event + event.accept() + def _fix_macos_tab_styling(self): """ Fix tab widget styling on macOS where native styling overrides custom stylesheets. @@ -453,7 +631,8 @@ def _fix_macos_tab_styling(self): # Force Fusion style on the tab widget to disable native macOS rendering fusion_style = QStyleFactory.create("Fusion") if fusion_style: - self.ui.tabWidget.setStyle(fusion_style) + self.ui.logTabWidget.setStyle(fusion_style) + self.ui.configTabWidget.setStyle(fusion_style) # Apply comprehensive stylesheet to ensure consistent appearance tab_bar_stylesheet = """ @@ -494,4 +673,5 @@ def _fix_macos_tab_styling(self): """ # Apply the stylesheet to the tab widget - self.ui.tabWidget.setStyleSheet(tab_bar_stylesheet) \ No newline at end of file + self.ui.logTabWidget.setStyleSheet(tab_bar_stylesheet) + self.ui.configTabWidget.setStyleSheet(tab_bar_stylesheet) \ No newline at end of file diff --git a/scripts/GinanUI/app/models/archive_manager.py b/scripts/GinanUI/app/models/archive_manager.py index 3fe4e6893..1361a2662 100644 --- a/scripts/GinanUI/app/models/archive_manager.py +++ b/scripts/GinanUI/app/models/archive_manager.py @@ -2,6 +2,7 @@ from pathlib import Path import shutil +import os from datetime import datetime from typing import Optional, Dict, Any @@ -14,30 +15,64 @@ def archive_old_outputs(output_dir: Path, visual_dir: Path = None): """ Moves existing output files to an archive directory to keep the workspace clean. - THIS FUNCTION LOOKS FOR ALL TXT, LOG, JSON, POS files. + THIS FUNCTION LOOKS FOR ALL TXT, LOG, JSON, POS, GPX, TRACE files. DON'T USE THE INPUT PRODUCTS DIRECTORY. :param output_dir: Path to the user-selected output directory. :param visual_dir: Optional path to associated visualisation directory. """ + # Move visual folder contents if it exists (visual_dir is typically output_dir / "visual") + if visual_dir is None: + visual_dir = output_dir / "visual" + + # First, collect all files that would be archived (only .POS, .GPX, .TRACE) + files_to_archive = [] + for ext in [".pos", ".POS", ".gpx", ".GPX", ".trace", ".TRACE"]: + files_to_archive.extend(list(output_dir.glob(f"*{ext}"))) + + # Check visual directory for files + visual_files_to_archive = [] + if visual_dir.exists() and visual_dir.is_dir(): + visual_files_to_archive = [f for f in visual_dir.glob("*") if f.is_file()] + + # Only proceed if there's something to archive + if not files_to_archive and not visual_files_to_archive: + Logger.console("📂 No previous outputs found to archive.") + return + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") archive_dir = output_dir / "archive" / timestamp - archive_dir.mkdir(parents=True, exist_ok=True) - # Move .pos, .log, .txt, etc. from output_dir + # Make the visual directory + os.makedirs(archive_dir, exist_ok=True) + + # Move .pos, .gpx, .trace from output_dir moved_files = 0 - for ext in [".pos", ".POS", ".log", ".txt", ".json"]: - for file in output_dir.glob(f"*{ext}"): - shutil.move(str(file), archive_dir / file.name) + for file in files_to_archive: + try: + shutil.move(str(file), str(archive_dir / file.name)) moved_files += 1 + except Exception as e: + Logger.console(f"Failed to archive {file.name}: {e}") - # Move HTML visual files (optional) - if visual_dir and visual_dir.exists(): + # Move visual folder contents + if visual_files_to_archive: visual_archive = archive_dir / "visual" - visual_archive.mkdir(parents=True, exist_ok=True) - for html_file in visual_dir.glob("*.html"): - shutil.move(str(html_file), visual_archive / html_file.name) - moved_files += 1 + + # Make the visual archive directory + os.makedirs(visual_archive, exist_ok=True) + + for visual_file in visual_files_to_archive: + try: + shutil.move(str(visual_file), str(visual_archive / visual_file.name)) + moved_files += 1 + except Exception as e: + Logger.console(f"Failed to archive {visual_file.name}: {e}") + # Remove the now-empty visual archive directory + try: + visual_dir.rmdir() + except OSError: + pass # Directory not empty or other issue if moved_files > 0: Logger.console(f"📦 Archived {moved_files} old output file(s) to: {archive_dir}") @@ -48,7 +83,7 @@ def archive_products(products_dir: Path = INPUT_PRODUCTS_PATH, reason: str = "ma include_patterns: Optional[list[str]] = None) -> Optional[Path]: """ Archive GNSS product files from products_dir into a timestamped subfolder - under products_dir/archived/. + under products_dir/archive/. :param products_dir: Directory containing GNSS product files :param reason: String describing why the archive is happening (e.g., "rinex_change", "ppp_selection_change") @@ -60,22 +95,19 @@ def archive_products(products_dir: Path = INPUT_PRODUCTS_PATH, reason: str = "ma Logger.console(f"Products dir {products_dir} does not exist.") return None - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - archive_dir = products_dir / "archived" / f"{reason}_{timestamp}" - archive_dir.mkdir(parents=True, exist_ok=True) - product_patterns = [ "*.SP3", # precise orbit "*.CLK", # clock files "*.BIA", # biases "*.ION", # ionosphere products (if used) "*.TRO", # troposphere products (if used) + "BRDC*.rnx", # broadcast ephemeris + "BRDC*.rnx.*", # compressed broadcast ephemeris ] if startup_archival: startup_patterns = [ "finals.data.iau2000.txt", - "BRDC*.rnx", "igs_satellite_metadata*.snx", "igs20*.atx", "tables/ALOAD*", @@ -107,20 +139,40 @@ def archive_products(products_dir: Path = INPUT_PRODUCTS_PATH, reason: str = "ma if include_patterns: product_patterns.extend(include_patterns) - archived_files = [] + # First, collect all files that match the patterns + files_to_archive = [] for pattern in product_patterns: - for file in products_dir.glob(pattern): - try: - target = archive_dir / file.name - shutil.move(str(file), str(target)) - archived_files.append(file.name) - except Exception as e: - Logger.console(f"Failed to archive {file.name}: {e}") + files_to_archive.extend(list(products_dir.glob(pattern))) + + # Only proceed if there's something to archive + if not files_to_archive: + Logger.console("No matching product files found to archive.") + return None + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + archive_dir = products_dir / "archive" / f"{reason}_{timestamp}" + + # Create the archive directory + os.makedirs(archive_dir, exist_ok=True) + + archived_files = [] + for file in files_to_archive: + try: + target = archive_dir / file.name + shutil.move(str(file), str(target)) + archived_files.append(file.name) + except Exception as e: + Logger.console(f"Failed to archive {file.name}: {e}") if archived_files: Logger.console(f"Archived {', '.join(archived_files)} → {archive_dir}") return archive_dir else: + # Clean up empty archive directory if all moves failed + try: + archive_dir.rmdir() + except OSError: + pass Logger.console("No matching product files found to archive.") return None @@ -132,12 +184,12 @@ def archive_products_if_rinex_changed(current_rinex: Path, If the RINEX file has changed since last load, archive the cached products. """ if last_rinex and current_rinex.resolve() == last_rinex.resolve(): - Logger.console("RINEX file unchanged — skipping product cleanup.") + Logger.console("RINEX file unchanged, skipping product cleanup.") return None - Logger.console("RINEX file changed — archiving old products.") + Logger.console("RINEX file changed, archiving old products.") # Shouldn't remove BRDC if date isn't changed but would require extracting current and last rnx - return archive_products(products_dir, reason="rinex_change", include_patterns=["BRDC*.rnx*"]) + return archive_products(products_dir, reason="rinex_change") def archive_products_if_selection_changed(current_selection: Dict[str, Any], @@ -148,7 +200,7 @@ def archive_products_if_selection_changed(current_selection: Dict[str, Any], Excludes BRDC and finals.data.iau2000.txt since they are reusable. """ if last_selection and current_selection == last_selection: - Logger.console("[Archiver] PPP product selection unchanged — skipping product cleanup.") + Logger.console("[Archiver] PPP product selection unchanged, skipping product cleanup.") return None if last_selection: diff --git a/scripts/GinanUI/app/models/dl_products.py b/scripts/GinanUI/app/models/dl_products.py index fa726cf87..8644e4ba0 100644 --- a/scripts/GinanUI/app/models/dl_products.py +++ b/scripts/GinanUI/app/models/dl_products.py @@ -17,6 +17,20 @@ CHUNK_SIZE = 8192 # 8 KiB COMPRESSED_FILETYPE = (".gz", ".gzip", ".Z") # ignore any others (maybe add crx2rnx using hatanaka package) +# repro3 fallback constants +REPRO3_PROJECT = "R03" # Project code for reproduction products +REPRO3_4TH_CHAR_RANGE = range(10) # 4th character can be 0-9, prioritise lower numbers +REPRO3_PRIORITY_GPS_WEEK_START = 730 # Start of GPS week range where repro3 products are better quality +REPRO3_PRIORITY_GPS_WEEK_END = 2138 # End of GPS week range where repro3 products are better quality + +CONSTELLATION_MAP = { + 'G': 'GPS', + 'R': 'GLO', + 'E': 'GAL', + 'C': 'BDS', + 'J': 'QZS', +} + METADATA = [ "https://files.igs.org/pub/station/general/igs_satellite_metadata.snx", "https://files.igs.org/pub/station/general/igs20.atx", @@ -41,6 +55,140 @@ def date_to_gpswk(date: datetime) -> int: def gpswk_to_date(gps_week: int, gps_day: int = 0) -> datetime: return GPSDate(GPS_ORIGIN + np.timedelta64(gps_week, "W") + np.timedelta64(gps_day, "D")).as_datetime +def check_repro3_exists(gps_week: int, session: requests.Session = None) -> bool: + """ + Check if the /repro3/ directory exists for a given GPS week. + + :param gps_week: GPS week number + :param session: Authenticated requests session for CDDIS access + :returns: True if /repro3/ directory exists, False otherwise + """ + url = f"https://cddis.nasa.gov/archive/gnss/products/{gps_week}/repro3/" + try: + if session is None: + # Create authenticated session if not provided + session = requests.Session() + session.auth = get_netrc_auth() + resp = session.get(url, timeout=10, allow_redirects=True) + # CDDIS returns 200 for valid directories, check for content indicating it's a directory listing + return resp.status_code == 200 + except requests.RequestException: + return False + +def _is_in_repro3_priority_range(start_time: datetime, end_time: datetime) -> bool: + """ + Check if the time range falls within the GPS week range where repro3 products + Prioritise if so (GPSWeeks 730 - 2138 inclusive). + + :param start_time: the start of the time window + :param end_time: the end of the time window + :returns: True if ALL GPS weeks in the range are within 730-2138 inclusive + """ + start_week = date_to_gpswk(start_time) + end_week = date_to_gpswk(end_time) + + # Check if entire range is within the priority range + return (REPRO3_PRIORITY_GPS_WEEK_START <= start_week <= REPRO3_PRIORITY_GPS_WEEK_END and + REPRO3_PRIORITY_GPS_WEEK_START <= end_week <= REPRO3_PRIORITY_GPS_WEEK_END) + + +def get_repro3_product_dataframe(start_time: datetime, end_time: datetime, target_files: List[str] = None) -> pd.DataFrame: + """ + Retrieves a DataFrame of available products from the /repro3/ directory for given time window. + This is used as a fallback when no valid PPP providers are found in the main directory. + + Note: The 4th character in /repro3/ filenames can be 0-9 (not just 0). + We prioritise lower numbers (0, then 1, then 2, etc.) for each provider + as they are provider-defined and completely arbitrary + + :param start_time: the start of the time window (start_epoch) + :param end_time: the start of the time window (end_epoch) + :param target_files: list of target files to filter for, defaulted to ["CLK","BIA","SP3"] + :returns: dataframe of products, columns: "analysis_center", "project", "date", "solution_type", "period", + "resolution", "content", "format" + """ + if target_files is None: + target_files = ["CLK", "BIA", "SP3"] + else: + target_files = [file.upper() for file in target_files] + + products = pd.DataFrame( + columns=["analysis_center", "project", "date", "solution_type", "period", "resolution", "content", "format", "_4th_char"]) + + gps_weeks = range(date_to_gpswk(start_time), date_to_gpswk(end_time) + 1) + + # Create authenticated session for CDDIS access + session = requests.Session() + session.auth = get_netrc_auth() + + for gps_week in gps_weeks: + # Check if repro3 directory exists for this week + if not check_repro3_exists(gps_week, session): + Logger.console(f"/repro3/ directory does not exist for GPS week {gps_week}, skipping") + continue + + url = f"https://cddis.nasa.gov/archive/gnss/products/{gps_week}/repro3/" + try: + week_files = session.get(url, timeout=10) + week_files.raise_for_status() + except requests.RequestException as e: + Logger.console(f"Failed to fetch /repro3/ files for GPS week {gps_week}: {e}") + continue + + soup = BeautifulSoup(week_files.content, "html.parser", parse_only=SoupStrainer("div", class_="archiveItemTextContainer")) + + for div in soup: + filename = div.get_text().split(" ")[0] + try: + # repro3 uses the modern format (post week 2237 style) + # e.g. COD0R03FIN_20232620000_01D_01D_OSB.BIA.gz + # AAA#PPPSNX_YYYYDDDHHMM_LEN_SMP_CNT.FMT.gz + # where # is the 4th character (can be 0-9) + center = filename[0:3] # e.g. "COD" + fourth_char = filename[3] # e.g. "0", "1", "2", etc. + project = filename[4:7] # e.g. "R03" + _type = filename[7:10] # e.g. "FIN" + year = int(filename[11:15]) # e.g. "2023" + day_of_year = int(filename[15:18]) # e.g. "262" + hour = int(filename[18:20]) # e.g. "00" + minute = int(filename[20:22]) # e.g. "00" + intended_period = filename[23:26] # eg "01D" + sampling_resolution = filename[27:30] # eg "01D" + content = filename[31:34] # e.g. "OSB" + _format = filename[35:38] # e.g. "BIA" + + date = datetime(year, 1, 1, hour, minute) + timedelta(day_of_year - 1) + period = timedelta(days=int(intended_period[:-1])) # Assuming all periods are in days + + # Check if product's coverage period overlaps with the requested time window + product_end = date + period + if _format in target_files and date < end_time and product_end > start_time: + products.loc[len(products)] = { + "analysis_center": center, + "project": project, + "date": date, + "solution_type": _type, + "period": period, + "resolution": sampling_resolution, + "content": content, + "format": _format, + "_4th_char": fourth_char + } + except (ValueError, IndexError): + # Skips md5 sums and other non-conforming files + continue + + if products.empty: + return products + + # Prioritise lower values 4th character values for each (center, project, date, solution_type, format) combination + products = products.sort_values(by=["analysis_center", "project", "date", "solution_type", "format", "_4th_char"]) + products = products.drop_duplicates(subset=["analysis_center", "project", "date", "solution_type", "format"], keep="first") + products = products.drop(columns=["_4th_char"]) + products = products.reset_index(drop=True) + + return products + def str_to_datetime(date_time_str): """ @@ -71,7 +219,7 @@ def get_product_dataframe(start_time: datetime, end_time: datetime, target_files target_files = [file.upper() for file in target_files] products = pd.DataFrame( - columns=["analysis_center", "project", "date", "solution_type", "period", "resolution", "content", "format"]) + columns=["analysis_center", "project", "date", "solution_type", "period", "resolution", "content", "format", "_4th_char"]) # 1. Retrieve available options gps_weeks = range(date_to_gpswk(start_time), date_to_gpswk(end_time) + 1) @@ -100,6 +248,7 @@ def get_product_dataframe(start_time: datetime, end_time: datetime, target_files project = "OPS" sampling_resolution = None content = None + fourth_char = None date = gpswk_to_date(gps_week) if 0 < day < 7: date += timedelta(days=day) @@ -109,8 +258,10 @@ def get_product_dataframe(start_time: datetime, end_time: datetime, target_files else: # e.g. GRG0OPSFIN_20232620000_01D_01D_SOL.SNX.gz - # AAA0OPSSNX_YYYYDDDHHMM_LEN_SMP_CNT.FMT.gz + # AAA#PPPSNX_YYYYDDDHHMM_LEN_SMP_CNT.FMT.gz + # where # is the 4th character (can be 0-9) center = filename[0:3] # e.g. "COD" + fourth_char = filename[3] # e.g. "0", "1", "2", etc. project = filename[4:7] # e.g. "OPS" or "RNN" unused _type = filename[7:10] # e.g. "FIN" year = int(filename[11:15]) # e.g. "2023" @@ -125,7 +276,9 @@ def get_product_dataframe(start_time: datetime, end_time: datetime, target_files date = datetime(year, 1, 1, hour, minute) + timedelta(day_of_year - 1) period = timedelta(days=int(intended_period[:-1])) # Assuming all periods are in days :shrug: - if _format in target_files and start_time <= date <= end_time: + # Check if product's coverage period overlaps with the requested time window + product_end = date + period + if _format in target_files and date < end_time and product_end > start_time: products.loc[len(products)] = { "analysis_center": center, "project": project, @@ -134,12 +287,22 @@ def get_product_dataframe(start_time: datetime, end_time: datetime, target_files "period": period, "resolution": sampling_resolution, "content": content, - "format": _format + "format": _format, + "_4th_char": fourth_char } except (ValueError, IndexError): # Skips md5 sums and other non-conforming files continue - products = products.drop_duplicates(inplace=False) # resets indexes too + + if products.empty: + return products + + # Prioritise lower 4th character values for each (center, project, date, solution_type, format) combination + # This is consistent with the repro3 logic where lower numbers are preferred + products = products.sort_values(by=["analysis_center", "project", "date", "solution_type", "format", "_4th_char"]) + products = products.drop_duplicates(subset=["analysis_center", "project", "date", "solution_type", "format"], keep="first") + products = products.reset_index(drop=True) + return products @@ -257,6 +420,186 @@ def get_valid_providers_with_series(data: pd.DataFrame) -> dict: return provider_series_map +def get_product_dataframe_with_repro3_fallback(start_time: datetime, end_time: datetime, target_files: List[str] = None) -> pd.DataFrame: + """ + Retrieves a DataFrame of available products, with intelligent handling of repro3 directory + based on GPS week range: + - GPS week < 730: Use main directory first, fallback to repro3 if no valid providers + - 730 <= GPS week <= 2138: Prioritise repro3 (better quality), fallback to main if repro3 unavailable + - GPS week > 2138: Use main directory first, fallback to repro3 if no valid providers + + :param start_time: the start of the time window (start_epoch) + :param end_time: the start of the time window (end_epoch) + :param target_files: list of target files to filter for, defaulted to ["CLK","BIA","SP3"] + :returns: dataframe of products (from main directory or repro3 depending on GPS week range) + """ + # Check if time range falls within the repro3 priority range + if _is_in_repro3_priority_range(start_time, end_time): + return _try_repro3_first(start_time, end_time, target_files) + else: + # Outside priority range: use main directory first, fallback to repro3 if needed + return _try_main_first(start_time, end_time, target_files) + + +def _try_main_first(start_time: datetime, end_time: datetime, target_files: List[str] = None) -> pd.DataFrame: + """ + Try main directory first, fallback to repro3 if no valid PPP providers found. + Used for GPS weeks outside the repro3 priority range. + + :param start_time: the start of the time window + :param end_time: the end of the time window + :param target_files: list of target files to filter for + :returns: dataframe of products + """ + # First try the main directory + products = get_product_dataframe(start_time, end_time, target_files) + + if products.empty: + Logger.terminal("📦 No products found in main directory, checking /repro3/...") + return _try_repro3_fallback(start_time, end_time, target_files) + + # Check if we have valid PPP providers + valid_centers = get_valid_analysis_centers(products) + + if valid_centers: + # Valid providers found in main directory, no fallback needed + return products + + # No valid PPP providers found, try repro3 fallback + Logger.terminal("📦 No valid PPP providers in main directory, checking /repro3/...") + return _try_repro3_fallback(start_time, end_time, target_files, main_products=products) + + +def _try_repro3_first(start_time: datetime, end_time: datetime, target_files: List[str] = None) -> pd.DataFrame: + """ + Try /repro3/ directory first + fallback to main directory if repro3 is unavailable or has no valid providers. + + :param start_time: the start of the time window + :param end_time: the end of the time window + :param target_files: list of target files to filter for + :returns: dataframe of products + """ + # Create authenticated session for CDDIS access + session = requests.Session() + session.auth = get_netrc_auth() + + # Check if repro3 exists for any of the GPS weeks in the range + gps_weeks = range(date_to_gpswk(start_time), date_to_gpswk(end_time) + 1) + repro3_exists_for_any_week = False + + for gps_week in gps_weeks: + if check_repro3_exists(gps_week, session): + repro3_exists_for_any_week = True + break + + if not repro3_exists_for_any_week: + Logger.terminal("📦 /repro3/ directory does not exist, falling back to main directory...") + return _try_main_directory_fallback(start_time, end_time, target_files) + + # Fetch products from repro3 + repro3_products = get_repro3_product_dataframe(start_time, end_time, target_files) + + if repro3_products.empty: + Logger.terminal("📦 No products found in /repro3/ directory, falling back to main directory...") + return _try_main_directory_fallback(start_time, end_time, target_files) + + # Check if repro3 has valid PPP providers + repro3_valid_centers = get_valid_analysis_centers(repro3_products) + + if repro3_valid_centers: + return repro3_products + + # No valid providers in repro3, try main directory as fallback + Logger.terminal("📦 No valid PPP providers in /repro3/, falling back to main directory...") + return _try_main_directory_fallback(start_time, end_time, target_files, repro3_products=repro3_products) + + +def _try_main_directory_fallback(start_time: datetime, end_time: datetime, target_files: List[str] = None, repro3_products: pd.DataFrame = None) -> pd.DataFrame: + """ + Internal helper to attempt fetching products from main directory as a fallback + when /repro3/ is prioritised but unavailable. + + :param start_time: the start of the time window + :param end_time: the end of the time window + :param target_files: list of target files to filter for + :param repro3_products: optional existing products from repro3 to return if main fails + :returns: main products if valid providers found, otherwise repro3_products or empty DataFrame + """ + products = get_product_dataframe(start_time, end_time, target_files) + + if products.empty: + if repro3_products is not None and not repro3_products.empty: + Logger.terminal("⚠️ No valid PPP providers found") + return repro3_products + Logger.terminal("⚠️ No valid PPP providers found") + return pd.DataFrame() + + # Check if main directory has valid PPP providers + valid_centers = get_valid_analysis_centers(products) + + if valid_centers: + Logger.terminal(f"✅ Found valid PPP providers in main directory: {', '.join(sorted(valid_centers))}") + return products + + # No valid providers in main either + Logger.terminal("⚠️ No valid PPP providers found") + if repro3_products is not None and not repro3_products.empty: + return repro3_products + return products + + +def _try_repro3_fallback(start_time: datetime, end_time: datetime, target_files: List[str] = None, main_products: pd.DataFrame = None) -> pd.DataFrame: + """ + Internal helper to attempt fetching products from repro3 directory. + + :param start_time: the start of the time window + :param end_time: the end of the time window + :param target_files: list of target files to filter for + :param main_products: optional existing products from main directory to return if repro3 fails + :returns: repro3 products if valid providers found, otherwise main_products or empty DataFrame + """ + # Create authenticated session for CDDIS access + session = requests.Session() + session.auth = get_netrc_auth() + + # Check if repro3 exists for any of the GPS weeks in the range + gps_weeks = range(date_to_gpswk(start_time), date_to_gpswk(end_time) + 1) + repro3_exists_for_any_week = False + + for gps_week in gps_weeks: + if check_repro3_exists(gps_week, session): + repro3_exists_for_any_week = True + break + + if not repro3_exists_for_any_week: + Logger.terminal("📦 repro3 directory does not exist for this time range") + if main_products is not None and not main_products.empty: + Logger.terminal("⚠️ No valid PPP providers found") + return main_products + return pd.DataFrame() + + # Fetch products from repro3 + repro3_products = get_repro3_product_dataframe(start_time, end_time, target_files) + + if repro3_products.empty: + Logger.terminal("📦 No products found in repro3 directory") + if main_products is not None and not main_products.empty: + Logger.terminal("⚠️ No valid PPP providers found") + return main_products + return pd.DataFrame() + + # Check if repro3 has valid PPP providers + repro3_valid_centers = get_valid_analysis_centers(repro3_products) + + if repro3_valid_centers: + return repro3_products + else: + Logger.terminal("⚠️ No valid PPP providers found") + if main_products is not None and not main_products.empty: + return main_products + return repro3_products + def extract_file(filepath: Path) -> Path: """ Extracts [".gz", ".gzip", ".Z"] files with gzip and unlzw3 respectively. @@ -350,7 +693,7 @@ def download_file(url: str, session: requests.Session, download_dir: Path = INPU downloaded = _partial.stat().st_size for _chunk in resp.iter_content(chunk_size=CHUNK_SIZE): if stop_requested and stop_requested(): - raise RuntimeError("Stop requested during download.") + raise RuntimeError("Stop requested during download") if _chunk: # Filters keep-alives partial_out.write(_chunk) @@ -424,11 +767,24 @@ def download_products(products: pd.DataFrame, download_dir: Path = INPUT_PRODUCT :raises Exception: Max retries reached """ + # Create authenticated session for CDDIS access early so it can be used for URL generation + _sesh = requests.Session() + _sesh.auth = get_netrc_auth() + # 1. Generate filenames from the DataFrame downloads = [] for _, row in products.iterrows(): gps_week = date_to_gpswk(row.date) - if gps_week < 2237: + # Check if this is a repro3 product (R03 project) FIRST + # repro3 always uses the modern naming convention regardless of GPS week + is_repro3 = row.project == REPRO3_PROJECT + + if is_repro3: + # For repro3, always use modern naming convention and find the correct 4th character + # Try each character 0 - 9, prioritising lower numbers + filename, url = _get_repro3_filename_and_url(row, gps_week, _sesh) + elif gps_week < 2237: + # Old naming convention for non-repro3 products before week 2237 # AAAWWWWD.TYP.Z # e.g. COD22360.FIN.SNX.gz if row.period == timedelta(days=7): @@ -436,12 +792,16 @@ def download_products(products: pd.DataFrame, download_dir: Path = INPUT_PRODUCT else: day = int((row.date - gpswk_to_date(gps_week)).days) filename = f"{row.analysis_center.lower()}{gps_week}{day}.{row.format.lower()}.Z" + url = f"{BASE_URL}/gnss/products/{gps_week}/{filename}" else: + # Modern naming convention for non-repro3 products from week 2237 onwards # e.g. GRG0OPSFIN_20232620000_01D_01D_SOL.SNX.gz - # AAA0OPSSNX_YYYYDDDHHMM_LEN_SMP_CNT.FMT.gz - filename = f"{row.analysis_center}0{row.project}{row.solution_type}_{row.date.strftime('%Y%j%H%M')}_{row.period.days:02d}D_{row.resolution}_{row.content}.{row.format}.gz" + # AAA#PPPSNX_YYYYDDDHHMM_LEN_SMP_CNT.FMT.gz + # where # is the 4th character (can be 0-9, stored in _4th_char column) + fourth_char = row._4th_char if hasattr(row, '_4th_char') and row._4th_char is not None else "0" + filename = f"{row.analysis_center}{fourth_char}{row.project}{row.solution_type}_{row.date.strftime('%Y%j%H%M')}_{row.period.days:02d}D_{row.resolution}_{row.content}.{row.format}.gz" + url = f"{BASE_URL}/gnss/products/{gps_week}/{filename}" - url = f"{BASE_URL}/gnss/products/{gps_week}/{filename}" downloads.append(url) if dl_urls: @@ -450,8 +810,6 @@ def download_products(products: pd.DataFrame, download_dir: Path = INPUT_PRODUCT Logger.terminal(f"📦 {len(downloads)} files to check or download") download_dir.mkdir(parents=True, exist_ok=True) (download_dir / "tables").mkdir(parents=True, exist_ok=True) - _sesh = requests.Session() - _sesh.auth = get_netrc_auth() for url in downloads: _x = url.split("/") if len(_x) < 2: @@ -460,6 +818,762 @@ def download_products(products: pd.DataFrame, download_dir: Path = INPUT_PRODUCT fin_dir = download_dir / "tables" if _x[-2] == "tables" else download_dir yield download_file(url, _sesh, fin_dir, progress_callback, stop_requested) +def _get_repro3_filename_and_url(row: pd.Series, gps_week: int, session: requests.Session = None) -> tuple: + """ + Determine the correct filename and URL for a repro3 product. + Tries 4th characters 0-9 and returns the first one that exists. + + :param row: DataFrame row with product info + :param gps_week: GPS week number + :param session: Optional authenticated requests session for CDDIS access + :returns: tuple of (filename, url) + """ + if session is None: + session = requests.Session() + session.auth = get_netrc_auth() + + for fourth_char in REPRO3_4TH_CHAR_RANGE: + filename = f"{row.analysis_center}{fourth_char}{row.project}{row.solution_type}_{row.date.strftime('%Y%j%H%M')}_{row.period.days:02d}D_{row.resolution}_{row.content}.{row.format}.gz" + url = f"{BASE_URL}/gnss/products/{gps_week}/repro3/{filename}" + + # Check if this file exists using authenticated session + try: + resp = session.head(url, timeout=5, allow_redirects=True) + if resp.status_code == 200: + return filename, url + except requests.RequestException: + continue + + # Fallback: return with 4th char = 0 (will likely fail download, but that's expected behaviour) + filename = f"{row.analysis_center}0{row.project}{row.solution_type}_{row.date.strftime('%Y%j%H%M')}_{row.period.days:02d}D_{row.resolution}_{row.content}.{row.format}.gz" + url = f"{BASE_URL}/gnss/products/{gps_week}/repro3/{filename}" + return filename, url + + +#region SP3 Product Validation + +# Size of partial download for SP3 header (2KB should be enough) +SP3_HEADER_BYTES = 2048 + +def parse_sp3_header_constellations(header_content: str) -> set: + """ + Parse SP3 file header content and extract constellation prefixes. + + The SP3 header contains satellite list lines starting with '+' that look like: + + 122 G01G02G03G04G05G06G07G08G09G10G11G12G13G14G15G16G17 + + G18G19G20G21G22G23G24...R01R02...E02E03...C06C07...J02J03 + + :param header_content: String content of the SP3 header + :returns: Set of constellation codes found (e.g., {'GPS', 'GLO', 'GAL', 'BDS', 'QZS'}) + """ + constellations = set() + + for line in header_content.split('\n'): + # Look for satellite list lines (start with '+') + # Skip the first '+' line which contains the satellite count + if line.startswith('+') and len(line) > 10: + # Extract satellite IDs from the line (format: G01, R02, E03, etc.) + # The satellite data starts after the initial '+' and whitespace/numbers + content = line[1:].strip() + + # Skip if this is the count line (contains only numbers and spaces at start) + if content and content.split()[0].isdigit(): + # This line has count info, but satellite IDs follow + # Find where satellite IDs start (first letter) + for i, char in enumerate(content): + if char.isalpha(): + content = content[i:] + break + else: + continue + + # Parse satellite IDs (3 characters each: letter + 2 digits) + # They are packed together without separators: "G01G02G03R01R02E01..." + i = 0 + while i < len(content): + if content[i].isalpha(): + sat_prefix = content[i] + if sat_prefix in CONSTELLATION_MAP: + constellations.add(CONSTELLATION_MAP[sat_prefix]) + # Move to next potential satellite ID (3 chars) + i += 3 + elif content[i] == '0' and i + 1 < len(content) and content[i:i + 2] == ' ': + break + else: + i += 1 + + return constellations + +def download_sp3_header(url: str, session: requests.Session, max_retries: int = 3) -> Optional[str]: + """ + Download only the first portion of an SP3 file (compressed) to get the header. + Uses HTTP Range request to download partial content. + + :param url: URL of the SP3 file (may be .gz compressed) + :param session: Authenticated requests session + :param max_retries: Maximum number of retry attempts (default 3) + :returns: Decompressed header content as string, or None if download fails + """ + import time + + for attempt in range(max_retries): + try: + # Request only the first N bytes using Range header + headers = {"Range": f"bytes=0-{SP3_HEADER_BYTES - 1}"} + resp = session.get(url, headers=headers, timeout=15, stream=True) + + # Accept both 200 (full content) and 206 (partial content) + if resp.status_code not in (200, 206): + if attempt < max_retries - 1: + Logger.console(f"SP3 download attempt {attempt + 1} failed (status {resp.status_code}), retrying...") + time.sleep(1 * (attempt + 1)) # Exponential backoff + continue + return None + + content = resp.content + + # Decompress if gzip compressed + if url.endswith('.gz') or url.endswith('.gzip'): + try: + # For partial gzip, we may get truncation errors - that's OK + # We just need enough to parse the header + import zlib + try: + # Try raw deflate with gzip header handling + decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS) + decompressed = decompressor.decompress(content) + except zlib.error: + # Fallback: try standard gzip decompress + try: + decompressed = gzip.decompress(content) + except (gzip.BadGzipFile, OSError): + if attempt < max_retries - 1: + time.sleep(1 * (attempt + 1)) + continue + return None + content = decompressed + except Exception: + if attempt < max_retries - 1: + time.sleep(1 * (attempt + 1)) + continue + return None + elif url.endswith('.Z'): + try: + decompressed = unlzw3.unlzw(content) + content = decompressed + except Exception: + if attempt < max_retries - 1: + time.sleep(1 * (attempt + 1)) + continue + return None + + # Decode to string + return content.decode('utf-8', errors='ignore') + + except requests.RequestException as e: + if attempt < max_retries - 1: + Logger.console(f"SP3 download attempt {attempt + 1} failed ({e}), retrying...") + time.sleep(1 * (attempt + 1)) + continue + Logger.console(f"Failed to download SP3 header from {url} after {max_retries} attempts: {e}") + return None + except Exception as e: + if attempt < max_retries - 1: + time.sleep(1 * (attempt + 1)) + continue + Logger.console(f"Error processing SP3 header from {url}: {e}") + return None + + return None + +def get_sp3_url_for_product(row: pd.Series, session: requests.Session = None) -> Optional[str]: + """ + Get the URL for an SP3 file for a specific product row. + + :param row: DataFrame row with product info (must have format == 'SP3') + :param session: Optional authenticated session for CDDIS + :returns: URL string or None if not found + """ + gps_week = date_to_gpswk(row.date) + + # Check if this is a repro3 product + is_repro3 = row.project == REPRO3_PROJECT + + if session is None: + session = requests.Session() + session.auth = get_netrc_auth() + + if is_repro3: + # Use repro3 naming and URL + filename, url = _get_repro3_filename_and_url(row, gps_week, session) + elif gps_week < 2237: + # Old naming convention + if row.period == timedelta(days=7): + day = 7 + else: + day = int((row.date - gpswk_to_date(gps_week)).days) + filename = f"{row.analysis_center.lower()}{gps_week}{day}.{row.format.lower()}.Z" + url = f"{BASE_URL}/gnss/products/{gps_week}/{filename}" + else: + # Modern naming convention + fourth_char = row._4th_char if hasattr(row, '_4th_char') and row._4th_char is not None else "0" + filename = f"{row.analysis_center}{fourth_char}{row.project}{row.solution_type}_{row.date.strftime('%Y%j%H%M')}_{row.period.days:02d}D_{row.resolution}_{row.content}.{row.format}.gz" + url = f"{BASE_URL}/gnss/products/{gps_week}/{filename}" + + return url + +def get_provider_constellations(products_df: pd.DataFrame, progress_callback: Optional[Callable] = None, stop_requested: Optional[Callable] = None) -> dict: + """ + Download partial SP3 headers for valid provider/series/project combinations + (those that have all required files: SP3, BIA, CLK) and extract constellation information. + + :param products_df: Products dataframe from get_product_dataframe_with_repro3_fallback() + :param progress_callback: Optional callback for progress updates (description, percent) + :param stop_requested: Optional callback to check if operation should stop + :returns: Nested dictionary mapping provider -> series -> project -> set of constellations + e.g., { + 'COD': { + 'FIN': {'OPS': {'GPS', 'GLO', 'GAL'}, 'MGX': {'GPS', 'GLO', 'GAL', 'BDS', 'QZS'}}, + 'RAP': {'OPS': {'GPS', 'GLO', 'GAL'}} + }, + 'GRG': { + 'FIN': {'OPS': {'GPS', 'GLO', 'GAL'}}, + 'RAP': {'OPS': {'GPS', 'GLO', 'GAL'}} + } + } + """ + provider_constellations = {} + REQUIRED_FILES = {"SP3", "BIA", "CLK"} + + # Get valid analysis centers first (those with all required files) + valid_centers = get_valid_analysis_centers(products_df) + + if not valid_centers: + Logger.console("No valid analysis centers found") + return provider_constellations + + # Build list of valid (provider, series, project) combinations + # These are the ones that have all three of the .SP3, .BIA, and .CLK files + valid_combinations = [] + + for provider in valid_centers: + provider_data = products_df[products_df['analysis_center'] == provider] + + # Get valid series for this provider (those with all required files) + for series in provider_data['solution_type'].unique(): + series_data = provider_data[provider_data['solution_type'] == series] + available_files = set(series_data['format'].unique()) + + if REQUIRED_FILES.issubset(available_files): + # This series has all required files, get the project(s) + for project in series_data['project'].unique(): + valid_combinations.append((provider, series, project)) + + if not valid_combinations: + Logger.console("No valid provider/series/project combinations found") + return provider_constellations + + total_combinations = len(valid_combinations) + Logger.console(f"📡 Fetching constellation info for {total_combinations} valid combinations...") + + # Create authenticated session + session = requests.Session() + session.auth = get_netrc_auth() + + for i, (provider, series, project) in enumerate(valid_combinations): + # Check for stop request + if stop_requested and stop_requested(): + Logger.console("Constellation fetch cancelled") + break + + # Progress callback + if progress_callback: + percent = int((i + 1) / total_combinations * 100) + progress_callback(f"Fetching {provider}/{series}/{project} SP3", percent) + + # Find the SP3 product row for this combination + sp3_row = products_df[ + (products_df['analysis_center'] == provider) & + (products_df['project'] == project) & + (products_df['solution_type'] == series) & + (products_df['format'] == 'SP3') + ] + + if sp3_row.empty: + Logger.console(f" {provider}/{series}/{project}: No SP3 product found") + continue + + # Get SP3 URL for this combination + url = get_sp3_url_for_product(sp3_row.iloc[0], session) + + if not url: + Logger.console(f" {provider}/{series}/{project}: No SP3 URL found") + continue + + # Download partial header + header_content = download_sp3_header(url, session) + + if not header_content: + Logger.console(f" {provider}/{series}/{project}: Failed to download SP3 header") + continue + + # Parse constellations from header + constellations = parse_sp3_header_constellations(header_content) + + if constellations: + # Build nested dictionary structure: provider -> series -> project -> constellations + if provider not in provider_constellations: + provider_constellations[provider] = {} + if series not in provider_constellations[provider]: + provider_constellations[provider][series] = {} + provider_constellations[provider][series][project] = constellations + + Logger.console(f" {provider}/{series}/{project}: {', '.join(sorted(constellations))}") + else: + Logger.console(f" {provider}/{series}/{project}: No constellations found in header") + + return provider_constellations + +#endregion + +#region BIA Product Validation + +# Chunk size for BIA file downloads (100 KB) +BIA_CHUNK_SIZE = 102400 + +def get_bia_url_for_product(row: pd.Series, session: requests.Session = None) -> Optional[str]: + """ + Get the URL for a BIA file for a specific product row. + + :param row: DataFrame row with product info (must have format == 'BIA') + :param session: Optional authenticated session for CDDIS + :returns: URL string or None if not found + """ + gps_week = date_to_gpswk(row.date) + + # Check if this is a repro3 product + is_repro3 = row.project == REPRO3_PROJECT + + if session is None: + session = requests.Session() + session.auth = get_netrc_auth() + + if is_repro3: + # Use repro3 naming and URL + filename, url = _get_repro3_filename_and_url(row, gps_week, session) + elif gps_week < 2237: + # Old naming convention - BIA files may not exist in old format + # Try the modern format anyway as BIA is a newer product type + fourth_char = row._4th_char if hasattr(row, '_4th_char') and row._4th_char is not None else "0" + filename = f"{row.analysis_center}{fourth_char}{row.project}{row.solution_type}_{row.date.strftime('%Y%j%H%M')}_{row.period.days:02d}D_{row.resolution}_{row.content}.{row.format}.gz" + url = f"{BASE_URL}/gnss/products/{gps_week}/{filename}" + else: + # Modern naming convention + fourth_char = row._4th_char if hasattr(row, '_4th_char') and row._4th_char is not None else "0" + filename = f"{row.analysis_center}{fourth_char}{row.project}{row.solution_type}_{row.date.strftime('%Y%j%H%M')}_{row.period.days:02d}D_{row.resolution}_{row.content}.{row.format}.gz" + url = f"{BASE_URL}/gnss/products/{gps_week}/{filename}" + + return url + +def download_bia_satellite_section(url: str, session: requests.Session, progress_callback: Optional[Callable] = None, stop_requested: Optional[Callable] = None, max_retries: int = 3) -> Optional[str]: + """ + Download the satellite bias section of a BIA file in chunks. + Stops downloading when: + - A station marker is detected (4-char STATION field populated) + - The first PRN cycles back with a different time (full satellite cycle complete) + - The -BIAS/SOLUTION end marker is reached + - The file ends + + :param url: URL of the BIA file (may be .gz compressed) + :param session: Authenticated requests session + :param progress_callback: Optional callback for progress updates (description, percent) + :param stop_requested: Optional callback to check if operation should stop + :param max_retries: Maximum number of retry attempts for failed requests (default 3) + :returns: Decompressed satellite bias section as string, or None if download fails + """ + import zlib + import time + + for attempt in range(max_retries): + accumulated_content = b"" + accumulated_text = "" + chunk_num = 0 + max_chunks = 100 # Safety limit: 100 * 100KB = 1MB max + chunk_retry_count = 0 + max_chunk_retries = 3 + + # Set up decompressor for streaming gzip + is_gzip = url.endswith('.gz') or url.endswith('.gzip') + is_lzw = url.endswith('.Z') + + try: + while chunk_num < max_chunks: + # Check for stop request + if stop_requested and stop_requested(): + return None + + # Calculate byte range for this chunk + start_byte = chunk_num * BIA_CHUNK_SIZE + end_byte = start_byte + BIA_CHUNK_SIZE - 1 + + # Request chunk using Range header with retry logic + try: + headers = {"Range": f"bytes={start_byte}-{end_byte}"} + resp = session.get(url, headers=headers, timeout=30, stream=True) + except requests.RequestException as chunk_error: + chunk_retry_count += 1 + if chunk_retry_count < max_chunk_retries: + Logger.console(f"BIA chunk {chunk_num} download failed, retrying ({chunk_retry_count}/{max_chunk_retries})...") + time.sleep(1 * chunk_retry_count) + continue + raise chunk_error + + # Check response status + if resp.status_code == 416: + # Range not satisfiable - we've reached the end of the file + break + elif resp.status_code not in (200, 206): + if chunk_num == 0: + # First chunk failed, retry the whole download + if attempt < max_retries - 1: + Logger.console(f"BIA download attempt {attempt + 1} failed (status {resp.status_code}), retrying...") + time.sleep(1 * (attempt + 1)) + break # Break inner loop to retry from start + Logger.console(f"BIA download failed with status {resp.status_code}") + return None + break + + chunk_data = resp.content + if not chunk_data: + break + + accumulated_content += chunk_data + chunk_retry_count = 0 # Reset retry count on success + + # Decompress accumulated content + try: + if is_gzip: + # Try to decompress what we have so far + try: + temp_decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS) + decompressed = temp_decompressor.decompress(accumulated_content) + accumulated_text = decompressed.decode('utf-8', errors='ignore') + except zlib.error: + # Incomplete gzip stream, continue accumulating + chunk_num += 1 + continue + elif is_lzw: + try: + decompressed = unlzw3.unlzw(accumulated_content) + accumulated_text = decompressed.decode('utf-8', errors='ignore') + except Exception: + chunk_num += 1 + continue + else: + accumulated_text = accumulated_content.decode('utf-8', errors='ignore') + except Exception: + chunk_num += 1 + continue + + # Progress callback + if progress_callback: + progress_callback(f"Downloading BIA ({(chunk_num + 1) * BIA_CHUNK_SIZE // 1024} KB)", -1) + + # Check if we've found a termination condition (station marker, cycle complete, or end marker) + should_stop, satellite_section = _check_bia_termination(accumulated_text) + + if should_stop: + Logger.console(f"📥 BIA download finished after {(chunk_num + 1) * BIA_CHUNK_SIZE // 1024} KB") + return satellite_section + + chunk_num += 1 + + # If we have accumulated content, process it + if accumulated_text: + # If we exited the loop without finding termination, return what we have + Logger.console(f"📥 BIA download completed (no early termination), processing {len(accumulated_text)} chars") + _, satellite_section = _check_bia_termination(accumulated_text, force_return=True) + return satellite_section if satellite_section else accumulated_text + + # If we got here with no content on first chunk failure, continue to next attempt + if attempt < max_retries - 1: + continue + + except requests.RequestException as e: + if attempt < max_retries - 1: + Logger.console(f"BIA download attempt {attempt + 1} failed ({e}), retrying...") + time.sleep(1 * (attempt + 1)) + continue + Logger.console(f"Failed to download BIA file from {url} after {max_retries} attempts: {e}") + return None + except Exception as e: + if attempt < max_retries - 1: + time.sleep(1 * (attempt + 1)) + continue + Logger.console(f"Error processing BIA file from {url}: {e}") + return None + + return None + + +def _check_bia_termination(content: str, force_return: bool = False) -> tuple[bool, Optional[str]]: + """ + Check if we should stop downloading and extract the satellite bias section. + + Termination conditions: + 1. Station marker detected (4-char STATION field at columns 15-18) + 2. We see a (constellation, OBS) combination that we've already seen, but with a + DIFFERENT BIAS_START - this means we've collected all unique codes for that + constellation and are now seeing repeats + 3. -BIAS/SOLUTION end marker reached + + This handles: + - COD/GRG format: GPS → GAL → BDS → station markers (terminates on station) + - IGS format: GPS(time1) → GPS(time2) → ... → GLO(time2) → GLO(time3) → ... + (terminates when a constellation+OBS repeats with new time) + - TUG format: G01(time1,time2,time3) → G02(time1,time2) → ... + (terminates when constellation+OBS repeats with new time after seeing other constellations) + + BIA format column positions (0-indexed): + - Cols 0-3: BIAS type (e.g., " OSB") + - Cols 6-9: SVN (e.g., "G080") + - Cols 11-13: PRN (e.g., "G01") + - Cols 15-18: STATION (4 spaces for satellite biases, 4-char marker for station biases) + - Cols 25-27: OBS1 (e.g., "C1C") + - Cols 35-48: BIAS_START (e.g., "2025:180:00000") + + :param content: Accumulated BIA file content + :param force_return: If True, return whatever satellite section we have + :returns: Tuple of (should_stop, satellite_section_content) + """ + lines = content.split('\n') + satellite_lines = [] + in_bias_solution = False + found_termination = False + + # Track seen (constellation, OBS) -> first BIAS_START for that combination + # This allows us to detect when we've cycled through all unique codes + seen_constellation_obs = {} + + # Track which constellations we've seen to know when we have a complete set + seen_constellations = set() + + # Map PRN prefix to constellation + prn_to_constellation = {'G': 'GPS', 'R': 'GLO', 'E': 'GAL', 'C': 'BDS', 'J': 'QZS'} + + for line in lines: + # Check for BIAS/SOLUTION block start + if '+BIAS/SOLUTION' in line: + in_bias_solution = True + continue + + # Check for BIAS/SOLUTION block end + if '-BIAS/SOLUTION' in line: + found_termination = True + break + + # Skip if not in BIAS/SOLUTION block + if not in_bias_solution: + continue + + # Skip comment/header lines + if line.startswith('*') or not line.strip(): + continue + + # Check if this is a bias data line + if len(line) >= 28 and line[0] == ' ' and line[1:4] in ('OSB', 'DSB', 'DCB', 'ISB', 'LCB'): + # Check for station marker (columns 15-18, 0-indexed) + station_field = line[15:19] if len(line) > 19 else " " + + if station_field.strip(): + # Found a station marker - we have reached station biases + found_termination = True + break + + # Extract PRN, OBS, and BIAS_START + prn = line[11:14].strip() if len(line) > 14 else "" + obs = line[25:28].strip() if len(line) > 28 else "" + bias_start = line[35:49].strip() if len(line) > 49 else "" + + if not prn or not obs or len(prn) < 1: + continue + + # Get constellation from PRN prefix + constellation = prn_to_constellation.get(prn[0], None) + if not constellation: + continue + + seen_constellations.add(constellation) + + # Check if we've seen this (constellation, OBS) before + key = (constellation, obs) + if key in seen_constellation_obs: + first_time = seen_constellation_obs[key] + if bias_start and bias_start != first_time: + # We've seen this constellation+OBS before with a different time + # This means we've completed collecting unique codes for this constellation + # But only terminate if we've seen at least 2 constellations (to handle IGS format) + # OR if we've seen enough entries (safety check) + if len(seen_constellations) >= 2 or len(satellite_lines) > 500: + found_termination = True + break + # Otherwise, just skip this duplicate line + continue + else: + # First time seeing this constellation+OBS, record its BIAS_START + seen_constellation_obs[key] = bias_start + satellite_lines.append(line) + + if found_termination: + satellite_section = '\n'.join(satellite_lines) if satellite_lines else None + return True, satellite_section + + if force_return: + satellite_section = '\n'.join(satellite_lines) if satellite_lines else None + return True, satellite_section + + return False, None + +def parse_bia_code_priorities(bia_content: str) -> dict: + """ + Parse BIA file content and extract code priorities per constellation. + Transforms code observations (C*) to phase observations (L*). + + BIA format column positions (0-indexed): + - Cols 0-3: BIAS type (e.g., " OSB") + - Cols 6-9: SVN (e.g., "G080") + - Cols 11-13: PRN (e.g., "G01") + - Cols 15-18: STATION (4 spaces for satellite biases, 4-char marker for station biases) + - Cols 25-27: OBS1 (e.g., "C1C") + - Cols 35-48: BIAS_START (e.g., "2025:180:00000") + + :param bia_content: String content of the satellite bias section + :returns: Dictionary mapping constellation names to sets of code priorities + e.g., {'GPS': {'L1C', 'L2W', 'L1W'}, 'GAL': {'L1C', 'L5Q'}, ...} + """ + code_priorities = { + 'GPS': set(), + 'GLO': set(), + 'GAL': set(), + 'BDS': set(), + 'QZS': set(), + } + + if not bia_content: + return code_priorities + + # Debug: show first 20 lines being parsed + debug_lines = [] + line_count = 0 + + for line in bia_content.split('\n'): + # Skip empty lines + if not line.strip(): + continue + + # Check if this is a bias data line (need at least enough chars for OBS1) + if len(line) < 28: + continue + + # Bias data lines start with space + bias type + if line[0] != ' ' or line[1:4] not in ('OSB', 'DSB', 'DCB', 'ISB', 'LCB'): + continue + + # Extract fields using correct column positions (verified against real files) + svn = line[6:10].strip() if len(line) > 10 else "" + prn = line[11:14].strip() if len(line) > 14 else "" + station = line[15:19] if len(line) > 19 else " " # Don't strip - check for spaces + obs1 = line[25:28].strip() if len(line) > 28 else "" + + # Skip station bias lines - station field should be all spaces for satellite biases + if station.strip(): + # This is a station bias line, skip it + continue + + # Skip if PRN is invalid + if not prn or len(prn) < 2: + continue + + # Get constellation from first character of PRN + constellation_char = prn[0] + if constellation_char not in CONSTELLATION_MAP: + continue + + constellation = CONSTELLATION_MAP[constellation_char] + + # Skip if OBS1 is invalid + if not obs1 or len(obs1) < 3: + continue + + # Transform C* codes to L* codes + if obs1.startswith('C'): + obs1 = 'L' + obs1[1:] + + # Add to the constellation's set + code_priorities[constellation].add(obs1) + + return code_priorities + +def get_bia_code_priorities_for_selection(products_df: pd.DataFrame, + provider: str, series: str, project: str, + progress_callback: Optional[Callable] = None, + stop_requested: Optional[Callable] = None) -> Optional[dict]: + """ + Download and parse BIA file for a specific provider/series/project combination + to extract available code priorities per constellation. + + :param products_df: Products dataframe from get_product_dataframe_with_repro3_fallback() + :param provider: Analysis center (e.g., 'COD', 'GRG') + :param series: Solution type (e.g., 'FIN', 'RAP') + :param project: Project code (e.g., 'OPS', 'MGX') + :param progress_callback: Optional callback for progress updates (description, percent) + :param stop_requested: Optional callback to check if operation should stop + :returns: Dictionary mapping constellation names to sets of code priorities, + or None if download/parse fails + """ + # Find the BIA product row for this combination + bia_row = products_df[ + (products_df['analysis_center'] == provider) & + (products_df['project'] == project) & + (products_df['solution_type'] == series) & + (products_df['format'] == 'BIA') + ] + + if bia_row.empty: + Logger.console(f"No BIA product found for {provider}/{series}/{project}") + return None + + # Create authenticated session + session = requests.Session() + session.auth = get_netrc_auth() + + # Get BIA URL + url = get_bia_url_for_product(bia_row.iloc[0], session) + if not url: + Logger.console(f"Could not generate BIA URL for {provider}/{series}/{project}") + return None + + Logger.terminal(f"📥 Validating constellation signal frequencies against BIA file for {provider}/{series}/{project}...") + + # Download satellite bias section + bia_content = download_bia_satellite_section(url, session, progress_callback=progress_callback, stop_requested=stop_requested) + + if not bia_content: + Logger.console(f"Failed to download BIA content for {provider}/{series}/{project}") + return None + + # Parse code priorities + code_priorities = parse_bia_code_priorities(bia_content) + + # Log results + Logger.console(f"✅ Extracted code priorities for {provider}/{series}/{project}:") + for constellation, codes in sorted(code_priorities.items()): + if codes: + Logger.console(f" {constellation}: {', '.join(sorted(codes))}") + + return code_priorities + +#endregion if __name__ == "__main__": # Test whole file download diff --git a/scripts/GinanUI/app/models/execution.py b/scripts/GinanUI/app/models/execution.py index da46da21b..1a4bc936c 100644 --- a/scripts/GinanUI/app/models/execution.py +++ b/scripts/GinanUI/app/models/execution.py @@ -12,6 +12,7 @@ from pathlib import Path from scripts.GinanUI.app.utils.yaml import load_yaml, write_yaml, normalise_yaml_value from scripts.plot_pos import plot_pos_files +from scripts.plot_trace_res import plot_trace_res_files from scripts.GinanUI.app.utils.common_dirs import GENERATED_YAML, TEMPLATE_PATH, INPUT_PRODUCTS_PATH # Import the new logger @@ -153,8 +154,7 @@ def __init__(self, config_path: Path = GENERATED_YAML): if config_path.exists(): Logger.console(f"Using existing config file: {config_path}") else: - Logger.console( - f"Existing config not found, copying default template: {template_file} → {config_path}") + Logger.console(f"Existing config not found, copying default template: {template_file} → {config_path}") try: config_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy(template_file, config_path) @@ -175,6 +175,33 @@ def reload_config(self): except Exception as e: raise RuntimeError(f"❌ Failed to reload config from {self.config_path}: {e}") + def reset_config(self): + """ + Delete the generated yaml config file and regenerate it from the template. + This restores the config to its default state. + + :raises RuntimeError: If the reset operation fails + """ + template_file = Path(TEMPLATE_PATH) + + try: + # Delete the existing generated yaml config if it exists + if self.config_path.exists(): + self.config_path.unlink() + Logger.console(f"🗑️ Deleted existing config: {self.config_path}") + + # Copy fresh template to generated config location + self.config_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(template_file, self.config_path) + Logger.console(f"📄 Regenerated config from template: {template_file} → {self.config_path}") + + # Reload the fresh config into memory + self.config = load_yaml(self.config_path) + self.changes = False + + except Exception as e: + raise RuntimeError(f"❌ Failed to reset config: {e}") + def edit_config(self, key_path: str, value, add_field=False): """ Edits the cached config while preserving YAML formatting and comments. @@ -250,6 +277,11 @@ def apply_ui_config(self, inputs): out_val = normalise_yaml_value(inputs.output_path) self.edit_config("outputs.outputs_root", out_val, False) + # Output toggles from UI + self.edit_config("outputs.gpx.output", bool(inputs.gpx_output), True) + self.edit_config("outputs.pos.output", bool(inputs.pos_output), True) + self.edit_config("outputs.trace.output_network", bool(inputs.trace_output_network), True) + # 2. Replace 'TEST' receiver block with real marker name if "TEST" in self.config.get("receiver_options", {}): self.config["receiver_options"][inputs.marker_name] = self.config["receiver_options"].pop("TEST") @@ -278,6 +310,25 @@ def apply_ui_config(self, inputs): if const in all_constellations: self.edit_config(f"processing_options.gnss_general.sys_options.{const}.process", True, False) + # 5. Handle observation code priorities for each constellation + obs_code_map = { + 'gps': getattr(inputs, 'gps_codes', []), + 'gal': getattr(inputs, 'gal_codes', []), + 'glo': getattr(inputs, 'glo_codes', []), + 'bds': getattr(inputs, 'bds_codes', []), + 'qzs': getattr(inputs, 'qzs_codes', []) + } + + for const, codes in obs_code_map.items(): + if const in all_constellations: + if codes and len(codes) > 0: + # Convert codes list to a yaml compatible format + code_seq = CommentedSeq(codes) + code_seq.fa.set_flow_style() + self.edit_config(f"processing_options.gnss_general.sys_options.{const}.code_priorities", code_seq, False) + else: + self.edit_config(f"processing_options.gnss_general.sys_options.{const}.code_priorities", [],False) + def write_cached_changes(self): write_yaml(self.config_path, self.config) self.changes = False @@ -453,4 +504,71 @@ def build_pos_plots(self, out_dir=None): else: Logger.terminal("⚠️ No plots were generated.") + return htmls + + def build_trace_plots(self, out_dir=None): + """ + Search for .TRACE files directly under outputs_root + and generate HTML plots using plot_trace_res in outputs_root/visual. + Return a list of generated html paths (str). + + Uses configuration: + --mark-amb-resets --mark-large-errors --show-stats-table + --ambiguity-counts --ambiguity-totals --amb-totals-orient h + """ + try: + outputs_root = self.config["outputs"]["outputs_root"] + root = Path(outputs_root).expanduser().resolve() + except Exception: + # Fallback to default + root = Path(__file__).resolve().parents[2] / "tests" / "resources" / "outputData" + root = root.resolve() + + # Set output dir for HTML plots + if out_dir is None: + out_dir = root / "visual" + else: + out_dir = Path(out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + # Look for Network*.TRACE files in the outputs_root + trace_files = list(root.glob("Network*.TRACE")) + list(root.glob("network*.TRACE")) + list(root.glob("Network*.trace")) + list(root.glob("network*.trace")) + + # Also check for any other .TRACE files if no Network files found + if not trace_files: + trace_files = list(root.glob("*.TRACE")) + list(root.glob("*.trace")) + + if trace_files: + Logger.terminal(f"📂 Found {len(trace_files)} .TRACE files in {root}:") + for f in trace_files: + Logger.terminal(f" • {f.name}") + else: + Logger.terminal(f"⚠️ No .TRACE files found in {root}") + return [] + + htmls = [] + try: + # Convert trace files to string paths for the plotting function + trace_file_paths = [str(f) for f in trace_files] + + html_files = plot_trace_res_files( + files=trace_file_paths, + out_dir=str(out_dir), + mark_amb_resets=True, + mark_large_errors=True, + show_stats_table=True, + ambiguity_counts=True, + ambiguity_totals=True, + amb_totals_orient="h", + ) + htmls.extend(html_files) + except Exception as e: + Logger.terminal(f"[plot_trace_res] ❌ Failed to generate trace plots: {e}") + + # Final summary + if htmls: + Logger.terminal(f"✅ Generated {len(htmls)} trace plot(s) → saved in {out_dir}") + else: + Logger.terminal("⚠️ No trace plots were generated.") + return htmls \ No newline at end of file diff --git a/scripts/GinanUI/app/models/rinex_extractor.py b/scripts/GinanUI/app/models/rinex_extractor.py index b9918affa..5cfb6aeb9 100644 --- a/scripts/GinanUI/app/models/rinex_extractor.py +++ b/scripts/GinanUI/app/models/rinex_extractor.py @@ -1,5 +1,10 @@ import re from datetime import datetime +from pathlib import Path + +from scripts.GinanUI.app.utils.logger import Logger +from scripts.GinanUI.app.utils.yaml import load_yaml +from scripts.GinanUI.app.utils.common_dirs import GENERATED_YAML class RinexExtractor: @@ -29,6 +34,18 @@ def extract_rinex_data(self, rinex_path: str): } found_constellations = set() + # Observation types (code_priorities) for each constellation + # G = GPS, E = GAL, R = GLO, C = BDS, J = QZS + # Note: S (SBAS) and I (IRNSS) are intentionally omitted at the moment + obs_types_by_system = { + "G": [], # GPS + "E": [], # GAL + "R": [], # GLO + "C": [], # BDS + "J": [], # QZS + } + current_obs_system = None # Track the current system for continuation lines + def format_time(year, month, day, hour, minute, second): """ Helper function to format the parameters into a usable time string for RNX extraction @@ -68,6 +85,34 @@ def chunk_sat_ids(s: str): out.append(chunk) return out + def extract_obs_types_v3(line: str, obs_data: str): + """ + Extract code_priorities codes from a "SYS / # / OBS TYPES" line (RINEX v3/v4). + + :param line: The full line from the RINEX file + :param obs_data: The data portion of the line (first 60 characters) + :returns: Tuple of (system_letter, list of obs type codes) or (None, []) for continuation + """ + # Check if this is a new system line or a continuation line + first_char = line[0] if line else " " + + # Check if the first letter is within our valid systems + if first_char in obs_types_by_system: + # New system line: "G 22 C1C L1C D1C..." + # System letter is at position 0, count at positions 3-6 + # Extract observation codes starting after the count (position 7 onwards) + codes_section = obs_data[6:60].strip() + codes = [c for c in codes_section.split() if len(c) == 3] + return (first_char, codes) + elif first_char == " ": + # Continuation line: " S2L C5Q L5Q..." + codes_section = obs_data.strip() + codes = [c for c in codes_section.split() if len(c) == 3] + return (None, codes) # None indicates continuation + else: + # Some other system we don't care about (e.g., "S", "I") + return (first_char, []) + rinex_version = None previous_observation_dt = None epoch_interval = None @@ -101,7 +146,21 @@ def chunk_sat_ids(s: str): # ----- RINEX v2 header ----- if rinex_version and rinex_version < 3.0: if label == "# / TYPES OF OBSERV": - pass + obs_data = line[0:60] + parts = obs_data.split() + + # Check if first element is the count (numeric) + if parts and parts[0].isdigit(): + # First line - skip the count, extract observation codes + v2_obs_types = [p for p in parts[1:] if len(p) == 2] + else: + # Continuation line - all elements are observation codes + v2_obs_types = [p for p in parts if len(p) == 2] + + # Store raw v2 obs types for later conversion + if not hasattr(self, '_v2_obs_types'): + self._v2_obs_types = [] + self._v2_obs_types.extend(v2_obs_types) elif label == "TIME OF FIRST OBS": parts = line.split() if len(parts) >= 6: @@ -149,17 +208,43 @@ def chunk_sat_ids(s: str): system_id = line[0] if line else "" if system_id in system_mapping: found_constellations.add(system_mapping[system_id]) + + # Extract observation types for v3/v4 + obs_data = line[0:60] + system_letter, codes = extract_obs_types_v3(line, obs_data) + + if system_letter is not None: + # New system line + if system_letter in obs_types_by_system: + obs_types_by_system[system_letter].extend(codes) + current_obs_system = system_letter + else: + # System we don't track (e.g., "S", "I"), reset current + current_obs_system = None + else: + # Continuation line - append to the current system + if current_obs_system is not None and current_obs_system in obs_types_by_system: + obs_types_by_system[current_obs_system].extend(codes) + elif label == "TIME OF FIRST OBS": try: - y = int(line[0:6]); m = int(line[6:12]); d = int(line[12:18]) - hh = int(line[18:24]); mm = int(line[24:30]); ss = float(line[30:43]) + y = int(line[0:6]) + m = int(line[6:12]) + d = int(line[12:18]) + hh = int(line[18:24]) + mm = int(line[24:30]) + ss = float(line[30:43]) start_epoch = format_time(y, m, d, hh, mm, ss) except Exception: pass elif label == "TIME OF LAST OBS": try: - y = int(line[0:6]); m = int(line[6:12]); d = int(line[12:18]) - hh = int(line[18:24]); mm = int(line[24:30]); ss = float(line[30:43]) + y = int(line[0:6]) + m = int(line[6:12]) + d = int(line[12:18]) + hh = int(line[18:24]) + mm = int(line[24:30]) + ss = float(line[30:43]) end_epoch = format_time(y, m, d, hh, mm, ss) except Exception: pass @@ -214,8 +299,11 @@ def chunk_sat_ids(s: str): continue y_raw = int(m.group(1)) - mo = int(m.group(2)); dd = int(m.group(3)) - hh = int(m.group(4)); mmn = int(m.group(5)); ssf = float(m.group(6)) + mo = int(m.group(2)) + dd = int(m.group(3)) + hh = int(m.group(4)) + mmn = int(m.group(5)) + ssf = float(m.group(6)) flag = int(m.group(7)) # currently unused nsat = m.group(8) nsat = int(nsat) if nsat is not None else 0 @@ -310,6 +398,199 @@ def chunk_sat_ids(s: str): if epoch_interval is None: raise ValueError("Epoch interval could not be determined") + # ---------- RINEX v2 observation type conversion ---------- + # Convert v2 obs types (C1, C2, P1, P2) to v3 codes using YAML config mappings + if rinex_version and rinex_version < 3.0: + v2_obs_types = getattr(self, '_v2_obs_types', []) + if v2_obs_types: + Logger.console(f"=== RINEX v2 Observation Types Found: {v2_obs_types} ===") + + # Filter to only the codes we care about: P1, P2, C1, C2 + # Priority order: P1 (highest), P2, C1, C2 (lowest) + v2_priority_order = ['P1', 'P2', 'C1', 'C2'] + v2_codes_present = [code for code in v2_priority_order if code in v2_obs_types] + + Logger.console(f"=== Filtered v2 codes (priority order): {v2_codes_present} ===") + + # Load rinex2 code conversions from YAML config + try: + template_config = load_yaml(GENERATED_YAML) + receiver_options = template_config.get('receiver_options', {}).get('global', {}) + + # Map system letters to constellation names used in YAML + system_to_yaml_name = { + 'G': 'gps', + 'R': 'glo', + } + + # For each constellation found in the RINEX file, convert v2 codes to v3 + for sys_letter, const_name in system_to_yaml_name.items(): + # Check if this constellation has rinex2 conversions defined + const_config = receiver_options.get(const_name, {}) + rinex2_config = const_config.get('rinex2', {}) + code_conversions = rinex2_config.get('rnx_code_conversions', {}) + + if code_conversions: + # Convert v2 codes to v3 codes in priority order + v3_codes = [] + for v2_code in v2_codes_present: + if v2_code in code_conversions: + v3_code = str(code_conversions[v2_code]) + v3_codes.append(v3_code) + + if v3_codes: + obs_types_by_system[sys_letter] = v3_codes + + except Exception as e: + Logger.console(f"⚠️ Could not load rinex2 code conversions from config: {e}") + + # Clean up temporary attribute + if hasattr(self, '_v2_obs_types'): + delattr(self, '_v2_obs_types') + + # Cull observation types to only L-codes (converting C to L) + def cull_observation_codes(obs_list): + """ + Cull observation codes to only carrier phase (L) codes. + Rules: + - Only keep codes starting with 'C' or 'L' (ignore D, S, etc.) + - Convert 'C' codes to 'L' codes (C1C -> L1C) + - Remove duplicates (if both C1C and L1C exist, keep only L1C) + + Example: ['C1C', 'L1C', 'D1C', 'S1C', 'C1W'] -> ['L1C', 'L1W'] + """ + # Filter to only C and L codes + filtered = [code for code in obs_list if code and code[0] in ('C', 'L')] + + # Convert all to L-codes and track unique codes + l_codes = set() + for code in filtered: + if code[0] == 'C': + # Convert C to L (e.g., C1C -> L1C) + l_code = 'L' + code[1:] + l_codes.add(l_code) + else: + # Already an L code + l_codes.add(code) + + # Return sorted list to maintain consistent order + return sorted(list(l_codes)) + + # Apply culling to all observation types (only for v3/v4 files) + # For v2 files, codes are already converted to L-codes with correct priority order + if not (rinex_version and rinex_version < 3.0): + obs_types_by_system['G'] = cull_observation_codes(obs_types_by_system['G']) + obs_types_by_system['E'] = cull_observation_codes(obs_types_by_system['E']) + obs_types_by_system['R'] = cull_observation_codes(obs_types_by_system['R']) + obs_types_by_system['C'] = cull_observation_codes(obs_types_by_system['C']) + obs_types_by_system['J'] = cull_observation_codes(obs_types_by_system['J']) + + # Load default priority order from template config + def load_default_priorities(): + """ + Load the default code_priorities from the template YAML config. + + Returns: + dict: Mapping of constellation codes to their priority lists + e.g., {'G': ['L1W', 'L1C', ...], 'E': ['L1C', 'L1X', ...]} + """ + try: + template_config = load_yaml(GENERATED_YAML) + sys_options = template_config.get('processing_options', {}).get('gnss_general', {}).get('sys_options', + {}) + + # Map constellation names to system letters + const_map = { + 'gps': 'G', + 'gal': 'E', + 'glo': 'R', + 'bds': 'C', + 'qzs': 'J' + } + + priorities = {} + for const_name, sys_letter in const_map.items(): + if const_name in sys_options: + code_priorities = sys_options[const_name].get('code_priorities', []) + priorities[sys_letter] = code_priorities + else: + priorities[sys_letter] = [] + + return priorities + except Exception as e: + Logger.console(f"⚠️ Could not load default priorities from template: {e}") + # Return empty priorities if template can't be loaded + return {'G': [], 'E': [], 'R': [], 'C': [], 'J': []} + + def reorder_by_priority(rinex_codes, priority_order): + """ + Reorder RINEX codes based on template priority order. + + Rules: + 1. Codes that appear in priority_order are placed first, in that order (enabled by default) + 2. Codes from RINEX that aren't in priority_order are appended at the end (disabled by default) + + Args: + rinex_codes: List of codes from RINEX (after culling) + priority_order: Preferred order from template config + + Returns: + tuple: (ordered_codes, enabled_set) + - ordered_codes: List of codes in priority order + - enabled_set: Set of codes that should be enabled by default + + Example: + rinex_codes = ['L1C', 'L2W', 'L5Q', 'L1L', 'L2L'] + priority_order = ['L1W', 'L1C', 'L2W', 'L5Q'] + result = (['L1C', 'L2W', 'L5Q', 'L1L', 'L2L'], {'L1C', 'L2W', 'L5Q'}) + ^ in priority order ^ ^ extras ^ ^ only priority codes enabled ^ + """ + if not priority_order: + # If no priority order defined, enable all codes + return rinex_codes, set(rinex_codes) + + # Convert to sets for efficient lookup + rinex_set = set(rinex_codes) + priority_set = set(priority_order) + + # Start with codes from priority list that exist in RINEX (these are enabled) + ordered_codes = [code for code in priority_order if code in rinex_set] + enabled_codes = set(ordered_codes) + + # Append codes from RINEX that aren't in priority list (these are disabled) + extra_codes = sorted([code for code in rinex_codes if code not in priority_set]) + + return ordered_codes + extra_codes, enabled_codes + + # Load default priorities + default_priorities = load_default_priorities() + + # Reorder observation types based on template priorities and track which should be enabled + # For v2 files, skip reordering since codes are already in correct priority order from conversion + if rinex_version and rinex_version < 3.0: + # For v2 files, all converted codes are enabled and already in priority order + enabled_gps = set(obs_types_by_system['G']) + enabled_gal = set(obs_types_by_system['E']) + enabled_glo = set(obs_types_by_system['R']) + enabled_bds = set(obs_types_by_system['C']) + enabled_qzs = set(obs_types_by_system['J']) + else: + # For v3/v4 files, use the existing reordering logic + obs_types_by_system['G'], enabled_gps = reorder_by_priority(obs_types_by_system['G'], default_priorities.get('G', [])) + obs_types_by_system['E'], enabled_gal = reorder_by_priority(obs_types_by_system['E'], default_priorities.get('E', [])) + obs_types_by_system['R'], enabled_glo = reorder_by_priority(obs_types_by_system['R'], default_priorities.get('R', [])) + obs_types_by_system['C'], enabled_bds = reorder_by_priority(obs_types_by_system['C'], default_priorities.get('C', [])) + obs_types_by_system['J'], enabled_qzs = reorder_by_priority(obs_types_by_system['J'], default_priorities.get('J', [])) + + # Log extracted observation types for verification + Logger.console("=== Extracted Observation Types (Code Priorities) ===") + Logger.console(f"GPS (G): {obs_types_by_system['G']}") + Logger.console(f"GAL (E): {obs_types_by_system['E']}") + Logger.console(f"GLO (R): {obs_types_by_system['R']}") + Logger.console(f"BDS (C): {obs_types_by_system['C']}") + Logger.console(f"QZS (J): {obs_types_by_system['J']}") + Logger.console("======================================================") + return { "rinex_version": rinex_version, "start_epoch": start_epoch, @@ -320,4 +601,14 @@ def chunk_sat_ids(s: str): "antenna_type": antenna_type, "antenna_offset": antenna_offset, "constellations": ", ".join(sorted(found_constellations)) if found_constellations else "Unknown", + "obs_types_gps": obs_types_by_system["G"], + "obs_types_gal": obs_types_by_system["E"], + "obs_types_glo": obs_types_by_system["R"], + "obs_types_bds": obs_types_by_system["C"], + "obs_types_qzs": obs_types_by_system["J"], + "enabled_gps": enabled_gps, + "enabled_gal": enabled_gal, + "enabled_glo": enabled_glo, + "enabled_bds": enabled_bds, + "enabled_qzs": enabled_qzs, } \ No newline at end of file diff --git a/scripts/GinanUI/app/resources/Yaml/default_config.yaml b/scripts/GinanUI/app/resources/Yaml/default_config.yaml index 15c085890..b0350aeb0 100644 --- a/scripts/GinanUI/app/resources/Yaml/default_config.yaml +++ b/scripts/GinanUI/app/resources/Yaml/default_config.yaml @@ -54,7 +54,15 @@ outputs: pos: output: true filename: __.POS - + trace: + level: 2 + output_receivers: false + output_network: true + receiver_filename: __.TRACE + network_filename: __.TRACE + output_residuals: true + output_residual_chain: true + output_config: true diff --git a/scripts/GinanUI/app/resources/__init__.py b/scripts/GinanUI/app/resources/__init__.py index 1b82a7c45..2f92fa8f1 100644 --- a/scripts/GinanUI/app/resources/__init__.py +++ b/scripts/GinanUI/app/resources/__init__.py @@ -1,3 +1,3 @@ import sys -from . import ginan_logo_rc as _rc +from .assets import ginan_logo_rc as _rc sys.modules.setdefault("ginan_logo_rc", _rc) \ No newline at end of file diff --git a/scripts/GinanUI/app/resources/ginan_logo.qrc b/scripts/GinanUI/app/resources/assets/ginan_logo.qrc similarity index 100% rename from scripts/GinanUI/app/resources/ginan_logo.qrc rename to scripts/GinanUI/app/resources/assets/ginan_logo.qrc diff --git a/scripts/GinanUI/app/resources/ginan_logo_rc.py b/scripts/GinanUI/app/resources/assets/ginan_logo_rc.py similarity index 100% rename from scripts/GinanUI/app/resources/ginan_logo_rc.py rename to scripts/GinanUI/app/resources/assets/ginan_logo_rc.py diff --git a/scripts/GinanUI/app/resources/assets/icons.qrc b/scripts/GinanUI/app/resources/assets/icons.qrc new file mode 100644 index 000000000..69fe2545d --- /dev/null +++ b/scripts/GinanUI/app/resources/assets/icons.qrc @@ -0,0 +1,13 @@ + + + checkbox_selected.png + checkbox_selected_disabled.png + checkbox_selected_hover.png + checkbox_selected_pressed.png + checkbox_unselected.png + checkbox_unselected_disabled.png + checkbox_unselected_hover.png + checkbox_unselected_pressed.png + checkbox_disabled.png + + diff --git a/scripts/GinanUI/app/resources/assets/icons_rc.py b/scripts/GinanUI/app/resources/assets/icons_rc.py new file mode 100644 index 000000000..0952d112b --- /dev/null +++ b/scripts/GinanUI/app/resources/assets/icons_rc.py @@ -0,0 +1,357 @@ +# Resource object code (Python 3) +# Created by: object code +# Created by: The Resource Compiler for Qt version 6.10.1 +# WARNING! All changes made in this file will be lost! + +from PySide6 import QtCore + +qt_resource_data = b"\ +\x00\x00\x01w\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00\x00\ +\x09pHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\ +\xa8d\x00\x00\x01\x0cIDAThC\xed\xd91\x0e\ +\x82@\x10\x05\xd0]=\x01\xb7\xa0\xa6 v\xde\x81;\ +@\xad\x95V&z\x01\xa9\xe1\x0e\xf4v\xd6$Ps\ +\x0b\x8e\x80;q\x1a\x02k\xb2\x09d@\xfekf\x87\ +\x02\xe7\x0b\x9bl\x82\x02\x00\x10\xa5\xb9\x8eJ\x92\xe4i\ +\xca\xe9\xdb\x89I\xb3,;\xf3z`\xcfu \x8e\xe3\ +\xbb\xd6\xfa\xc2\xad\xa4C\x10\x04\xbb\xba\xae\xdf\xdc\xf7\xec\ +\xb8\x0e\x98\xe1o\xbc\x14\xf7k\x16k\x80\xb5@\x00i\ +\x08 \x0d\x01\xa4!\x804\x04\x90\xb6\xfa\x00\xd6\xe3\xb4\ +9Jw\xbc\xec\xf1:\xd9HN\x8c\x13l%\x00\x00\x00\x00IE\ +ND\xaeB`\x82\ +\x00\x00\x01w\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00\x00\ +\x09pHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\ +\xa8d\x00\x00\x01\x0cIDAThC\xed\xd91\x0e\ +\x82@\x10\x05\xd0]=\x01\xb7\xa0\xa6 v\xde\x81;\ +@\xad\x95V&z\x01\xa9\xe1\x0e\xf4v\xd6$Ps\ +\x0b\x8e\x80;q\x1a\x02k\xb2\x09d@\xfekf\x87\ +\x02\xe7\x0b\x9bl\x82\x02\x00\x10\xa5\xb9\x8eJ\x92\xe4i\ +\xca\xe9\xdb\x89I\xb3,;\xf3z`\xcfu \x8e\xe3\ +\xbb\xd6\xfa\xc2\xad\xa4C\x10\x04\xbb\xba\xae\xdf\xdc\xf7\xec\ +\xb8\x0e\x98\xe1o\xbc\x14\xf7k\x16k\x80\xb5@\x00i\ +\x08 \x0d\x01\xa4!\x804\x04\x90\xb6\xfa\x00\xd6\xe3\xb4\ +9Jw\xbc\xec\xf1:\xd9HN\x8c\x13l%\x00\x00\x00\x00IE\ +ND\xaeB`\x82\ +\x00\x00\x029\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00\x00\ +\x09pHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\ +\xa8d\x00\x00\x01\xceIDAThC\xed\x99\xbbJ\ +\x03A\x14\x86gb#b\xe1\xa5\x10,\xc4\xbb\x8d\x08\ +A\x08)\x84\x94\x11A_ \x8d\xa9mb\xa5\x95\x18\ +\xb17o`\x1a_\xc0BLga%\x08)\xac\xc4\ +\x10;\x11\x0cI\xd0\xa0\xdd\x9a\xa3g\x83kf&{\ +\x99\xdd\x89r>X\xe6\x9cI\xf3\x7f\xcb\xccd\x97e\ +\x04A\x10F\xe18\x0a\x99No\x9fp\xces\xd8\x1a\ +\xc1\xb2\xac\xc2c\xa9\xb8\x8bm\x17\x038v1\x9b\xce\ +\xe6\x19\xe7{\xd8\x1a\xa3}\x03\x93cs\xf1X\xbdR\ +\xbe\xc2)\x071\x1c\xbb\xb08;\xc0\xd28\xaa,R\ +\x81\xbf\x02\x09\x98\x86\x04LC\x02A\x98\x18\x1fa\x9b\ +\xa9D\xe7\x82\xde+\xc6\x04 \xec\xc6\xda*;\xde\xc9\ +t\xae\xc4\xf2\x22\xfe\xea\x1e#\x02v\xf8\x5cf\x0bg\ +\xfc\x13\xb9\x80\xce\xf0@\xa4\x02\xaa\xf0\x85\xb3svs\ +w\x8f\x9d{\x22\x13\xe8\x15\xfe\xe2\xfa\x96=\xd7\x1a8\ +\xe3\x1e_\x02\x10\xc6\xcb\xc9\x11Vx\xc0\xb3\x80\x1d\xc6\ +>9\xa0VI\x84\x19\x1e\xf0,\x00G\xdd\xcf0P\ +\xcb$\xc2\x0e\x0fh\xd9\x03\x22\x89(\xc2\x03\xd27\xb2\ +\xd1\xf9\xf8!\x96\x0e\x9ao-Vk\xbe\xb2\xe4\xca\x12\ +\xce|\x03=\xcc?\xbd\xd4\xd9\xf0\xd0\xa0\xf6\xf0\x8d\x87\ +r\x1eK\x07\x9e\x05Z\xef\x1f_!U\x12\x0bS\x93\ +\xda\xef\xbcL@\xfaR?\xb3\x9e\xb5\xb0\x14\xa2Z\x22\ +\x22\x82.\x9b\xea\xe5\xa90\xab\xef=\x00A \x10\x04\ +\xeb\x85\xce5\xff\x9b@\x9b\xd8\x8dD\x98\xe1\x81\xc0\xa7\ +\x90J\x22\xec\xf0\x80\x96cT$\x11Ex\xc0\xf7&\ +\x16\x01\x1b\xdb~\xa6\x87\x073\x9d\xe1e\x9bX\xab@\ +\x98h?\x85\xfa\x05\x120\x0d\x09\x98\xe6\xff\x0a\xc0\x97\ +\x11,\x8d\xa3\xca\x22}\x9cnT\xca%\xf82\xd2\xfe\ +\xa7H\xe1\x94\x11\xb8\xc5\x8e\xaa\xa5\xe2>\xb6\x04A\x10\ +}\x05c\x9f\xc3]\xf0\xc1*\x5c\xab\x9f\x00\x00\x00\x00\ +IEND\xaeB`\x82\ +\x00\x00\x01[\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00\x00\ +\x09pHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\ +\xa8d\x00\x00\x00\xf0IDAThC\xed\xd9\xb1\x0e\ +\xc1P\x14\xc6\xf1sk5\x98\x0c\x12\xab\xc4N\xd9\x8c\ +M<\x83Wh\xa2\x166\xe1\x01T\xd2W\xf0\x0c\x92\ +n\xe6\xb2K\xac\x12\x83\xc9`\xafj\x8eA([\xbf\ +\x92\xef\xb7\xdc\xde\x93\x0e\xfd\xb7[\xaf\x10\x11A\x19]\ +\xdfj\xbb\xbe\x9f\xdc0\xd4-D,\xb2\xdc\x06\x9e\xa7\ +\xdb\x17%]_\xb4\xdd\xc5\xcc\x88\x19\xeb\x16&y\x81\ +\xddZ\xc7\xb1NQ\xb8\xd1\xd1\x93\xcc/`\xbb~\x12\ +/R\xad\x94\xa5\xd5\xa8\xa7\xb3\xbc\xed\x0eG9_\xae\ +\xe9u\x14xo\x9f\xf5k@\xdfn\xcat\xe0\xa4\xb3\ +\xbc\xcdV\xa1\xac\xa3}z\x9d\x15`\xe9\xfa\xb3\x18\x80\ +\xc6\x004\x06\xa01\x00\x8d\x01h\x0c@c\x00\x1a\x03\ +\xd0\x18\x80\xc6\x004\x06\xa01\x00\x8d\x01h\x0c@\xfb\ +\xdf\xdf\xebE8\x9dy\xf8tJ\x93yBs\x8a\xc2\ +\xf0~2b\xc4\xf4t\x04\x11K<\xdf\x06\xa3\x89n\ +\x89\x88\x0aE\xe4\x06_}91\xefam\x0d\x00\x00\ +\x00\x00IEND\xaeB`\x82\ +\x00\x00\x02A\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00\x00\ +\x09pHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\ +\xa8d\x00\x00\x01\xd6IDAThC\xed\x99?H\ +\xc3P\x10\xc6/\xba;\x08V\x17\x11*8hiu\ +\x92\x82tt\x11\x5c\x1c\x5c\xac\xa0\xa2\xbb{\x07\x85:\ +\xbb;X\xc4\x0e\xba\x15\xc1\xc5\xb1\x0a\xe2VCut\ +\x14\xb4TT(\xe2\x14sz)m\xf3\x12\xf3\xe7%\ +\x87\xf2~\xf0xw\xd7\x0e\xdf\xf7\x9a\xbb&\x04\x14\x0a\ +\x85\x82\x15\x8dv!\xa9\x95\xc2\xa1\xf9\x8d5Jy0\ +\xa0T/\x17\xd7)\xb3\xd1O\xbb\x8d\xa9|\xe1@\xd3\ +`\x83R>4\x98\x19J\xe7\x92\x0d\xbdZ\xa1J\x17\ +}\xb4\xdb0\x7f\x9aM\x0a\xd91\x0fr\x95B\x1b\x8e\ +\x06\xfe\x0a\xca\x007\xca\x007\xca@\x18\x86\x07\x07`\ +qn\xba\xbd0\xf7\x0b\x9b\x01\x14\xbb\x90\xcd\xc0\xde\xd6\ +R{\xcdN&\xe9S\xef\xb0\x18\xb0\xc4o/\xcfS\ +%8\xb1\x1b\x90)\x1e\x89\xd5\x80\x9b\xf8\xfd\xd3\x0b\xb8\ +\xb9\x7f\xa0\xcc;\xb1\x19\xf8M\xfc\xf9\xf5-<\xbd\xbc\ +S\xc5;\x81\x0c\xa0\x18?\x93#*\xf1\x88o\x03\x96\ +\x18kr`\xecf\x22J\xf1\x88o\x038\xea:\xc5\ +`\xecd\x22j\xf1\x88\x94\x1e\x10\x99\x88C<\xe2\xdb\ +\x00N\x0a\x14\xd0K\xa7\x89\xb8\xc4#\x8e\x8f\x94\x89L\ +n\x87\xc2.Z\x1f\x9f\xf0\xd8|\x85\xe6[\x0b\xb2\xa9\ +q\xaa\xfe\x809\xd6'FG\xa4\x8b\x7f\xd6\xab\xbb\x14\ +v\xe1\xf8P\x9f\xca\x17\x0c\x0a\x85\xb8\x9d\xb2\x88\xb0'\ +_?.\x0a\xb5\x06\xee\x01\x14\x82\x82D\x97S/\xb2\ +/\x9bNB5\xb1\x17\x13Q\x8aGBO!7\x13\ +Q\x8bG\xa4\x8cQ\x91\x898\xc4#\x81\x9bX\x046\ +\xb6uO\x8f\xe3V\xa6x\xe9M,\x02\x05\x9f]\xd5\ +\xbeW\xd4'o!\xd5\x00\x07\xca\x007\xca\x007\xff\ +\xd7\x80a\x18G\x14\xb2c\xfe!\x9dPh\xc3\xf1v\ +\xba\xa1_V\x12\xe9\xdc\x18\xbe!\xa1\x12\x0f\x06\x94\xee\ +\xcaE\xc7\x17\x1c\x0a\x85B\xc1\x09\xc0\x17\xa21\xe6\xba\ +\x9a\xc5C@\x00\x00\x00\x00IEND\xaeB`\x82\ +\ +\x00\x00\x02\x06\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00\x00\ +\x09pHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\ +\xa8d\x00\x00\x01\x9bIDAThC\xed\x99?K\ +\xc3@\x18\x87/uupr\x10\x5cE\xf7\xb6:\x08\ +\x8e\x01\x07?\x81\x1f\xc1\x80q\xd1\xc9\xa2\x1f\xc0\x08\xf1\ +\x938\x08\xd9\x1c\x9c\xd2\xee\x8a\xab\xd0\xc1\xc9\xc1=\xe6\ +'oJm\x92k\xfe\xdd\xbd*\xef\x03!w\xd7B\ +\x9f\xa7m\x8e\xd2(A\x10\x04V\x1c:\x172\xf0\x82\ + }\xc2)MYH\x94\xba\x1d\x87\xbeO\xd3\x1c+\ +t\xce1\xf0n\xae\x1c\xe5\x9c\xd3\x94\x8d\xf4\x0d\xdc\xdb\ +\xd8u{\xd38z\xa4\xa5\x1f\xf4\xe8\x9c#\x95\xbf\xa4\ +!;:\x97\xd2\x80\xbf\x82\x04p#\x01\xdcH@\x1b\ +\xd6\xd7V\xd5\xe1pgv`^\x17\xb6\x00\xc8\xba\xfd\ +m5:vgG\x7fk\x93\x1e\xad\x0eK@&\x7f\ +r\xb4O+\xcd\xb1\x1e\xd0\xa5<\xb0\x1a\xa0\x93\xbf\xbb\ +\x7fR\x93\xd77\x9aU\xc7Z\xc02\xf9h\xf2\xa2\xde\ +?>i\xa5:\x8d\x02 Sg\xe70%\x0fj\x07\ +d2\xd9\xce\x81\xb1.\xc2\xa4<\xa8\x1d\x80\xadn^\ +\x06\xe3\xb2\x08\xd3\xf2\xa0\x93k\xa0(\xc2\x86<\xa8\x1d\ +\x80\x9d\x02\x02\x8b\xccG\xd8\x92\x07\xb5\x03\xf0\xc2\x10\xd0\ +E\xd8\x92\x07\x8d\xbeB\xcb\x22l\xc9\x83\xc6\xd7\x80.\ +b\x11S\xf2\xa0\xd5E\x5c%\xc2\xa4\x80\x99\xd7\x9c\xf3(\x22\x87W\xdc\x9f\x8e\x99\xd7\ +\xd6\xac\xf9B\x93R\xda\xbc\xf7])e\xb0^~\xef\ +\x89\x09!\x84l{\x025\x09B\xa2N8F\x04\x00\ +\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x01]\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00\x00\ +\x09pHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\ +\xa8d\x00\x00\x00\xf2IDAThC\xed\xd9\xad\x12\ +\x01Q\x18\xc6\xf1\xf7p\x03\x86\xa0\xfa\x98\x91\x14\xc5h\ +\x1b)\xae@!+$\x82\xc2\xe8\x14\x99\xe2\x0a\x14\xdb\ +\xa8\x92 \x991T\xc1p\x07\x07;\xaf`X\x9a\x07\ +\xf3\xfc\xca9\xe7M\xfb\xdfm{\x84\x88\x08\xca\xe8\xfa\ +T,_\xee\x19cjz\x84\xb0\xd6\xf6w\xee\xa8\xae\ +\xc7\x07A]\x1f$\xf2\x95\xb6\x18\xd3\xd0#\xcc\xe5\x05\ +\xe6\xc2\xc9L\xe0\xb8Y\xcett\xc7\xf7\x0b\xc4\x0b\x15\ +{]\xa3\x91\x90d\xd3)o\xf6i\x8b\xd5Z\xf6\x87\ +\x93\xb7\xdfN\x87O\x9f\xf5m@\xd1\xc9J\xb7Z\xf2\ +f\x9f\xd6\x1a\x8ce2_x{\xbf\x80\x80\xae?\x8b\ +\x01h\x0c@c\x00\x1a\x03\xd0\x18\x80\xc6\x004\x06\xa0\ +1\x00\x8d\x01h\x0c@c\x00\x1a\x03\xd0\x18\x80\xc6\x00\ +\xb4\xff\xfd\xbd\xfe\x0d\xb737\xafni|ohN\ +\x9b\xa5{\xbd\x19\xb9$::\x820V:[w\xd4\ +\xd4#\x11\xd1W\x119\x03\xa9\xde<9{\x14\x92\x1c\ +\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x01p\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x000\x00\x00\x000\x08\x06\x00\x00\x00W\x02\xf9\x87\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00\x00\ +\x09pHYs\x00\x00\x0e\xc3\x00\x00\x0e\xc3\x01\xc7o\ +\xa8d\x00\x00\x01\x05IDAThC\xed\xd9\xb1J\ +\xc3P\x14\xc6\xf1s\xf5\x11\x8a\xd2\xad\xd0\xb5!\xe0\xd6\ +)`G'\xe9Vh,\xea\xa4\xaf`\xc6\xfb\x0c\x16\ +:X\xa1\x1d\x9cJ&G\x0b\xed\xe2\x1c\xd2\x07\xe8&\ +\x14\x9f!&\x97\xb3\x94$\xb8\xf5\xd3\xf2\xfd\x96\x9bs\ +\xc9\x90\x7f\xb2\xe5\x0a\x11\x11\x94\xd1\xb5\x927\x8c^\xf2\ +;nu\xc4\xc8d\x9a\xce\xed\x9dN%\xa7\xba\x96t\ +\xc2hb\x8c\xdc\xeb\x88c\xe4\xe2\xcc\x0f\xda\xbbd\x15\ +\xeb\xce\x9e\xda/\xe0\x85QV\xac\xadfC\xae\xba\xbe\ +\xdb;\xb4\xf7\xcfD\xb6_\xdf\xee:\x9d\xd9\xcag\xfd\ +5\xe0\xe1\xfaR\x1e\xfb=\xb7wh\xcf\x8b\x0f\x19\xc7\ +Kw]\x17p\xa2\xeb\xbf\xc5\x004\x06\xa01\x00\x8d\ +\x01h\x0c@c\x00\x1a\x03\xd0\x18\x80\xc6\x004\x06\xa0\ +1\x00\x8d\x01h\x0c@c\x00\xda\xf1\xfe^\xef\x0c\x9f\ +^\x8d1#\x1d\xa1\xf27\xf9\xb6\x99\xd9\x81\x8e{j\ +Ohv\xc9:>\xf7\x83VqB\xa2[\x18\x99L\ +7s{\xa3\x13\x11\xd1\x9f\x22\xf2\x03\xe518I&\ +C\xa7V\x00\x00\x00\x00IEND\xaeB`\x82\ +" + +qt_resource_name = b"\ +\x00\x04\ +\x00\x06\xfa^\ +\x00i\ +\x00c\x00o\x00n\ +\x00\x15\ +\x07Sl\xa7\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00d\x00i\x00s\x00a\x00b\x00l\x00e\x00d\ +\x00.\x00p\x00n\x00g\ +\x00\x1e\ +\x01\xa4;\xa7\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00s\x00e\x00l\x00e\x00c\x00t\x00e\x00d\ +\x00_\x00d\x00i\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00\x1d\ +\x07\xffj'\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00s\x00e\x00l\x00e\x00c\x00t\x00e\x00d\ +\x00_\x00p\x00r\x00e\x00s\x00s\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00\x1d\ +\x05\xf8\xd7\x07\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00u\x00n\x00s\x00e\x00l\x00e\x00c\x00t\ +\x00e\x00d\x00_\x00h\x00o\x00v\x00e\x00r\x00.\x00p\x00n\x00g\ +\x00\x15\ +\x09\x02\x14\xc7\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00s\x00e\x00l\x00e\x00c\x00t\x00e\x00d\ +\x00.\x00p\x00n\x00g\ +\x00\x1b\ +\x0e\xdbn\xa7\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00s\x00e\x00l\x00e\x00c\x00t\x00e\x00d\ +\x00_\x00h\x00o\x00v\x00e\x00r\x00.\x00p\x00n\x00g\ +\x00 \ +\x0a?_\xc7\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00u\x00n\x00s\x00e\x00l\x00e\x00c\x00t\ +\x00e\x00d\x00_\x00d\x00i\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00\x1f\ +\x04F\xdcg\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00u\x00n\x00s\x00e\x00l\x00e\x00c\x00t\ +\x00e\x00d\x00_\x00p\x00r\x00e\x00s\x00s\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00\x17\ +\x04\x93\xc8\x07\ +\x00c\ +\x00h\x00e\x00c\x00k\x00b\x00o\x00x\x00_\x00u\x00n\x00s\x00e\x00l\x00e\x00c\x00t\ +\x00e\x00d\x00.\x00p\x00n\x00g\ +" + +qt_resource_struct = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x09\x00\x00\x00\x02\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00>\x00\x00\x00\x00\x00\x01\x00\x00\x01{\ +\x00\x00\x01\x9b\x8b|\xd9\x1d\ +\x00\x00\x01\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x0ca\ +\x00\x00\x01\x9b\x8b\x7f\x84`\ +\x00\x00\x01\xf6\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xc2\ +\x00\x00\x01\x9b\x8b|\xb8I\ +\x00\x00\x00\xc0\x00\x00\x00\x00\x00\x01\x00\x00\x053\ +\x00\x00\x01\x9b\x8b}Y\x86\ +\x00\x00\x00\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01\x9b\x8b|\xd9\x1d\ +\x00\x00\x00\x80\x00\x00\x00\x00\x00\x01\x00\x00\x02\xf6\ +\x00\x00\x01\x9b\x8b\x7f\xec\x90\ +\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x06\x92\ +\x00\x00\x01\x9b\x8b|\x97\x0f\ +\x00\x00\x01l\x00\x00\x00\x00\x00\x01\x00\x00\x0a\xe1\ +\x00\x00\x01\x9b\xc3\xf0\x1b\xf5\ +\x00\x00\x010\x00\x00\x00\x00\x00\x01\x00\x00\x08\xd7\ +\x00\x00\x01\x9b\x8b}\x1f{\ +" + +def qInitResources(): + QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + +def qCleanupResources(): + QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + +qInitResources() diff --git a/scripts/GinanUI/app/utils/common_dirs.py b/scripts/GinanUI/app/utils/common_dirs.py index 3c0c9515a..cbd9c144b 100644 --- a/scripts/GinanUI/app/utils/common_dirs.py +++ b/scripts/GinanUI/app/utils/common_dirs.py @@ -11,7 +11,17 @@ def get_base_path(): # Running in development mode - __file__ is in app/utils/ return Path(__file__).parent.parent +def get_user_manual_path(): + """Get the path to the user manual, handling both development and PyInstaller bundled modes.""" + if getattr(sys, 'frozen', False): + # Running in PyInstaller bundle - look in _internal/docs/ + return Path(sys._MEIPASS) / "docs" / "USER_MANUAL.md" + else: + # Running in development mode - __file__ is in app/utils/ + return Path(__file__).parent.parent.parent / "docs" / "USER_MANUAL.md" + BASE_PATH = get_base_path() TEMPLATE_PATH = BASE_PATH / "resources" / "Yaml" / "default_config.yaml" GENERATED_YAML = BASE_PATH / "resources" / "ppp_generated.yaml" INPUT_PRODUCTS_PATH = BASE_PATH / "resources" / "inputData" / "products" +USER_MANUAL_PATH = get_user_manual_path() \ No newline at end of file diff --git a/scripts/GinanUI/app/utils/ui_compilation.py b/scripts/GinanUI/app/utils/ui_compilation.py index 31160d264..090c17afd 100644 --- a/scripts/GinanUI/app/utils/ui_compilation.py +++ b/scripts/GinanUI/app/utils/ui_compilation.py @@ -39,8 +39,9 @@ def compile_ui(): lines = f.readlines() for i, line in enumerate(lines): if line == "import ginan_logo_rc\n": - lines[i] = "from scripts.GinanUI.app.resources import ginan_logo_rc" - break + lines[i] = "from scripts.GinanUI.app.resources.assets import ginan_logo_rc\n" + if line == "import icons_rc\n": + lines[i] = "from scripts.GinanUI.app.resources.assets import icons_rc\n" with open(output_file, 'w') as f: f.writelines(lines) diff --git a/scripts/GinanUI/app/utils/workers.py b/scripts/GinanUI/app/utils/workers.py index 22bcbfbd5..1ebb54f3b 100644 --- a/scripts/GinanUI/app/utils/workers.py +++ b/scripts/GinanUI/app/utils/workers.py @@ -7,7 +7,7 @@ import pandas as pd from PySide6.QtCore import QObject, Signal, Slot -from scripts.GinanUI.app.models.dl_products import get_product_dataframe, download_products, get_brdc_urls, METADATA, download_metadata +from scripts.GinanUI.app.models.dl_products import get_product_dataframe_with_repro3_fallback, download_products, get_brdc_urls, download_metadata, get_provider_constellations, get_bia_code_priorities_for_selection from scripts.GinanUI.app.utils.common_dirs import INPUT_PRODUCTS_PATH from scripts.GinanUI.app.utils.logger import Logger @@ -28,7 +28,7 @@ def __init__(self, execution): @Slot() def stop(self): try: - Logger.terminal("🛑 Stop requested — terminating PEA...") + Logger.terminal("🛑 Stop requested - terminating PEA...") # recommended to implement stop_all() in Execution to terminate child processes if hasattr(self.execution, "stop_all"): self.execution.stop_all() @@ -58,8 +58,10 @@ class DownloadWorker(QObject): :param analysis_centers: Set to true to retrieve valid analysis centers, ensure start and end date specified """ finished = Signal(object) + cancelled = Signal() progress = Signal(str, int) atx_downloaded = Signal(str) + constellation_info = Signal(dict) # Emits provider to constellations mapping def __init__(self, start_epoch: Optional[datetime]=None, end_epoch: Optional[datetime]=None, download_dir: Path=INPUT_PRODUCTS_PATH, products: pd.DataFrame=pd.DataFrame(), analysis_centers=False): @@ -82,15 +84,56 @@ def run(self): if self.analysis_centers: if not self.start_epoch and not self.end_epoch: Logger.terminal(f"📦 No start and/or end date, can't check valid analysis centers") + self.cancelled.emit() return Logger.terminal(f"📦 Retrieving valid products") try: - valid_products = get_product_dataframe(self.start_epoch, self.end_epoch) + # Check if stop was requested before starting + if self._stop: + Logger.terminal(f"📦 Analysis centres retrieval cancelled") + self.cancelled.emit() + return + # Use the repro3 fallback function which automatically checks repro3 + # if no valid PPP providers are found in the main directory + valid_products = get_product_dataframe_with_repro3_fallback(self.start_epoch, self.end_epoch) + # Check again before emitting result - don't emit finished if cancelled + if self._stop: + Logger.terminal(f"📦 Analysis centres retrieval cancelled") + self.cancelled.emit() + return + + # Fetch constellation information for each provider + if not valid_products.empty: + Logger.terminal(f"📡 Fetching constellation information from SP3 headers...") + + def check_stop(): + return self._stop + + provider_constellations = get_provider_constellations( + valid_products, + progress_callback=self.progress.emit, + stop_requested=check_stop + ) + + if self._stop: + Logger.terminal(f"📦 Analysis centres retrieval cancelled") + self.cancelled.emit() + return + + # Emit constellation info before finished + if provider_constellations: + self.constellation_info.emit(provider_constellations) + self.finished.emit(valid_products) except Exception as e: + if self._stop: + Logger.terminal(f"📦 Analysis centres retrieval cancelled") + self.cancelled.emit() + return tb = traceback.format_exc() Logger.terminal(f"⚠️ Error whilst retrieving valid products:\n{tb}") Logger.terminal(f"⚠️ {e}") + self.cancelled.emit() return # 2. Install metadata @@ -101,6 +144,7 @@ def run(self): tb = traceback.format_exc() Logger.terminal(f"⚠️ Error whilst downloading metadata:\n{tb}") Logger.terminal(f"⚠️ {e}") + self.cancelled.emit() return self.finished.emit("📦 Downloaded metadata successfully.") @@ -118,11 +162,88 @@ def check_stop(): pass except RuntimeError as e: Logger.terminal(f"⚠️ {e}") + self.cancelled.emit() return except Exception as e: tb = traceback.format_exc() Logger.terminal(f"⚠️ Error whilst downloading products:\n{tb}") Logger.terminal(f"⚠️ {e}") + self.cancelled.emit() return - self.finished.emit("📦 Downloaded all products successfully.") \ No newline at end of file + if self._stop: + self.cancelled.emit() + return + + self.finished.emit("📦 Downloaded all products successfully.") + +class BiasProductWorker(QObject): + """ + Downloads and parses .BIA file for a specific PPP provider / series / project combination + to extract available code priorities per constellation. + + :param products_df: Products dataframe from get_product_dataframe_with_repro3_fallback() + :param provider: Analysis centre (e.g., 'COD', 'GRG') + :param series: Solution type (e.g., 'FIN', 'RAP') + :param project: Project code (e.g., 'OPS', 'MGX') + """ + finished = Signal(dict) # Emits code priorities dict: {'GPS': {'L1C', ...}, ...} + error = Signal(str) # Emits error message string + progress = Signal(str, int) # Emits (description, percent) for progress updates + + def __init__(self, products_df: pd.DataFrame, provider: str, series: str, project: str): + super().__init__() + self.products_df = products_df + self.provider = provider + self.series = series + self.project = project + self._stop = False + + @Slot() + def stop(self): + self._stop = True + + @Slot() + def run(self): + try: + # Check if stop was requested before starting + if self._stop: + Logger.console(f"📦 BIA fetch cancelled") + self.error.emit("BIA fetch cancelled") + return + + Logger.console(f"📦 Fetching BIA code priorities for {self.provider}/{self.series}/{self.project}...") + + def check_stop(): + return self._stop + + # Download and parse BIA file + code_priorities = get_bia_code_priorities_for_selection( + self.products_df, + self.provider, + self.series, + self.project, + progress_callback=self.progress.emit, + stop_requested=check_stop + ) + + # Check again after download + if self._stop: + Logger.console(f"📦 BIA fetch cancelled") + self.error.emit("BIA fetch cancelled") + return + + if code_priorities is None: + self.error.emit(f"Failed to fetch BIA data for {self.provider}/{self.series}/{self.project}") + return + + # Emit successful result + self.finished.emit(code_priorities) + + except Exception as e: + if self._stop: + self.error.emit("BIA fetch cancelled") + return + tb = traceback.format_exc() + Logger.console(f"⚠️ Error fetching BIA code priorities:\n{tb}") + self.error.emit(f"Error fetching BIA: {e}") \ No newline at end of file diff --git a/scripts/GinanUI/app/views/main_window.ui b/scripts/GinanUI/app/views/main_window.ui index 1c54b0ce3..968bf6a57 100644 --- a/scripts/GinanUI/app/views/main_window.ui +++ b/scripts/GinanUI/app/views/main_window.ui @@ -14,8 +14,14 @@ GINAN GNSS Processing - - + + + 0 + 0 + + + + @@ -26,7 +32,7 @@ - :/img/ginan-logo.png + :/img/ginan-logo.png true @@ -35,6 +41,12 @@ + + + 0 + 0 + + 16 @@ -48,8 +60,11 @@ + + true + - + 0 0 @@ -73,24 +88,51 @@ text-align: right; - Ginan-UI v4.0.0 + Ginan-UI v4.1.0 - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTop|Qt::AlignmentFlag::AlignTrailing + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - + + + + + 0 + 0 + + + + Qt::Orientation::Horizontal + + + 16 + + + false + + + + + 1 + 0 + + + + + 360 + 0 + + + QLayout::SizeConstraint::SetDefaultConstraint - + @@ -105,6 +147,9 @@ 40 + + PointingHandCursor + QPushButton { background-color: rgb(24, 24, 24); @@ -147,6 +192,9 @@ QPushButton:disabled { 16777215 + + PointingHandCursor + QPushButton { background-color: rgb(24, 24, 24); @@ -172,256 +220,181 @@ QPushButton:disabled { - + - + 0 0 + + + 12 + false + false + false + PreferDefault + true + Medium + + + + false + - background-color: rgb(24, 24, 24); -color: rgb(255, 255, 255); + QTabWidget::pane { + border: none; + background-color: #2c5d7c; + } + + QTabBar { + background-color: transparent; + alignment: left; + } + + QTabBar::tab { + background-color: #1a3a4d; + color: white; + padding: 8px 16px; + margin-right: 2px; + border: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + min-width: 60px; + } + + QTabBar::tab:selected { + background-color: #2c5d7c; + color: white; + font-weight: bold; + } + + QTabBar::tab:hover:!selected { + background-color: #234a5f; + } + + QTabBar::tab:!selected { + margin-top: 2px; + } - - QFrame::Shape::NoFrame + + QTabWidget::TabPosition::North - - - QLayout::SizeConstraint::SetDefaultConstraint - - - 5 - - - 5 - - - 5 - - - 5 - - - 8 - - - 10 + + QTabWidget::TabShape::Rounded + + + 0 + + + + 16 + 16 + + + + Qt::TextElideMode::ElideNone + + + false + + + false + + + false + + + + background-color: rgb(24, 24, 24); color: rgb(255, 255, 255); - - - - - 0 - 0 - - - - false - - - Receiver Type - - - - - - - - 0 - 0 - - - - false - - - background:transparent;border:none; - - - Antenna Offset - - - true - - - - - - - Receiver Type - - - - - - - Data Interval - - - - - - - Antenna Offset - - - - - - - - 0 - 0 - - - - false - - - Data interval - - - - - - - PPP Series - - - - - - - - 0 - 0 - - - - false - - - PPP Series - - - - - - - - 0 - 0 - - - - false - - - Open Yaml config in editor - - - - - - - - 0 - 0 - - - - QPushButton { + + General + + + + QLayout::SizeConstraint::SetDefaultConstraint + + + 5 + + + 5 + + + 5 + + + 5 + + + 8 + + + 10 + + + + + + 0 + 0 + + + + false + + + PPP Provider + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QComboBox { background-color: #2c5d7c; color: white; padding: 0px 8px; font: 13pt "Segoe UI"; text-align: left; } -QPushButton:hover { +QComboBox:hover { background-color: #214861; } -QPushButton:pressed { - background-color: #1a3649; -} -QPushButton:disabled { +QComboBox:disabled { background-color: rgb(120, 120, 120); } - - - 0.0, 0.0, 0.0 - - - - - - - - 0 - 0 - - - - false - - - Time Window - - - - - - - Time Window - - - - - - - - 0 - 0 - - - - false - - - - - - - Antenna Type - - - - - - - - 0 - 0 - - - - false - - - PPP Provider - - - - - - - - 0 - 0 - - - - QComboBox { + + + + Select one + + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QComboBox { background-color: #2c5d7c; color: white; padding: 0px 8px; @@ -434,72 +407,147 @@ QComboBox:hover { QComboBox:disabled { background-color: rgb(120, 120, 120); } - - + + + + Select one + + + + + + + + + 0 + 0 + + + + false + - Select one + Data interval - - - - - - - PPP Provider - - - - - - - - 0 - 0 - - - - QPushButton { + + + + + + + 0 + 0 + + + + false + + + background:transparent;border:none; + + + Antenna Offset + + + true + + + + + + + false + + + Constellations + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QComboBox { background-color: #2c5d7c; color: white; padding: 0px 8px; font: 13pt "Segoe UI"; text-align: left; } -QPushButton:hover { +QComboBox:hover { background-color: #214861; } -QPushButton:pressed { - background-color: #1a3649; -} -QPushButton:disabled { +QComboBox:disabled { background-color: rgb(120, 120, 120); } - - - Interval (Seconds) - - - - - - - false - - - Static - - - - - - - - 0 - 0 - - - - QComboBox { + + + + Select one + + + + + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + PPP Project + + + + + + + PPP Provider + + + + + + + + 0 + 0 + + + + false + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QComboBox { background-color: #2c5d7c; color: white; padding: 0px 8px; @@ -512,92 +560,101 @@ QComboBox:hover { QComboBox:disabled { background-color: rgb(120, 120, 120); } - - + + + + Select one + + + + + + - Import text + PPP Series - - - - - - - - 0 - 0 - - - - QComboBox { + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QPushButton { background-color: #2c5d7c; color: white; padding: 0px 8px; font: 13pt "Segoe UI"; text-align: left; } -QComboBox:hover { +QPushButton:hover { background-color: #214861; } -QComboBox:disabled { +QPushButton:pressed { + background-color: #1a3649; +} +QPushButton:disabled { background-color: rgb(120, 120, 120); } - - + - Import text + Interval (Seconds) - - - - - - - - 0 - 0 - - - - QPushButton { + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QComboBox { background-color: #2c5d7c; color: white; - padding: 2px 8px; + padding: 0px 8px; font: 13pt "Segoe UI"; - text-align: center; + text-align: left; } -QPushButton:hover { +QComboBox:hover { background-color: #214861; } -QPushButton:pressed { - background-color: #1a3649; -} -QPushButton:disabled { +QComboBox:disabled { background-color: rgb(120, 120, 120); } - - - Show Config - - - - - - - Constellations - - - - - - - - 0 - 0 - - - - QComboBox { + + + + Select one or more + + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QComboBox { background-color: #2c5d7c; color: white; padding: 0px 8px; @@ -610,24 +667,27 @@ QComboBox:hover { QComboBox:disabled { background-color: rgb(120, 120, 120); } - - - - Select one or more - - - - - - - - 0 - 0 - - - - QPushButton { + + + Import text + + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QPushButton { background-color: #2c5d7c; color: white; padding: 0px 8px; @@ -643,145 +703,884 @@ QPushButton:pressed { QPushButton:disabled { background-color: rgb(120, 120, 120); } - - - Start / End - - - - - - - false - - - Constellations - - - - - - - - 0 - 0 - - - - Mode - - - - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - PPP Project - - - - - - - - 0 - 0 - - - - QComboBox { + + + Start / End + + + + + + + + 0 + 0 + + + + false + + + Receiver Type + + + + + + + + 0 + 0 + + + + false + + + PPP Series + + + + + + + Constellations + + + + + + + Antenna Offset + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QComboBox { background-color: #2c5d7c; color: white; padding: 0px 8px; font: 13pt "Segoe UI"; text-align: left; } -QComboBox:hover { - background-color: #214861; +QComboBox:hover { + background-color: #214861; +} +QComboBox:disabled { + background-color: rgb(120, 120, 120); +} + + + + Import text + + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QPushButton { + background-color: #2c5d7c; + color: white; + padding: 0px 8px; + font: 13pt "Segoe UI"; + text-align: left; +} +QPushButton:hover { + background-color: #214861; +} +QPushButton:pressed { + background-color: #1a3649; +} +QPushButton:disabled { + background-color: rgb(120, 120, 120); +} + + + 0.0, 0.0, 0.0 + + + + + + + + 0 + 0 + + + + Mode + + + + + + + false + + + Static + + + + + + + + 0 + 0 + + + + false + + + Time Window + + + + + + + Antenna Type + + + + + + + Time Window + + + + + + + Receiver Type + + + + + + + Data Interval + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QPushButton { + background-color: #2c5d7c; + color: white; + padding: 2px 8px; + font: 13pt "Segoe UI"; + text-align: center; +} +QPushButton:hover { + background-color: #214861; +} +QPushButton:pressed { + background-color: #1a3649; +} +QPushButton:disabled { + background-color: rgb(120, 120, 120); +} + + + Reset Config + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QPushButton { + background-color: #2c5d7c; + color: white; + padding: 2px 8px; + font: 13pt "Segoe UI"; + text-align: center; +} +QPushButton:hover { + background-color: #214861; +} +QPushButton:pressed { + background-color: #1a3649; +} +QPushButton:disabled { + background-color: rgb(120, 120, 120); +} + + + Show Config + + + + + + + + background-color: rgb(24, 24, 24); color: rgb(255, 255, 255); + + + Constellations + + + + QLayout::SizeConstraint::SetDefaultConstraint + + + 5 + + + 5 + + + 5 + + + 5 + + + 8 + + + 10 + + + + + true + + + + 0 + 0 + + + + + 12 + + + + BDS (BeiDou) + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + + + + 11 + + + + background-color: #2c5d7c; + color: white; + + + true + + + QAbstractItemView::DragDropMode::DragDrop + + + Qt::DropAction::MoveAction + + + QAbstractItemView::SelectionMode::ExtendedSelection + + + QAbstractItemView::SelectionBehavior::SelectItems + + + + + + + + 11 + + + + background-color: #2c5d7c; + color: white; + + + true + + + QAbstractItemView::DragDropMode::DragDrop + + + Qt::DropAction::MoveAction + + + QAbstractItemView::SelectionMode::ExtendedSelection + + + QAbstractItemView::SelectionBehavior::SelectItems + + + + + + + true + + + + 0 + 0 + + + + + 12 + + + + GLO (GLONASS) + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + + + true + + + + 0 + 0 + + + + + 12 + + + + QZS (Quasi-Zenith Satellite System) + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + + + + 11 + + + + background-color: #2c5d7c; + color: white; + + + true + + + QAbstractItemView::DragDropMode::DragDrop + + + Qt::DropAction::MoveAction + + + QAbstractItemView::SelectionMode::ExtendedSelection + + + QAbstractItemView::SelectionBehavior::SelectItems + + + + + + + + 11 + + + + background-color: #2c5d7c; + color: white; + + + true + + + QAbstractItemView::DragDropMode::DragDrop + + + Qt::DropAction::MoveAction + + + QAbstractItemView::SelectionMode::ExtendedSelection + + + QAbstractItemView::SelectionBehavior::SelectItems + + + + + + + true + + + + 0 + 0 + + + + + 12 + + + + GAL (Galileo) + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + + + + 11 + + + + background-color: #2c5d7c; + color: white; + + + true + + + QAbstractItemView::DragDropMode::DragDrop + + + Qt::DropAction::MoveAction + + + QAbstractItemView::SelectionMode::ExtendedSelection + + + QAbstractItemView::SelectionBehavior::SelectItems + + + + + + + true + + + + 0 + 0 + + + + + 12 + + + + GPS (Global Navigation System) + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + + + + background-color: rgb(24, 24, 24); color: rgb(255, 255, 255); + + + Output + + + + QLayout::SizeConstraint::SetDefaultConstraint + + + 5 + + + 5 + + + 5 + + + 5 + + + 8 + + + 10 + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Expanding + + + + 20 + 120 + + + + + + + + true + + + + 0 + 0 + + + + + 13 + true + + + + color: #bfbfbf; font-size: 13pt; + + + Select which files you would like Ginan to output + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + true + + + + + + + 5 + + + 5 + + + 5 + + + 5 + + + 24 + + + 30 + + + + + + 0 + 0 + + + + + 12 + + + + .TRACE + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + QCheckBox::indicator { + width: 36px; + height: 36px; +} + +QCheckBox::indicator:unchecked { + image: url(:/icon/checkbox_unselected.png); +} + +QCheckBox::indicator:unchecked:hover { + + image: url(:/icon/checkbox_unselected_pressed.png); +} + +QCheckBox::indicator:checked { + image: url(:/icon/checkbox_selected.png); +} + +QCheckBox::indicator:checked:hover { + image: url(:/icon/checkbox_selected_hover.png); +} + +QCheckBox::indicator:checked:disabled { + image: url(:/icon/checkbox_selected_disabled.png); +} + +QCheckBox::indicator:unchecked:disabled { + image: url(:/icon/checkbox_unselected_disabled.png); +} + + + + + + true + + + + + + + + 0 + 0 + + + + + 12 + + + + .POS + + + + + + + + 0 + 0 + + + + + 12 + + + + .GPX + + + + + + + true + + + + 0 + 0 + + + + PointingHandCursor + + + QCheckBox::indicator { + width: 36px; + height: 36px; +} + +QCheckBox::indicator:unchecked { + image: url(:/icon/checkbox_unselected.png); +} + +QCheckBox::indicator:unchecked:hover { + + image: url(:/icon/checkbox_unselected_pressed.png); +} + +QCheckBox::indicator:checked { + image: url(:/icon/checkbox_selected.png); } -QComboBox:disabled { - background-color: rgb(120, 120, 120); -} - - - - Select one - - - - - - - - - 0 - 0 - - - - QComboBox { - background-color: #2c5d7c; - color: white; - padding: 0px 8px; - font: 13pt "Segoe UI"; - text-align: left; + +QCheckBox::indicator:checked:hover { + image: url(:/icon/checkbox_selected_hover.png); } -QComboBox:hover { - background-color: #214861; + +QCheckBox::indicator:checked:disabled { + image: url(:/icon/checkbox_selected_disabled.png); } -QComboBox:disabled { - background-color: rgb(120, 120, 120); + +QCheckBox::indicator:unchecked:disabled { + image: url(:/icon/checkbox_unselected_disabled.png); } - - - - Select one - - - - - - - - - 0 - 0 - - - - QComboBox { - background-color: #2c5d7c; - color: white; - padding: 0px 8px; - font: 13pt "Segoe UI"; - text-align: left; + + + + + + + + + + true + + + + 0 + 0 + + + + PointingHandCursor + + + QCheckBox::indicator { + width: 36px; + height: 36px; } -QComboBox:hover { - background-color: #214861; + +QCheckBox::indicator:unchecked { + image: url(:/icon/checkbox_unselected.png); } -QComboBox:disabled { - background-color: rgb(120, 120, 120); + +QCheckBox::indicator:unchecked:hover { + + image: url(:/icon/checkbox_unselected_pressed.png); +} + +QCheckBox::indicator:checked { + image: url(:/icon/checkbox_selected.png); +} + +QCheckBox::indicator:checked:hover { + image: url(:/icon/checkbox_selected_hover.png); +} + +QCheckBox::indicator:checked:disabled { + image: url(:/icon/checkbox_selected_disabled.png); +} + +QCheckBox::indicator:unchecked:disabled { + image: url(:/icon/checkbox_unselected_disabled.png); } - - + + + + + + true + + + + + + + + + true + + + + 0 + 0 + + + + + 12 + + - Select one + Output File Generation - - - - + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + @@ -798,6 +1597,9 @@ QComboBox:disabled { 40 + + PointingHandCursor + QPushButton { color: black; @@ -822,6 +1624,9 @@ QPushButton:disabled { + + PointingHandCursor + Qt::LayoutDirection::LeftToRight @@ -839,16 +1644,34 @@ QPushButton:disabled { background-color: rgb(120,120,120); } - - - + + + + + 4 + 0 + + + + + 500 + 0 + + + + + 6 + Qt::Orientation::Horizontal + + QSizePolicy::Policy::Expanding + 40 @@ -857,19 +1680,47 @@ QPushButton:disabled { background-color: rgb(120,120,120); } + + + + PointingHandCursor + + + false + + + + + + + + + + 32 + 32 + + + + false + + + - 150 + 180 30 + + PointingHandCursor + QPushButton { background-color: #2c5d7c; color: white; - padding: 2px 8px; + padding: 2px 12px; font: 13pt "Segoe UI"; text-align: center; } @@ -891,96 +1742,151 @@ QPushButton:disabled { - + 0 0 - - - 870 - 0 - - - - - 12 - false - false - false - PreferDefault - true - Medium - - - - false - - - background-color:#2c5d7c;color:white; - - - QTabWidget::TabPosition::North - - - QTabWidget::TabShape::Rounded - - - 0 - - - - 16 - 16 - - - - Qt::TextElideMode::ElideNone - - - false + + Qt::Orientation::Vertical - - false + + 16 - + false - + + + + 0 + 1 + + + + + 0 + 150 + + + + + 12 + false + false + false + PreferDefault + true + Medium + + + + false + - background-color:#2c5d7c;color:white; + QTabWidget::pane { + border: none; + background-color: #2c5d7c; + } + + QTabBar { + background-color: transparent; + alignment: left; + } + + QTabBar::tab { + background-color: #1a3a4d; + color: white; + padding: 8px 16px; + margin-right: 2px; + border: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + min-width: 60px; + } + + QTabBar::tab:selected { + background-color: #2c5d7c; + color: white; + font-weight: bold; + } + + QTabBar::tab:hover:!selected { + background-color: #234a5f; + } + + QTabBar::tab:!selected { + margin-top: 2px; + } - - Workflow - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 + + QTabWidget::TabPosition::North + + + QTabWidget::TabShape::Rounded + + + 0 + + + + 16 + 16 + + + + Qt::TextElideMode::ElideNone + + + false + + + false + + + false + + + + background-color:#2c5d7c;color:white; - - - - background-color:#2c5d7c;color:white; - - - true - - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + + Workflow + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + background-color:#2c5d7c;color:white; + + + true + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> <html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> p, li { white-space: pre-wrap; } hr { height: 1px; border-width: 0; } @@ -988,22 +1894,78 @@ li.unchecked::marker { content: "\2610"; } li.checked::marker { content: "\2612"; } </style></head><body style=" font-family:'Ubuntu'; font-size:10pt; font-weight:400; font-style:normal;"> <p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'.AppleSystemUIFont'; font-size:13pt;">Workflow Terminal</span></p></body></html> - - - - - - - - background-color: rgb(24, 24, 24); + + + + + + + + background-color: rgb(24, 24, 24); +color:white; + + + Console + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + background-color: rgb(24, 24, 24); color:white; + + + true + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> +p, li { white-space: pre-wrap; } +hr { height: 1px; border-width: 0; } +li.unchecked::marker { content: "\2610"; } +li.checked::marker { content: "\2612"; } +</style></head><body style=" font-family:'Ubuntu'; font-size:10pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'.AppleSystemUIFont'; font-size:13pt;">PEA Console Log</span></p></body></html> + + + + + + + + + + 0 + 2 + - - Console - - + + + 0 + 200 + + + - 0 + 6 0 @@ -1018,65 +1980,46 @@ color:white; 0 - + - + + 14 + true + - - background-color: rgb(24, 24, 24); -color:white; + + Visualisation - - true + + + + + + + 0 + 0 + - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> -p, li { white-space: pre-wrap; } -hr { height: 1px; border-width: 0; } -li.unchecked::marker { content: "\2610"; } -li.checked::marker { content: "\2612"; } -</style></head><body style=" font-family:'Ubuntu'; font-size:10pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'.AppleSystemUIFont'; font-size:13pt;">PEA Console Log</span></p></body></html> + + + about:blank + - - - - - - - - - 14 - true - - - - Visualisation - - - - - - - - 0 - 0 - - - - - about:blank - - - - - - - - QPushButton { + + + + + 0 + 0 + + + + PointingHandCursor + + + QPushButton { background-color: #2c5d7c; color: white; padding: 2px 8px; @@ -1092,16 +2035,19 @@ QPushButton:pressed { QPushButton:disabled { background-color: rgb(120, 120, 120); } - - - Open in Browser - - - - - - - QComboBox { + + + Open in Browser + + + + + + + PointingHandCursor + + + QComboBox { background-color: #2c5d7c; color: white; padding: 0px 8px; @@ -1125,12 +2071,16 @@ QComboBox QAbstractItemView { selection-color: white; } - + + + + + - - + + @@ -1149,23 +2099,23 @@ QComboBox QAbstractItemView { outputButton terminalTextEdit consoleTextEdit - Mode - Constellations_2 + modeCombo + constellationsCombo timeWindowButton dataIntervalButton - Receiver_type - Antenna_type + receiverTypeCombo + antennaTypeCombo antennaOffsetButton - PPP_provider - PPP_series - PPP_project - showConfigButton + pppProviderCombo + pppSeriesCombo + pppProjectCombo processButton stopAllButton - tabWidget + logTabWidget - + + diff --git a/scripts/GinanUI/docs/USER_GUIDE.md b/scripts/GinanUI/docs/USER_MANUAL.md similarity index 77% rename from scripts/GinanUI/docs/USER_GUIDE.md rename to scripts/GinanUI/docs/USER_MANUAL.md index 9e5469e89..c8f24d263 100644 --- a/scripts/GinanUI/docs/USER_GUIDE.md +++ b/scripts/GinanUI/docs/USER_MANUAL.md @@ -1,8 +1,8 @@ # Ginan-UI ## User Manual ### This guide is written to aid those using the Ginan-UI extension software. -### Version: Release 1.0 -### Last Updated: 12th December 2025 +### Version: Release v4.1.0 +### Last Updated: 22nd January 2026 ## 1. Introduction @@ -109,33 +109,33 @@ When you open Ginan-UI for the first time, you will be taken to the main dashboa ![Dashboard of Ginan-UI](./images/ginan_ui_dashboard.jpg) -To use Ginan-UI, you will require an account with NASA's CDDIS EarthData archives. Once you have created an account here, you can log in by clicking the “CDDIS Credentials” button in the top-right of Ginan-UI: +To use Ginan-UI, you will require an account with NASA's CDDIS EarthData archives. Once you have created an account here, you can log in by clicking the "CDDIS Credentials" button in the top-right of Ginan-UI:

CDDIS Credentials button in the top-right

!["CDDIS Credentials" button highlighted](./images/cddis_credentials_button.jpg) -Then, enter your CDDIS credentials and click “Save”. +Then, enter your CDDIS credentials and click "Save".

Type in your CDDIS login credentials here

![CDDIS Credentials screen displaying username and password fields](./images/cddis_credentials_screen.jpg) -Next, click the “Observations” button in the top-left and select the RINEX observation data file you want to process. Afterward, click the adjacent “Output” button and choose an output location for where Ginan will store its results after processing. +Next, click the "Observations" button in the top-left and select the RINEX observation data file you want to process. Afterward, click the adjacent "Output" button and choose an output location for where Ginan will store its results after processing.

Select your RINEX observation file and your output location

!["Observations" and "Output" buttons highlighted](./images/observations_output_buttons.jpg) -Once your RINEX observation file is set, most of the UI fields should autofill based on extracted data from the RINEX file, however the user still needs to set the “Mode” parameter. This defines how much noise should be expected in the data (i.e. “Static” = stationary GNSS receiver, “Kinematic” = a moving car, “Dynamic” = a moving plane). Set this field now. +Once your RINEX observation file is set, most of the UI fields should autofill based on extracted data from the RINEX file, however the user still needs to set the "Mode" parameter. This defines how much noise should be expected in the data (i.e. "Static" = stationary GNSS receiver, "Kinematic" = a moving car, "Dynamic" = a moving plane). Set this field now. -More experienced users may recognise this parameter as the `estimation_parameters.receivers.global.pos.process_noise` config value. By default, “Static” = 0, “Kinematic” = 30, and “Dynamic” = 100. +More experienced users may recognise this parameter as the `estimation_parameters.receivers.global.pos.process_noise` config value. By default, "Static" = 0, "Kinematic" = 30, and "Dynamic" = 100.

Select a "Mode" to set the `process_noise`

!["Mode" dropdown showing the options: "Static", "Kinematic", and "Dynamic"](./images/mode_dropdown.jpg) -Once everything is configured and all fields have been autofilled by Ginan-UI, simply click “Process” to start Ginanʼs PEA processing. Ginan-UI will begin downloading the required products from the CDDIS servers for Ginan to process, and then will execute Ginan automatically. Ginanʼs processing progress will be displayed in the accompanying "Console" tab next to the default "Workflow" tab. +Once everything is configured and all fields have been autofilled by Ginan-UI, simply click "Process" to start Ginanʼs PEA processing. Ginan-UI will begin downloading the required products from the CDDIS servers for Ginan to process, and then will execute Ginan automatically. Ginanʼs processing progress will be displayed in the accompanying "Console" tab next to the default "Workflow" tab.

Click "Process" when ready!

@@ -149,7 +149,7 @@ Once everything is configured and all fields have been autofilled by Ginan-UI, s ![PEA processing within the "Console" tab](./images/pea_processing.jpg) -When Ginan finishes processing, you can view the generated position plot within Ginan-UI, or alternatively open the generated HTML output to review the results by clicking “Open in Browser”. +When Ginan finishes processing, you can view the generated position plot within Ginan-UI, or alternatively open the generated HTML output to review the results by clicking "Open in Browser".

Once PEA finishes processing, Ginan-UI will plot the results

@@ -163,23 +163,29 @@ And that is it! Check out Section 6 for more in-depth tooling. ## 4. User Interface Reference +The Ginan-UI interface is divided into two main panels: the left panel for input configuration, and the right panel for monitoring, output, and visualisation. All panels are resizable by dragging the divider between them, allowing you to customise your workspace layout. + ### 4.1 Input Configuration (Left Panel) -The left-hand panel contains all the configuration options required to set up Ginan to commence processing. +The left-hand panel contains all the configuration options required to set up Ginan to commence processing. These options are organised into three tabs: **General**, **Constellations**, and **Output**. + +#### 4.1.1 General Tab + +The General tab contains the primary configuration options for setting up your GNSS processing run. -#### "Observations" Button +##### "Observations" Button - Opens a file picker to select your RINEX v2/v3/v4 observation file (optionally can be compressed). - Ginan-UI will automatically extract metadata from your provided RINEX file, including the time window, available constellations, and receiver and antenna type information. This metadata is then used to autopopulate the several fields below. -#### "Output" Button +##### "Output" Button - Opens a file picker to select where PEA will save its processing results (`.pos`, `.log`, HTML plots). - Remains disabled until a valid "Observations" file has been selected. -#### Mode +##### Mode - **Critical parameter** that must be set by the user. @@ -190,29 +196,29 @@ The left-hand panel contains all the configuration options required to set up Gi - Corresponds to the `estimation_parameters.receivers.global.pos.process_noise` YAML field -#### Constellations +##### Constellations - Drop-down showing GNSS systems detected from your RINEX file. Displays which constellations are available: GPS, GAL (Galileo), GLO (GLONASS), BDS (BeiDou), QZS (QZSS) -#### Time Window +##### Time Window - Displays the detected start and end epochs. - Useful is you only want to process a subset of your observation period -#### Data Interval +##### Data Interval - Set the interval to downsample your observation data (i.e. process every 120 seconds, instead of 30 seconds). -#### Receiver Type / Antenna Type +##### Receiver Type / Antenna Type - Used internally for antenna phase centre corrections -#### Antenna Offset +##### Antenna Offset - View / edit ENU (East-North-Up) offset values. This allows manual adjustment if your antenna has a known offset position from its reference point. -#### PPP Provider / Project / Series +##### PPP Provider / Project / Series - Three drop-downs that filter available products based on the provided time window @@ -226,7 +232,19 @@ The left-hand panel contains all the configuration options required to set up Gi - These fields are populated after a valid observation file has been loaded. -#### "Show Config" Button +##### "Reset Config" Button + +- Resets both the UI and the configuration file back to their default states. + +- The `ppp_generated.yaml` configuration file will be regenerated from the default template. + +- All UI fields will be cleared and returned to their initial placeholder values. + +- This is useful if you have made configuration changes you want to undo, or if you want to start fresh with a new RINEX file without lingering settings from a previous session. + +- A confirmation dialog will appear before the reset proceeds. + +##### "Show Config" Button - A button that opens the generated `ppp_generated.yaml` file in your system's default text editor. @@ -234,6 +252,46 @@ The left-hand panel contains all the configuration options required to set up Gi - See Section 6.1 for more details on manual config editing. +#### 4.1.2 Constellations Tab + +The Constellations tab allows you to manage the observation code priorities for each enabled GNSS constellation on the "General" tab. Code priorities determine which signal types PEA will prefer when processing your data. + +##### How It Works + +When you select a PPP provider, series, and project in the "General" tab, Ginan-UI will automatically retrieve the supported code priorities from the corresponding `.BIA` (bias) product file. These are cross-referenced against the observation codes present in the provided RINEX observation file and the constellations available in the `.SP3` (orbit) product file. + +##### Constellation Panels + +Each constellation (GPS, GAL, GLO, BDS, QZS) has its own panel displaying the available observation codes. Only constellations that are enabled in the "General" tab's "Constellations" field will be shown and configurable. + +- Codes are listed in priority order from top to bottom. + +- You can reorder codes by dragging and dropping to change their priority. + +##### Automatic Validation + +Ginan-UI performs automatic validation to ensure compatibility between the provided RINEX observation data and the selected PPP products: + +- **RINEX vs SP3 verification:** The constellations in the RINEX file are verified against those available in the PPP provider's `.SP3` orbit file. If a constellation in your RINEX file is not supported by the PPP products, it will be displayed but coloured red and be strikethrough. + +- **Code priority detection:** The supported code priorities are automatically detected from the PPP provider's `.BIA` file, ensuring that only valid codes are configured. + +#### 4.1.3 Output Tab + +The Output tab allows you to specify which output files PEA should generate during processing. + +##### Output File Options + +- **POS** (Position file): Contains the computed position solutions. This is the primary output for most users and is enabled by default. + +- **GPX** (GPS Exchange Format): Generates a GPX file compatible with mapping software and GPS devices. Enabled by default. + +- **TRACE** (Trace file): Produces detailed debugging output from PEA processing. Disabled by default. + +##### Visualisation Dependency + +The plot visualisation feature in the right panel depends on the output files being generated. If you disable the POS output, the corresponding position plots will not be available in the Visualisation section after processing completes. + ### 4.2 Monitoring & Output (Right Panel) The right-hand panel contains all the monitoring tools for Ginan-UI's functionality and Ginan's processing, as well as managing your CDDIS credentials. @@ -260,10 +318,12 @@ The right-hand panel contains all the monitoring tools for Ginan-UI's functional #### "Visualisation" Section -- The visualisation panel displays an interactive HTML plot that is generated using the `plot_pos.py` script after PEA completes its processing. It allows the user to view, pan, zoom, hover over tooltips and toggle legends. +- The visualisation panel displays an interactive HTML plot that is generated using the `plot_pos.py` script after PEA completes its processing. It allows the user to view, pan, zoom, hover over tooltips, and toggle legends. - Below the visualisation panel, the user can choose to open the plot in their system's default web-browser, or switch between the other generated plots. +- **Note:** Plot visualisation is only available when the corresponding output file type is enabled in the Output tab. For example, position plots require the POS output to be enabled. + ### 4.3 Process Control #### "Process" Button @@ -338,6 +398,12 @@ Ginan-UI will automatically determine which dynamic products you need based on: 3. The constellations present in your data (GPS, GLONASS, Galileo, BeiDou, QZSS) +#### REPRO3 Fallback for Older Data + +For older RINEX files (typically more than three years old), the standard PPP products may not be available in the main CDDIS directory. In these cases, Ginan-UI will automatically search the REPRO3 (third IGS reprocessing campaign) directory for reproduction products. These reprocessed products provide high-quality orbits, clocks, and biases for historical data that may otherwise be unavailable. + +When REPRO3 products are used, you will see a notification in the Workflow log indicating that the fallback occurred. + #### Download Process When you click "Process", Ginan-UI will: @@ -443,7 +509,13 @@ All other parameters like processing strategies, filter settings, quality contro #### Resetting to Default -If you experience any configuration errors and want to start fresh: +If you experience any configuration errors and want to start fresh, you have two options: + +**Option 1: Use the Reset Config Button** + +Click the "Reset Config" button in the General tab. This will regenerate the configuration file from the default template and reset all UI fields to their initial state. A confirmation dialog will appear before the reset proceeds. + +**Option 2: Manual Reset** 1. Delete `scripts/GinanUI/app/resources/ppp_generated.yaml` @@ -466,9 +538,11 @@ If you experience any configuration errors and want to start fresh: | Program crash when clicking "Process" | "`Core dump whilst thread ''`" occurs when the user uses the "Stop" button before the first download has started and subsequently clicked the process button again before the thread has a chance to exit. | The thread cannot exit whilst raising a request for status. Wait a few seconds for the "stopped thread" message in the Console before clicking Process again. | | Connection reset errors | CDDIS server timeouts and / or network problems. | Wait 30 seconds and then try again. If the issue persists, check your network connection. Note: CDDIS servers experienced reliability issues during the 2025 US government shutdown. | | CDDIS authentication failed | Invalid or expired Earthdata credentials, or credentials not properly saved to `.netrc` / `_netrc` file. | Re-enter your credentials via the "CDDIS Credentials" button. Verify your account is active at [Earthdata Login](https://urs.earthdata.nasa.gov). Check that `.netrc` or `_netrc` exists in your home directory with correct permissions (0600 on Linux/macOS). | -| PEA configuration error / YAML syntax error | Manual edits to the YAML config contain syntax errors (incorrect indentation, mismatched quotes, malformed lists). | Verify your YAML formatting in the config editor. If errors persist, delete `ppp_generated.yaml` to reset to default template (see Section 6.1). | -| Plots not appearing in Visualisation panel | PEA processing failed before generating `.pos` files, or `plot_pos.py` script encountered errors. | Check the Console for PEA errors. Verify that `.pos` files exist in your output directory. If files exist but plots don't render, check for Qt WebEngine issues in the Console. | +| PEA configuration error / YAML syntax error | Manual edits to the YAML config contain syntax errors (incorrect indentation, mismatched quotes, malformed lists). | Verify your YAML formatting in the config editor. If errors persist, use the "Reset Config" button or delete `ppp_generated.yaml` to reset to default template (see Section 6.1). | +| Plots not appearing in Visualisation panel | PEA processing failed before generating `.pos` files, the `plot_pos.py` script encountered errors, or the POS output is disabled in the Output tab. | Check the Console for PEA errors. Verify that `.pos` files exist in your output directory. Ensure POS output is enabled in the Output tab. If files exist but plots don't render, check for Qt WebEngine issues in the Console. | | Disk space errors during processing | Insufficient disk space for downloading products or writing PEA outputs. | Free up disk space. Products can consume several GB depending on time window and number of constellations. Check available space in both the products directory and your selected output directory. | +| Constellation mismatch warning | The constellations in the provided RINEX file do not match those available in the selected PPP provider's SP3 file. | Select a different PPP provider that supports the constellations in your RINEX file, or disable the unsupported constellations in the "General" config tab's "Constellations" field. | +| No valid PPP providers found for older data | The RINEX file is from a time period where standard PPP products are no longer available in the main CDDIS directory. | Ginan-UI will automatically attempt to use REPRO3 products for older data. If no providers are found, the data may be too old for available PPP products. | ### 7.2 Log Message Interpretation @@ -544,9 +618,21 @@ Here are some answers to the frequently asked questions: **A:** : The `.yaml` config file used by PEA is in `ginan/scripts/GinanUI/app/resources/ppp_generated.yaml` which can be edited with the "Show Config" button. The template file in `ginan/scripts/GinanUI/app/resources/Yaml/default_config.yaml` is copied and used when no `ppp_generated.yaml` can be found. -**Q:** *"Why is pea giving me a configuration error?"* +**Q:** *"Why is PEA giving me a configuration error?"* + +**A:** This could be due to a product file being deleted erroneously, which would resolve on the next click of the "Process" button, or due to manual changes to the `.yaml` config file. The app **does not overwrite** the `ppp_generated.yaml` file when the `.rnx` file is changed or when the app is restarted. If you wish to reset to the default config, click the "Reset Config" button in the General tab, or delete the file in `ginan/scripts/GinanUI/app/resources/ppp_generated.yaml` and then run the app again. + +**Q:** *"How do I reset the configuration to default?"* + +**A:** Click the "Reset Config" button in the General tab. This will regenerate the configuration file from the default template and clear all UI fields back to their initial state. Alternatively, you can manually delete the `ppp_generated.yaml` file. + +**Q:** *"Why is the plot visualisation disabled or not showing?"* + +**A:** Plot visualisation depends on the corresponding output file being enabled in the "Output" tab. If you have disabled the POS output, the position plots will not be available. Enable the required output type and re-run processing. + +**Q:** *"Can I process older RINEX files?"* -**A:** This could be due to a product file being deleted erroneously, which would resolve on the next click of the "Process" button, or due to manual changes to the `.yaml` config file. The app **does not overwrite** the `ppp_generated.yaml` file when the `.rnx` file is changed or when the app is restarted. If you wish to reset to the default config, delete the file in `ginan/scripts/GinanUI/app/resources/ppp_generated.yaml` and then run the app again. +**A:** Yes. Ginan-UI supports RINEX v2, v3, and v4 files. For older data (typically more than three years old), Ginan-UI will automatically search the REPRO3 directory for reprocessed products if standard products are not available. **Q:** *"Where can I learn more about Ginan itself?"* diff --git a/scripts/GinanUI/docs/images/cddis_credentials_button.jpg b/scripts/GinanUI/docs/images/cddis_credentials_button.jpg index 263fb1370..7e670f196 100644 Binary files a/scripts/GinanUI/docs/images/cddis_credentials_button.jpg and b/scripts/GinanUI/docs/images/cddis_credentials_button.jpg differ diff --git a/scripts/GinanUI/docs/images/cddis_credentials_screen.jpg b/scripts/GinanUI/docs/images/cddis_credentials_screen.jpg index 18e2893a0..a4dcd4e01 100644 Binary files a/scripts/GinanUI/docs/images/cddis_credentials_screen.jpg and b/scripts/GinanUI/docs/images/cddis_credentials_screen.jpg differ diff --git a/scripts/GinanUI/docs/images/ginan_ui_dashboard.jpg b/scripts/GinanUI/docs/images/ginan_ui_dashboard.jpg index d1b746801..c13c37754 100644 Binary files a/scripts/GinanUI/docs/images/ginan_ui_dashboard.jpg and b/scripts/GinanUI/docs/images/ginan_ui_dashboard.jpg differ diff --git a/scripts/GinanUI/docs/images/mode_dropdown.jpg b/scripts/GinanUI/docs/images/mode_dropdown.jpg index 32000fc24..ad1b7efd7 100644 Binary files a/scripts/GinanUI/docs/images/mode_dropdown.jpg and b/scripts/GinanUI/docs/images/mode_dropdown.jpg differ diff --git a/scripts/GinanUI/docs/images/observations_output_buttons.jpg b/scripts/GinanUI/docs/images/observations_output_buttons.jpg index 5705fa989..07119ac3c 100644 Binary files a/scripts/GinanUI/docs/images/observations_output_buttons.jpg and b/scripts/GinanUI/docs/images/observations_output_buttons.jpg differ diff --git a/scripts/GinanUI/docs/images/pea_processing.jpg b/scripts/GinanUI/docs/images/pea_processing.jpg index 151665cd1..013bdf76c 100644 Binary files a/scripts/GinanUI/docs/images/pea_processing.jpg and b/scripts/GinanUI/docs/images/pea_processing.jpg differ diff --git a/scripts/GinanUI/docs/images/plot_visualisation.jpg b/scripts/GinanUI/docs/images/plot_visualisation.jpg index 83c230199..19a935cf7 100644 Binary files a/scripts/GinanUI/docs/images/plot_visualisation.jpg and b/scripts/GinanUI/docs/images/plot_visualisation.jpg differ diff --git a/scripts/GinanUI/docs/images/plot_visualisation_web.jpg b/scripts/GinanUI/docs/images/plot_visualisation_web.jpg index 291bb5691..62786a58c 100644 Binary files a/scripts/GinanUI/docs/images/plot_visualisation_web.jpg and b/scripts/GinanUI/docs/images/plot_visualisation_web.jpg differ diff --git a/scripts/GinanUI/docs/images/process_button.jpg b/scripts/GinanUI/docs/images/process_button.jpg index 686ac3319..118a6e154 100644 Binary files a/scripts/GinanUI/docs/images/process_button.jpg and b/scripts/GinanUI/docs/images/process_button.jpg differ diff --git a/scripts/GinanUI/docs/images/product_downloading.jpg b/scripts/GinanUI/docs/images/product_downloading.jpg index d4622ae6f..42adcaa93 100644 Binary files a/scripts/GinanUI/docs/images/product_downloading.jpg and b/scripts/GinanUI/docs/images/product_downloading.jpg differ diff --git a/scripts/README.md b/scripts/README.md index 7492ff621..d5358df8e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,32 +1,34 @@ # Ginan Scripts -This directory contains a number of useful scripts that facilitate: +This directory contains a number of useful scripts that facilitate: - running Ginan via: - - a graphical user interface (under `scripts/GinanUI`) + - a graphical user interface (under `scripts/GinanUI`) - shell scripts for installing Ginan natively (under `scripts/installation`) - scripts that handle downloading necessary input files (`auto_download_PPP.py`) - plotting Ginan output files, including - POS files (`plot_pos.py`) + - SBAS SPP files (`plot_spp.py`) - ZTD files (`plotting/ztd_plot.py`) + - Network trace files (`plot_trace_res.py`) - exploring and debugging Ginan and it's Kalman filter via: - The Ginan Exploratory Data Analysis (EDA) tool (`scripts/GinanEDA`) -Each sub-directory listed above contains it's own README, which provides further details on running the various functionalities. +Each sub-directory listed above contains it's own README, which provides further details on running the various functionalities. The rest of this README will cover the files located on the `scripts` directory, namely: 1. `auto_download_PPP.py` 2. `plot_pos.py` -3. `plot_trace_res.py ` -4. `s3_filehandler.py` +3. `plot_spp.py` +4. `plot_trace_res.py` -## _**Recommended:**_ +## _**Recommended:**_ Before continuing, it is highly recommended that you create a python virtual environment if you have not already done so as suggested on the root README file: ```bash # Create virtual environment python3 -m venv ginan-env source ginan-env/bin/activate ``` -The above line will the virtual environment in your current working directory. Once the above is complete, you will have the virtual environment in your current working directory. +The above line will the virtual environment in your current working directory. Once the above is complete, you will have the virtual environment in your current working directory. You can then install all python dependencies via a `pip` command: ```bash @@ -38,13 +40,13 @@ The `auto_download_PPP.py` script makes it easier to download the necessary high Based on a few details provided by the user via arguments in the command-line interface (CLI), the script fetches the appropriate files for a given date or date range. These files includes products such as: - - precise orbits (`.SP3`) + - precise orbits (`.SP3`) - broadcast orbits (`BRDC.RNX`) - - precise clocks (`.CLK`) + - precise clocks (`.CLK`) - Earth rotation parameters (`.ERP` or IERS IAU2000 file) - - CORS station positions and metadata (`.SNX`), + - CORS station positions and metadata (`.SNX`) - satellite metadata (`.SNX`) - - code biases (`.BIA`) + - code biases (`.BIA`) The product files are mostly obtained from the NASA archive known as the Crustal Dynamics Data Information System (CDDIS). This is one of NASA's Distributed Active Archive Centers (DAACs). @@ -52,16 +54,16 @@ To use and download from this archive, you will need to create an Earthdata Logi It also includes the various model files needed: - - planetary ephemerides (JPL Development Ephemeris `DE436.1950.2050`), - - atmospheric loading, - - geopotential (Earth Gravitational Model `EGM2008`), - - geomagnetic reference field (International Geomagnetic Reference Field `IGRF14`) - - ocean loading, - - ocean pole tide coefficients, - - ocean tide potential (Finite Element Solution 2014b `FES2014b`), + - planetary ephemerides (JPL Development Ephemeris `DE436.1950.2050`) + - atmospheric loading + - geopotential (Earth Gravitational Model `EGM2008`) + - geomagnetic reference field (International Geomagnetic Reference Field `IGRF14`) + - ocean loading + - ocean pole tide coefficients + - ocean tide potential (Finite Element Solution 2014b `FES2014b`) - troposphere (Global Pressure and Temperature model `GPT2.5`) -These are needed for running PPP. +These are needed for running PPP. ### 1.1 Earthdata Login Credentials - CDDIS Downloads To download product files from the Crustal Dynamics Data Information System (CDDIS) web archive you will need an Earthdata Login account credentials saved to your machine. @@ -119,7 +121,7 @@ This will display the help page with detailed information on all possible arugme ### 1.3 Example Run of "auto_download_PPP" With your virtual environment active, you can now download the product files needed for a PPP run in Ginan. -We will use the `igs-station` preset to download RINEX files for two IGS stations for two days in 2024 together with all the product and model files needed to run this in Ginan. +We will use the `igs-station` preset to download RINEX files for two IGS stations for two days in 2024 together with all the product and model files needed to run this in Ginan. ```bash # Example run of auto_download_PPP: @@ -141,9 +143,9 @@ python auto_download_PPP.py --help ## 2. plot_pos -The `plot_pos.py` script is used to visualise the contents of a Ginan `.POS` format file. +The `plot_pos.py` script is used to visualise the contents of a Ginan `.POS` format file. -Output plots are plotly `.html` files that can be displayed in a web browser +Output plots are plotly `.html` files that can be displayed in a web browser. ```bash Usage: @@ -153,7 +155,7 @@ Plots positional data and uncertainties with optional smoothing and color coding ### Optional arguments: - - `-h`, `--help` show this help message and exit + - `-h`, `--help`: Show this help message and exit - `--input-files` INPUT_FILES ...: One or more input .POS files for plotting (**required**) - `--start-datetime` START_DATETIME: Start datetime in the format YYYY-MM-DDTHH:MM:SS, optional timezone - `--end-datetime` END_DATETIME: End datetime in the format YYYY-MM-DDTHH:MM:SS, optional timezone @@ -171,34 +173,69 @@ Plots positional data and uncertainties with optional smoothing and color coding ### Examples -- Plot a Ginan output .POS file: +- Plot a Ginan output .POS file: ```bash python plot_pos.py --input-files ALIC00AUS_R_20191990000_01D_30S_MO.rnx.POS ``` -- Plot a Ginan output .POS file, using colours to represent uncertainties and a heatmep of horizontal positions: +- Plot a Ginan output .POS file, using colours to represent uncertainties and a heatmap of horizontal positions: ```bash python plot_pos.py --input-files ALIC00AUS_R_20191990000_01D_30S_MO.rnx.POS --colour-sigma --heatmap --elevation ``` -## 3. plot_trace_res +## 3. plot_spp + +The `plot_spp.py` script is used to visualise the contents of a Ginan SBAS `.SPP` format files. + +Output plots are plotly `.html` files that can be displayed in a web browser. + +```bash +Usage: +plot_spp.py [-h] -i INPUT [INPUT ...] [-o OUTPUT] [--title TITLE] [--map] [--pl] +``` +Plot Ginan SBAS .SPP output as Plotly HTML (concatenated inputs). + +### Optional arguments: + + - `-h`, `--help`: Show this help message and exit + - `-i`, `--input` INPUT ...: One or more input .SPP / *_SPP.POS files (**required**) + - `-o`, `--output` OUTPUT: Output .html file path (default: .html) + - `--title` TITLE: Main plot title (default: derived from input filenames) + - `--map`: Also write a separate linked-hover lat/lon + Href HTML. + - `--pl`: Also write a protection-level vs error log/log plot (dH vs HPL, dU vs VPL). + +### Examples -The `plot_trace_res.py` script is used to visualise the contents of a Ginan Network `.TRACE` format file. +- Plot a Ginan SBAS .SPP file: + +```bash +python plot_spp.py -i ALIC-202602303.SPP +``` + +- Concatenate and plot Ginan SBAS .SPP files, and a 2D lon/lat scatter (Eref vs Nref) map of horizontal positions: + +```bash +python3 plot_spp.py -i ALIC-202602303.SPP ALIC-202602304.SPP ALIC-202602305.SPP --map +``` + +## 4. plot_trace_res + +The `plot_trace_res.py` script is used to visualise the contents of a Ginan Network `.TRACE` format file. Extracts and plots GNSS code and phase residuals by receiver and/or satellite with optional markers for large-errors, state errors. Output plots are plotly `.html` files that can be displayed in a web browser ```bash -Usage: -plot_trace_res.py [-h] --files FILES [FILES ...] [--residual {prefit,postfit}] [--receivers RECEIVERS [--sat SAT] [--label-regex LABEL_REGEX] [--max-abs MAX_ABS] [--start START] [--end END] [--decimate DECIMATE] [--split-per-sat | --split-per-recv] [--out-dir OUT_DIR] [--basename BASENAME] [--webgl] [--log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL}] [--out-prefix OUT_PREFIX] [--mark-large-errors] [--hover-unified] [--plot-normalised-res] [--show-stats-table] [--stats-matrix] [--stats-matrix-weighted] [--annotate-stats-matrix] [--mark-amb-resets] [--ambiguity-counts] [--ambiguity-totals] [--amb-totals-orient {h,v}] [--amb-totals-topn AMB_TOTALS_TOPN] [--use-forward-residuals] +Usage: +plot_trace_res.py [-h] --files FILES [FILES ...] [--residual {prefit,postfit}] [--receivers RECEIVERS] [--sat SAT] [--label-regex LABEL_REGEX] [--max-abs MAX_ABS] [--start START] [--end END] [--decimate DECIMATE] [--split-per-sat | --split-per-recv] [--out-dir OUT_DIR] [--basename BASENAME] [--webgl] [--log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL}] [--out-prefix OUT_PREFIX] [--mark-large-errors] [--hover-unified] [--plot-normalised-res] [--show-stats-table] [--stats-matrix] [--stats-matrix-weighted] [--annotate-stats-matrix] [--mark-amb-resets] [--ambiguity-counts] [--ambiguity-totals] [--amb-totals-orient {h,v}] [--amb-totals-topn AMB_TOTALS_TOPN] [--use-forward-residuals] ``` Optional arguments: -- `-h`, `--help` show this help message and exit +- `-h`, `--help`: Show this help message and exit - `--files` FILES [FILES ...]: One or more TRACE files (space or , sep ), e.g. 'A.trace B.trace,C.trace' (wildcards allowed eg. *.TRACE) - `--residual` {prefit,postfit}: Plot prefit or postfit residuals (default: postfit) - `--receivers` RECEIVERS: One or more receiver names (space or , separated), e.g. 'ABMF,CHUR ALGO' @@ -207,7 +244,7 @@ Optional arguments: - `--max-abs` MAX_ABS: Max residual to plot - `--start` START: Start datetime or time-only - `--end` END: End datetime (exclusive) -- `--decimate` DECIMATE: +- `--decimate` DECIMATE: - `--split-per-sat` : - `--split-per-recv` : - `--out-dir` OUT_DIR: Output directory for HTML files; defaults to CWD. @@ -228,5 +265,3 @@ Optional arguments: - `--amb-totals-orient`: {h,v} Orientation for totals bar charts: 'h' (horizontal, default) or 'v' (vertical). - `--amb-totals-topn AMB_TOTALS_TOPN`: Show only the top N receivers/satellites by total resets (to avoid clutter). - `--use-forward-residuals`: Use residuals from forward (non-smoothed) files instead of smoothed files (default: use smoothed for more accurate residuals). - -## 4. s3_filehandler diff --git a/scripts/s3_filehandler.py b/scripts/deprecated_scripts/s3_filehandler.py similarity index 100% rename from scripts/s3_filehandler.py rename to scripts/deprecated_scripts/s3_filehandler.py diff --git a/scripts/plot_pos.py b/scripts/plot_pos.py index bd28f1b47..50bb74d57 100644 --- a/scripts/plot_pos.py +++ b/scripts/plot_pos.py @@ -7,6 +7,7 @@ import numpy as np import argparse + def parse_pos_format(file_path): """ Parse a .POS file into a pandas DataFrame. @@ -20,37 +21,38 @@ def parse_pos_format(file_path): """ data = [] try: - with open(file_path, 'r') as file: + with open(file_path, "r") as file: data_started = False for line in file: - if line.startswith('*'): + if line.startswith("*"): data_started = True continue if data_started: parts = line.split() if len(parts) >= 24: record = { - 'Time': datetime.strptime(parts[0], '%Y-%m-%dT%H:%M:%S.%f'), - 'Latitude': float(parts[11]), - 'Longitude': float(parts[12]), - 'Elevation': float(parts[13]), - 'dN': float(parts[14]), - 'dE': float(parts[15]), - 'dU': float(parts[16]), - 'sN': float(parts[17]), - 'sE': float(parts[18]), - 'sU': float(parts[19]), - 'sElevation': float(parts[19]), - 'Rne': float(parts[20]), - 'Rnu': float(parts[21]), - 'Reu': float(parts[22]), - 'soln': parts[23] + "Time": datetime.strptime(parts[0], "%Y-%m-%dT%H:%M:%S.%f"), + "Latitude": float(parts[11]), + "Longitude": float(parts[12]), + "Elevation": float(parts[13]), + "dN": float(parts[14]), + "dE": float(parts[15]), + "dU": float(parts[16]), + "sN": float(parts[17]), + "sE": float(parts[18]), + "sU": float(parts[19]), + "sElevation": float(parts[19]), + "Rne": float(parts[20]), + "Rnu": float(parts[21]), + "Reu": float(parts[22]), + "soln": parts[23], } data.append(record) except Exception as e: print(f"Error parsing file {file_path}: {e}") return pd.DataFrame(data) + # Function to parse the datetime with optional timezone def parse_datetime(datetime_str): """ @@ -78,6 +80,7 @@ def parse_datetime(datetime_str): continue raise ValueError(f"datetime {datetime_str} does not match expected formats.") + def remove_weighted_mean(data): """ Remove weighted mean from each component series in-place and return the DataFrame. @@ -88,14 +91,15 @@ def remove_weighted_mean(data): Returns: pandas.DataFrame: The same DataFrame with each component demeaned by its weighted mean. """ - sigma_keys = {'dN': 'sN', 'dE': 'sE', 'dU': 'sU', 'Elevation': 'sElevation'} # Assume sElevation exists - for component in ['dN', 'dE', 'dU', 'Elevation']: + sigma_keys = {"dN": "sN", "dE": "sE", "dU": "sU", "Elevation": "sElevation"} # Assume sElevation exists + for component in ["dN", "dE", "dU", "Elevation"]: sigma_key = sigma_keys[component] - weights = 1 / data[sigma_key]**2 + weights = 1 / data[sigma_key] ** 2 weighted_mean = np.average(data[component], weights=weights) data[component] -= weighted_mean # Demean the series return data + def apply_smoothing(data, horz_smoothing=None, vert_smoothing=None): """ Apply LOWESS smoothing to horizontal and / or vertical components. @@ -108,37 +112,39 @@ def apply_smoothing(data, horz_smoothing=None, vert_smoothing=None): Returns: pandas.DataFrame: DataFrame with additional 'Smoothed_*' columns when smoothing is applied. """ - for component in ['dN', 'dE', 'dU', 'Elevation']: - if horz_smoothing and (component == 'dN' or component == 'dE'): - data[f'Smoothed_{component}'] = lowess(data[component], data['Time'], frac=horz_smoothing, return_sorted=False) - if vert_smoothing and (component == 'dU' or component == 'Elevation'): - data[f'Smoothed_{component}'] = lowess(data[component], data['Time'], frac=vert_smoothing, return_sorted=False) + for component in ["dN", "dE", "dU", "Elevation"]: + if horz_smoothing and (component == "dN" or component == "dE"): + data[f"Smoothed_{component}"] = lowess( + data[component], data["Time"], frac=horz_smoothing, return_sorted=False + ) + if vert_smoothing and (component == "dU" or component == "Elevation"): + data[f"Smoothed_{component}"] = lowess( + data[component], data["Time"], frac=vert_smoothing, return_sorted=False + ) return data + def compute_statistics(data): stats = {} - for component in ['dN', 'dE', 'dU', 'Elevation']: - sigma_key = f's{component[-1].upper()}' if component != 'Elevation' else 'sElevation' - weights = 1 / data[sigma_key]**2 + for component in ["dN", "dE", "dU", "Elevation"]: + sigma_key = f"s{component[-1].upper()}" if component != "Elevation" else "sElevation" + weights = 1 / data[sigma_key] ** 2 weighted_mean = np.average(data[component], weights=weights) - std_dev = np.sqrt(np.average((data[component] - weighted_mean)**2, weights=weights)) - rms = np.sqrt(np.mean(data[component]**2)) + std_dev = np.sqrt(np.average((data[component] - weighted_mean) ** 2, weights=weights)) + rms = np.sqrt(np.mean(data[component] ** 2)) # Store calculated statistics - stats[component] = { - 'weighted_mean': weighted_mean, - 'std_dev': std_dev, - 'rms': rms - } + stats[component] = {"weighted_mean": weighted_mean, "std_dev": std_dev, "rms": rms} # Prepare series for plotting - data[f'{component}_weighted_mean'] = [weighted_mean] * len(data) - data[f'{component}_std_dev_upper'] = weighted_mean + std_dev*2.0 - data[f'{component}_std_dev_lower'] = weighted_mean - std_dev*2.0 + data[f"{component}_weighted_mean"] = [weighted_mean] * len(data) + data[f"{component}_std_dev_upper"] = weighted_mean + std_dev * 2.0 + data[f"{component}_std_dev_lower"] = weighted_mean - std_dev * 2.0 return data, stats + def create_plots(all_data, input_files, component_stats, args, show_plots=True): """ Create interactive HTML plots for POS analysis. @@ -159,120 +165,128 @@ def create_plots(all_data, input_files, component_stats, args, show_plots=True): ## Fig1 # Determine max sigma and color scale settings for Fig1 title_text = f"Time Series Analysis: {', '.join(input_files)}
" - color_scale = 'Jet' if args.colour_sigma else None # Only set color scale if --colour_sigma is active - max_sigma_data = np.max([all_data['sN'].max(), all_data['sE'].max(), all_data['sU'].max()]) - min_sigma_data = np.min([all_data['sN'].min(), all_data['sE'].min(), all_data['sU'].min()]) + color_scale = "Jet" if args.colour_sigma else None # Only set color scale if --colour_sigma is active + max_sigma_data = np.max([all_data["sN"].max(), all_data["sE"].max(), all_data["sU"].max()]) + min_sigma_data = np.min([all_data["sN"].min(), all_data["sE"].min(), all_data["sU"].min()]) cmax = min(args.max_sigma, max_sigma_data) if args.max_sigma is not None else max_sigma_data # cmin = min_sigma_data cmin = 0.0 # Setting up the plot fig1 = go.Figure() - components = ['dN', 'dE', 'Elevation'] if args.elevation else ['dN', 'dE', 'dU'] - component_colors = { - 'dN': 'red', - 'dE': 'green', - 'dU': 'blue', - 'Elevation': 'orange' - } + components = ["dN", "dE", "Elevation"] if args.elevation else ["dN", "dE", "dU"] + component_colors = {"dN": "red", "dE": "green", "dU": "blue", "Elevation": "orange"} for component in components: # Correctly map the component to its sigma key - if component == 'Elevation': - sigma_key = 'sU' # Assuming sigma for Elevation is stored in 'sU' + if component == "Elevation": + sigma_key = "sU" # Assuming sigma for Elevation is stored in 'sU' else: - sigma_key = f's{component[-1].upper()}' + sigma_key = f"s{component[-1].upper()}" - print('Plotting: ', sigma_key) # To check if the correct sigma key is being used + print("Plotting: ", sigma_key) # To check if the correct sigma key is being used # Add the primary and smoothed series data if args.colour_sigma: # When using --colour_sigma, use the sigma value for coloring - fig1.add_trace(go.Scatter( - x=all_data['Time'], y=all_data[component], - mode='lines+markers', - marker=dict(size=5, color=all_data[sigma_key], coloraxis="coloraxis"), - name=component, - hoverinfo='text+x+y', - text=f'{component} Sigma: ' + all_data[sigma_key].astype(str) - )) + fig1.add_trace( + go.Scatter( + x=all_data["Time"], + y=all_data[component], + mode="lines+markers", + marker=dict(size=5, color=all_data[sigma_key], coloraxis="coloraxis"), + name=component, + hoverinfo="text+x+y", + text=f"{component} Sigma: " + all_data[sigma_key].astype(str), + ) + ) else: # When not using --colour_sigma, add error bars using the sigma values - fig1.add_trace(go.Scatter( - x=all_data['Time'], y=all_data[component], - mode='markers', - name=component, - error_y=dict( - type='data', # Represent error in data coordinates - array=all_data[sigma_key], # Positive error - arrayminus=all_data[sigma_key], # Negative error - visible=True, # Make error bars visible - color='gray' # Color of error bars - ), - marker=dict(size=5, color=component_colors[component]), - line=dict(color=component_colors[component]), - hoverinfo='text+x+y', - text=f'{component} Sigma: ' + all_data[sigma_key].astype(str) - )) - - if f'Smoothed_{component}' in all_data: - fig1.add_trace(go.Scatter( - x=all_data['Time'], y=all_data[f'Smoothed_{component}'], - mode='lines', - name=f'Smoothed {component}', - line=dict(color='rgba(0,0,255,0.5)') - )) + fig1.add_trace( + go.Scatter( + x=all_data["Time"], + y=all_data[component], + mode="markers", + name=component, + error_y=dict( + type="data", # Represent error in data coordinates + array=all_data[sigma_key], # Positive error + arrayminus=all_data[sigma_key], # Negative error + visible=True, # Make error bars visible + color="gray", # Color of error bars + ), + marker=dict(size=5, color=component_colors[component]), + line=dict(color=component_colors[component]), + hoverinfo="text+x+y", + text=f"{component} Sigma: " + all_data[sigma_key].astype(str), + ) + ) + + if f"Smoothed_{component}" in all_data: + fig1.add_trace( + go.Scatter( + x=all_data["Time"], + y=all_data[f"Smoothed_{component}"], + mode="lines", + name=f"Smoothed {component}", + line=dict(color="rgba(0,0,255,0.5)"), + ) + ) # Add statistical lines and shaded areas for standard deviation - fig1.add_trace(go.Scatter( - x=all_data['Time'], y=all_data[f'{component}_weighted_mean'], - mode='lines', - name=f'{component} Weighted Mean', - line=dict(color=component_colors[component]) - )) - - fig1.add_trace(go.Scatter( - x=all_data['Time'].tolist() + all_data['Time'].tolist()[::-1], - y=all_data[f'{component}_std_dev_upper'].tolist() + all_data[f'{component}_std_dev_lower'].tolist()[::-1], - fill='toself', - fillcolor='rgba(68, 68, 255, 0.2)', - line=dict(color='rgba(255,255,255,0)'), - name=f'{component} CI: 2 Sigma (95%)' - )) + fig1.add_trace( + go.Scatter( + x=all_data["Time"], + y=all_data[f"{component}_weighted_mean"], + mode="lines", + name=f"{component} Weighted Mean", + line=dict(color=component_colors[component]), + ) + ) + + fig1.add_trace( + go.Scatter( + x=all_data["Time"].tolist() + all_data["Time"].tolist()[::-1], + y=all_data[f"{component}_std_dev_upper"].tolist() + + all_data[f"{component}_std_dev_lower"].tolist()[::-1], + fill="toself", + fillcolor="rgba(68, 68, 255, 0.2)", + line=dict(color="rgba(255,255,255,0)"), + name=f"{component} CI: 2 Sigma (95%)", + ) + ) stats = component_stats[component] title_text += f"{component}: Weighted Mean = {stats['weighted_mean']:.3f}, Std Dev = {stats['std_dev']:.3f}, RMS = {stats['rms']:.3f}
" fig1.update_layout( title=title_text, - xaxis_title='Time', - yaxis_title='Measurement Value', - xaxis=dict( - rangeslider=dict(visible=True), - fixedrange=False, - type='date' - ), - yaxis=dict( - fixedrange=False + xaxis_title="Time", + yaxis_title="Measurement Value", + xaxis=dict(rangeslider=dict(visible=True), fixedrange=False, type="date"), + yaxis=dict(fixedrange=False), + coloraxis=( + dict( + colorscale=color_scale, + cmin=cmin, + cmax=cmax, + colorbar=dict( + title="Sigma Value", + x=0.5, # Center the color bar on the x-axis + y=-0.5, # Position the color bar below the x-axis + xanchor="center", # Anchor the color bar at its center for x positioning + yanchor="bottom", # Anchor the color bar from its bottom edge for y positioning + len=0.5, # Length of the color bar (75% of the width of the plot area) + thickness=10, # Thickness of the color bar + orientation="h", # Horizontal orientation + ), + ) + if args.colour_sigma + else {} ), - coloraxis=dict( - colorscale=color_scale, - cmin=cmin, - cmax=cmax, - colorbar=dict( - title='Sigma Value', - x=0.5, # Center the color bar on the x-axis - y=-0.5, # Position the color bar below the x-axis - xanchor='center', # Anchor the color bar at its center for x positioning - yanchor='bottom', # Anchor the color bar from its bottom edge for y positioning - len=0.5, # Length of the color bar (75% of the width of the plot area) - thickness=10, # Thickness of the color bar - orientation='h' # Horizontal orientation - ), - ) if args.colour_sigma else {}, showlegend=True, - margin=dict(t=150) + margin=dict(t=150), ) if show_plots: @@ -280,25 +294,25 @@ def create_plots(all_data, input_files, component_stats, args, show_plots=True): if args.save_prefix is not None: output_path = os.path.join(os.path.dirname(args.save_prefix), f"{input_root}_fig1.html") - os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) fig1.write_html(output_path) ## Fig2 # Build the title with file names and statistics for Fig2 title_text = f"dN vs dE Analysis: {', '.join(input_files)}
" - for component in ['dN', 'dE']: + for component in ["dN", "dE"]: stats = component_stats[component] title_text += f"{component}: Weighted Mean = {stats['weighted_mean']:.3f}, Std Dev = {stats['std_dev']:.3f}, RMS = {stats['rms']:.3f}
" # Conditional sigma calculations and setup - composite_uncertainty = np.sqrt(all_data['sN'] ** 2 + all_data['sE'] ** 2) - all_data['composite_uncertainty'] = composite_uncertainty + composite_uncertainty = np.sqrt(all_data["sN"] ** 2 + all_data["sE"] ** 2) + all_data["composite_uncertainty"] = composite_uncertainty max_sigma_data = composite_uncertainty.max() if args.colour_sigma: cmax = min(args.max_sigma, max_sigma_data) if args.max_sigma is not None else max_sigma_data cmin = composite_uncertainty.min() # cmin = 0.0 - color_scale = 'Jet' # Define the color scale here within the condition + color_scale = "Jet" # Define the color scale here within the condition else: cmin = None # No cmax needed for static colors cmax = None # No cmax needed for static colors @@ -306,51 +320,64 @@ def create_plots(all_data, input_files, component_stats, args, show_plots=True): # Plot configuration fig2 = go.Figure() - fig2.add_trace(go.Scatter( - x=all_data['dE'], y=all_data['dN'], - mode='markers', - marker=dict( - size=5, - color=all_data['composite_uncertainty'] if args.colour_sigma else 'blue', # Conditional coloring - coloraxis="coloraxis" if args.colour_sigma else None # Use color axis only if color sigma is set - ), - name='dE vs dN', - text=[f"{time} Sigma dNdE: {unc:.4f}" for time, unc in - zip(all_data['Time'], all_data['composite_uncertainty'])], - hoverinfo='text+x+y' - )) - - # Add smoothed data if available - if 'Smoothed_dN' in all_data.columns and 'Smoothed_dE' in all_data.columns: - fig2.add_trace(go.Scatter( - x=all_data['Smoothed_dE'], y=all_data['Smoothed_dN'], - mode='markers', + fig2.add_trace( + go.Scatter( + x=all_data["dE"], + y=all_data["dN"], + mode="markers", marker=dict( size=5, - color='red' + color=all_data["composite_uncertainty"] if args.colour_sigma else "blue", # Conditional coloring + coloraxis="coloraxis" if args.colour_sigma else None, # Use color axis only if color sigma is set ), - name='Smoothed' - )) + name="dE vs dN", + text=[ + f"{time} Sigma dNdE: {unc:.4f}" + for time, unc in zip(all_data["Time"], all_data["composite_uncertainty"]) + ], + hoverinfo="text+x+y", + ) + ) + + # Add smoothed data if available + if "Smoothed_dN" in all_data.columns and "Smoothed_dE" in all_data.columns: + fig2.add_trace( + go.Scatter( + x=all_data["Smoothed_dE"], + y=all_data["Smoothed_dN"], + mode="markers", + marker=dict(size=5, color="red"), + name="Smoothed", + ) + ) # Layout update with conditional color axis settings fig2.update_layout( title=title_text, - xaxis_title='dE (meters)', - yaxis_title='dN (meters)', + xaxis_title="dE (meters)", + yaxis_title="dN (meters)", xaxis=dict(scaleanchor="y", scaleratio=1), yaxis=dict(scaleanchor="x", scaleratio=1), - coloraxis=dict( - colorscale=color_scale, - cmin=cmin, - cmax=cmax, - colorbar=dict( - title='Sigma Value', - x=0.5, y=-0.15, # Adjusted for visibility - xanchor='center', yanchor='bottom', - len=0.75, thickness=20, orientation='h' + coloraxis=( + dict( + colorscale=color_scale, + cmin=cmin, + cmax=cmax, + colorbar=dict( + title="Sigma Value", + x=0.5, + y=-0.15, # Adjusted for visibility + xanchor="center", + yanchor="bottom", + len=0.75, + thickness=20, + orientation="h", + ), ) - ) if args.colour_sigma else None, # Apply color axis settings only if needed - showlegend=True + if args.colour_sigma + else None + ), # Apply color axis settings only if needed + showlegend=True, ) if show_plots: @@ -358,11 +385,11 @@ def create_plots(all_data, input_files, component_stats, args, show_plots=True): if args.save_prefix is not None: output_path = os.path.join(os.path.dirname(args.save_prefix), f"{input_root}_fig2.html") - os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) fig2.write_html(output_path) ## Fig3 - if getattr(args, 'map', False) or getattr(args, 'map_view', False): + if getattr(args, "map", False) or getattr(args, "map_view", False): # Plotly plotting using mapbox open-street-map # Adjust the zoom level dynamically based on the spread of the latitude and longitude @@ -378,26 +405,25 @@ def adjust_zoom(latitudes, longitudes): else: return 5 # Continental level zoom - zoom_level = adjust_zoom(all_data['Latitude'], all_data['Longitude']) + zoom_level = adjust_zoom(all_data["Latitude"], all_data["Longitude"]) - fig3 = go.Figure(go.Scattermapbox( - lat=all_data['Latitude'], - lon=all_data['Longitude'], - mode='markers+lines', - marker=dict(size=5, color='blue') - )) + fig3 = go.Figure( + go.Scattermapbox( + lat=all_data["Latitude"], + lon=all_data["Longitude"], + mode="markers+lines", + marker=dict(size=5, color="blue"), + ) + ) fig3.update_layout( mapbox=dict( style="open-street-map", - center=go.layout.mapbox.Center( - lat=all_data['Latitude'].mean(), - lon=all_data['Longitude'].mean() - ), - zoom=zoom_level + center=go.layout.mapbox.Center(lat=all_data["Latitude"].mean(), lon=all_data["Longitude"].mean()), + zoom=zoom_level, ), - title='Geographic Plot of Latitude and Longitude', - showlegend=False + title="Geographic Plot of Latitude and Longitude", + showlegend=False, ) if show_plots: @@ -405,93 +431,57 @@ def adjust_zoom(latitudes, longitudes): if args.save_prefix is not None: output_path = os.path.join(os.path.dirname(args.save_prefix), f"{input_root}_fig3.html") - os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) fig3.write_html(output_path) ## Fig4 if args.heatmap: # Plotly plotting dN vs dE heatmap fig4 = go.Figure() - fig4.add_trace(go.Histogram2dContour( - x=all_data['dE'], - y=all_data['dN'], - colorscale='Jet', - reversescale=False, - xaxis='x', - yaxis='y' - )) - fig4.add_trace(go.Scatter( - x=all_data['dE'], - y=all_data['dN'], - xaxis='x', - yaxis='y', - mode='markers', - marker=dict( - color='rgba(0,0,0,0.3)', - size=3 + fig4.add_trace( + go.Histogram2dContour( + x=all_data["dE"], y=all_data["dN"], colorscale="Jet", reversescale=False, xaxis="x", yaxis="y" ) - )) - fig4.add_trace(go.Scatter( - x=all_data['dE_weighted_mean'], - y=all_data['dN_weighted_mean'], - xaxis='x', - yaxis='y', - mode='markers', - marker=dict( - color="white", - size=15, - line_color='black', - symbol='x-dot', - line_width=2 - ), - hoverinfo='text+x+y', - text='Weighted Mean (dE, dN)' - )) - fig4.add_trace(go.Histogram( - y=all_data['dN'], - xaxis='x2', - marker=dict( - color='rgba(0,0,0,1)' + ) + fig4.add_trace( + go.Scatter( + x=all_data["dE"], + y=all_data["dN"], + xaxis="x", + yaxis="y", + mode="markers", + marker=dict(color="rgba(0,0,0,0.3)", size=3), ) - )) - fig4.add_trace(go.Histogram( - x=all_data['dE'], - yaxis='y2', - marker=dict( - color='rgba(0,0,0,1)' + ) + fig4.add_trace( + go.Scatter( + x=all_data["dE_weighted_mean"], + y=all_data["dN_weighted_mean"], + xaxis="x", + yaxis="y", + mode="markers", + marker=dict(color="white", size=15, line_color="black", symbol="x-dot", line_width=2), + hoverinfo="text+x+y", + text="Weighted Mean (dE, dN)", ) - )) + ) + fig4.add_trace(go.Histogram(y=all_data["dN"], xaxis="x2", marker=dict(color="rgba(0,0,0,1)"))) + fig4.add_trace(go.Histogram(x=all_data["dE"], yaxis="y2", marker=dict(color="rgba(0,0,0,1)"))) fig4.update_layout( autosize=False, - xaxis=dict( - zeroline=False, - domain=[0, 0.85], - showgrid=False - ), - yaxis=dict( - zeroline=False, - domain=[0, 0.85], - showgrid=False - ), - xaxis2=dict( - zeroline=False, - domain=[0.85, 1], - showgrid=False - ), - yaxis2=dict( - zeroline=False, - domain=[0.85, 1], - showgrid=False - ), + xaxis=dict(zeroline=False, domain=[0, 0.85], showgrid=False), + yaxis=dict(zeroline=False, domain=[0, 0.85], showgrid=False), + xaxis2=dict(zeroline=False, domain=[0.85, 1], showgrid=False), + yaxis2=dict(zeroline=False, domain=[0.85, 1], showgrid=False), title=title_text, - xaxis_title='dE (meters)', - yaxis_title='dN (meters)', + xaxis_title="dE (meters)", + yaxis_title="dN (meters)", height=800, width=800, bargap=0, - hovermode='closest', - showlegend=False + hovermode="closest", + showlegend=False, ) if show_plots: @@ -499,9 +489,10 @@ def adjust_zoom(latitudes, longitudes): if args.save_prefix is not None: output_path = os.path.join(os.path.dirname(args.save_prefix), f"{input_root}_fig4.html") - os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) fig4.write_html(output_path) + def _process_and_plot(input_files, args, show_plots=False): """ Internal helper to process POS data and generates plots. Shared function between the CLI and program UI call modes. @@ -524,28 +515,32 @@ def _process_and_plot(input_files, args, show_plots=False): file_data = parse_pos_format(file_path) all_data = pd.concat([all_data, file_data], ignore_index=True) - all_data['Time'] = pd.to_datetime(all_data['Time'], format="%Y-%m-%dT%H:%M:%S.%f") + all_data["Time"] = pd.to_datetime(all_data["Time"], format="%Y-%m-%dT%H:%M:%S.%f") # Apply time windowing if start_datetime: - all_data = all_data[all_data['Time'] >= start_datetime] + all_data = all_data[all_data["Time"] >= start_datetime] if end_datetime: - all_data = all_data[all_data['Time'] <= end_datetime] + all_data = all_data[all_data["Time"] <= end_datetime] # Apply threshold filtering if sigma_threshold is provided if args.sigma_threshold: se_threshold, sn_threshold, su_threshold = args.sigma_threshold - mask = (all_data['sE'] <= se_threshold) & (all_data['sN'] <= sn_threshold) & ( - all_data['sU'] <= su_threshold) & (all_data['sElevation'] <= su_threshold) + mask = ( + (all_data["sE"] <= se_threshold) + & (all_data["sN"] <= sn_threshold) + & (all_data["sU"] <= su_threshold) + & (all_data["sElevation"] <= su_threshold) + ) all_data = all_data[mask] # Down-sample the data if requested if args.down_sample: # Ensure the 'Time' column is datetime for proper indexing - all_data['Time'] = pd.to_datetime(all_data['Time']) - all_data.set_index('Time', inplace=True) + all_data["Time"] = pd.to_datetime(all_data["Time"]) + all_data.set_index("Time", inplace=True) # Resample and take the first available data point in each bin - all_data = all_data.resample(f'{args.down_sample}s').first().dropna().reset_index() + all_data = all_data.resample(f"{args.down_sample}s").first().dropna().reset_index() # Demean, smooth, and compute statistics if args.demean: @@ -554,7 +549,7 @@ def _process_and_plot(input_files, args, show_plots=False): all_data, component_stats = compute_statistics(all_data) # Generate plots - create_plots(all_data, input_files, component_stats, args, show_plots = show_plots) + create_plots(all_data, input_files, component_stats, args, show_plots=show_plots) # Return list of generated files if args.save_prefix: @@ -563,7 +558,7 @@ def _process_and_plot(input_files, args, show_plots=False): generated_files.append(os.path.join(os.path.dirname(args.save_prefix), f"{input_root}_fig1.html")) generated_files.append(os.path.join(os.path.dirname(args.save_prefix), f"{input_root}_fig2.html")) # Access map and / or heatmap - if getattr(args, 'map', False) or getattr(args, 'map_view', False): + if getattr(args, "map", False) or getattr(args, "map_view", False): generated_files.append(os.path.join(os.path.dirname(args.save_prefix), f"{input_root}_fig3.html")) if args.heatmap: generated_files.append(os.path.join(os.path.dirname(args.save_prefix), f"{input_root}_fig4.html")) @@ -571,11 +566,23 @@ def _process_and_plot(input_files, args, show_plots=False): return [] -def plot_pos_files(input_files, start_datetime=None, end_datetime=None, - horz_smoothing=None, vert_smoothing=None, colour_sigma=False, - max_sigma=None, elevation=False, demean=False, map_view=False, - heatmap=False, sigma_threshold=None, down_sample=None, - save_prefix=None): + +def plot_pos_files( + input_files, + start_datetime=None, + end_datetime=None, + horz_smoothing=None, + vert_smoothing=None, + colour_sigma=False, + max_sigma=None, + elevation=False, + demean=False, + map_view=False, + heatmap=False, + sigma_threshold=None, + down_sample=None, + save_prefix=None, +): """ Generate the interactive figures from one or more POS files (the programmatic call for Ginan-UI to use). @@ -600,6 +607,7 @@ def plot_pos_files(input_files, start_datetime=None, end_datetime=None, Returns: list: Paths to generated HTML files when save_prefix is provided; empty list otherwise. """ + class Args: def __init__(self): self.input_files = input_files @@ -620,40 +628,71 @@ def __init__(self): args = Args() # "show_plots = False" flags to remain in UI (don't open web browser) - return _process_and_plot(input_files, args, show_plots = False) + return _process_and_plot(input_files, args, show_plots=False) + # CLI Entry if __name__ == "__main__": # Setup and parse arguments parser = argparse.ArgumentParser(description="Plot positional data with optional smoothing and color coding.") - parser.add_argument('--input-files', nargs='+', required=True, help='One or more input .POS files') - parser.add_argument('--start-datetime', type=str, - help="Start datetime in the format YYYY-MM-DDTHH:MM:SS, optional timezone") - parser.add_argument('--end-datetime', type=str, - help="End datetime in the format YYYY-MM-DDTHH:MM:SS, optional timezone") - parser.add_argument('--horz-smoothing', type=float, default=None, - help='Fraction of the data used for horizontal (East and North) LOWESS smoothing (optional).') - parser.add_argument('--vert-smoothing', type=float, default=None, - help='Fraction of the data used for vertical (Up) LOWESS smoothing (optional).') - parser.add_argument('--colour-sigma', action='store_true', - help='Colourize the timeseries using the standard deviation (sigma) values (optional).') - parser.add_argument('--max-sigma', type=float, default=None, - help='Set a maximum sigma threshold for the sigma colour scale (optional).') - parser.add_argument('--elevation', action='store_true', - help='Plot Elevation values inplace of dU wrt the reference coord (optional).') - parser.add_argument('--demean', action='store_true', - help='Remove the mean values from all time series before plotting (optional).') - parser.add_argument('--map', action='store_true', - help='Create a geographic map view from the Longitude & Latitude estiamtes (optional).') - parser.add_argument('--heatmap', action='store_true', - help='Create a 2D heatmap view of E & N coodrinates wrt the reference position (optional).') - parser.add_argument('--sigma-threshold', nargs=3, type=float, - help="Thresholds for sE, sN, and sU to filter data.") - parser.add_argument('--down-sample', type=int, - help="Interval in seconds for down-sampling data.") - parser.add_argument('--save-prefix', nargs='?', const='plot', default=None, - help='Prefix for saving HTML figures, e.g., ./output/fig') + parser.add_argument("--input-files", nargs="+", required=True, help="One or more input .POS files") + parser.add_argument( + "--start-datetime", type=str, help="Start datetime in the format YYYY-MM-DDTHH:MM:SS, optional timezone" + ) + parser.add_argument( + "--end-datetime", type=str, help="End datetime in the format YYYY-MM-DDTHH:MM:SS, optional timezone" + ) + parser.add_argument( + "--horz-smoothing", + type=float, + default=None, + help="Fraction of the data used for horizontal (East and North) LOWESS smoothing (optional).", + ) + parser.add_argument( + "--vert-smoothing", + type=float, + default=None, + help="Fraction of the data used for vertical (Up) LOWESS smoothing (optional).", + ) + parser.add_argument( + "--colour-sigma", + action="store_true", + help="Colourize the timeseries using the standard deviation (sigma) values (optional).", + ) + parser.add_argument( + "--max-sigma", + type=float, + default=None, + help="Set a maximum sigma threshold for the sigma colour scale (optional).", + ) + parser.add_argument( + "--elevation", + action="store_true", + help="Plot Elevation values inplace of dU wrt the reference coord (optional).", + ) + parser.add_argument( + "--demean", action="store_true", help="Remove the mean values from all time series before plotting (optional)." + ) + parser.add_argument( + "--map", + action="store_true", + help="Create a geographic map view from the Longitude & Latitude estiamtes (optional).", + ) + parser.add_argument( + "--heatmap", + action="store_true", + help="Create a 2D heatmap view of E & N coodrinates wrt the reference position (optional).", + ) + parser.add_argument("--sigma-threshold", nargs=3, type=float, help="Thresholds for sE, sN, and sU to filter data.") + parser.add_argument("--down-sample", type=int, help="Interval in seconds for down-sampling data.") + parser.add_argument( + "--save-prefix", + nargs="?", + const="plot", + default=None, + help="Prefix for saving HTML figures, e.g., ./output/fig", + ) args = parser.parse_args() # "show_plots = True" flags to open the HTML file in web browser - _process_and_plot(args.input_files, args, show_plots = True) + _process_and_plot(args.input_files, args, show_plots=True) diff --git a/scripts/plot_spp.py b/scripts/plot_spp.py new file mode 100644 index 000000000..bc5e64799 --- /dev/null +++ b/scripts/plot_spp.py @@ -0,0 +1,502 @@ +#!/usr/bin/env python3 +""" +plot_spp.py + +Plotly plotting for Ginan SBAS .SPP / *_SPP.POS style output. +Reads ONE OR MORE input files, concatenates into a single DataFrame, +sorts by time, and plots as one continuous time series (no per-file differentiation). + +Outputs: +1) Main HTML (always): + - Time series vs ISO time for: HDOP, VDOP, GDOP, dN, dE, dU, dH, HPL, VPL + - X-axis range slider + - Legend on the right + - Box-zoom (x+y) + scroll-wheel zoom + - Plain HTML stats table below (always visible): + N, MEAN, MEDIAN, STD DEV, RMS for dE, dN, dU, dH (computed over concatenated data) + +2) Optional map HTML (--map): + - 2D lon/lat scatter (Eref vs Nref) on top + - Href time series underneath + - Hover sync between the two plots (same point index) + +Usage: + python3 plot_spp.py -i ALIC-202602303.SPP + python3 plot_spp.py -i ALIC-202602303.SPP ALIC-202602304.SPP ALIC-202602305.SPP --map +""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +from typing import Dict, List + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from plotly.utils import PlotlyJSONEncoder + + +PLOT_COLS = ["HDOP", "VDOP", "GDOP", "dN", "dE", "dU", "dH", "HPL", "VPL"] +STATS_COLS = ["dE", "dN", "dU", "dH"] +ALL_NUM_COLS = ["HDOP", "VDOP", "GDOP", "Nref", "Eref", "Href", "dN", "dE", "dU", "dH", "HPL", "VPL"] + + +def parse_spp_file(path: Path) -> pd.DataFrame: + """ + Parse .SPP / *_SPP.POS file into a DataFrame. + + Based on the example token positions (0-based indexes): + 0 ISO time + 5 HDOP + 6 VDOP + 7 GDOP + 11 Nref(lat) + 12 Eref(lon) + 13 Href + 14 dN + 15 dE + 16 dU + 17 dH + 18 HPL + 19 VPL + """ + rows: List[Dict[str, object]] = [] + + with path.open("rt", encoding="utf-8", errors="replace") as f: + # Skip until header marker (starts with '*'), if present. + for line in f: + if line.lstrip().startswith("*"): + break + + for line in f: + line = line.strip() + if not line or line.startswith("*"): + continue + + toks = line.split() + if len(toks) < 20: + continue + + try: + rows.append( + { + "time": toks[0], + "HDOP": toks[5], + "VDOP": toks[6], + "GDOP": toks[7], + "Nref": toks[11], + "Eref": toks[12], + "Href": toks[13], + "dN": toks[14], + "dE": toks[15], + "dU": toks[16], + "dH": toks[17], + "HPL": toks[18], + "VPL": toks[19], + } + ) + except IndexError: + continue + + if not rows: + raise ValueError(f"No data rows parsed from {path}") + + df = pd.DataFrame(rows) + df["time"] = pd.to_datetime(df["time"], errors="coerce") + + for c in ALL_NUM_COLS: + df[c] = pd.to_numeric(df[c], errors="coerce") + + df = df.dropna(subset=["time"]).sort_values("time").reset_index(drop=True) + return df + + +def _finite(series: pd.Series) -> np.ndarray: + x = pd.to_numeric(series, errors="coerce").to_numpy(dtype=float) + return x[np.isfinite(x)] + + +def compute_stats_table(df: pd.DataFrame) -> pd.DataFrame: + """Rows: dE/dN/dU/dH; Cols: N, MEAN, MEDIAN, STD DEV, RMS""" + rows = [] + for c in STATS_COLS: + x = _finite(df[c]) + if x.size == 0: + rows.append([c, 0, np.nan, np.nan, np.nan, np.nan]) + else: + rows.append( + [ + c, + int(x.size), + float(np.mean(x)), + float(np.median(x)), + float(np.std(x, ddof=0)), + float(math.sqrt(np.mean(x * x))), + ] + ) + return pd.DataFrame(rows, columns=["Component", "N", "MEAN", "MEDIAN", "STD DEV", "RMS"]) + + +def build_timeseries_figure(df: pd.DataFrame, title: str) -> go.Figure: + fig = go.Figure() + for c in PLOT_COLS: + fig.add_trace(go.Scatter(x=df["time"], y=df[c], mode="lines", name=c)) + + fig.update_layout( + title=title, + height=820, + margin=dict(l=70, r=260, t=70, b=60), + legend=dict(x=1.02, y=1.0, xanchor="left", yanchor="top", orientation="v"), + dragmode="zoom", + hovermode="x unified", + ) + + fig.update_xaxes( + title_text="Time", + fixedrange=False, + rangeslider=dict(visible=True, thickness=0.08), + ) + fig.update_yaxes(title_text="Value (m or dimensionless)", fixedrange=False) + return fig + + +def write_html_with_stats(fig: go.Figure, stats: pd.DataFrame, out_path: Path) -> None: + plot_div = fig.to_html( + full_html=False, + include_plotlyjs="cdn", + config={"scrollZoom": True, "responsive": True, "displaylogo": False}, + ) + + stats_fmt = stats.copy() + for col in ["MEAN", "MEDIAN", "STD DEV", "RMS"]: + stats_fmt[col] = stats_fmt[col].map(lambda v: f"{v:.4f}" if np.isfinite(v) else "NaN") + + table_html = stats_fmt.to_html(index=False, classes="stats", border=0) + + html = f""" + + + + SPP Plot + + + +
+ {plot_div} +

Statistics — dE, dN, dU, dH

+ {table_html} +
+ + +""" + out_path.write_text(html, encoding="utf-8") + + +def build_map_figure(df_map: pd.DataFrame, title: str) -> go.Figure: + """ + 2-row linked view (single concatenated dataset): + row1: lon/lat scatter (Eref vs Nref) + row2: Href time series + """ + g = df_map.reset_index(drop=True).copy() + g["time_str"] = g["time"].dt.strftime("%Y-%m-%dT%H:%M:%S.%f").str.slice(0, 23) + + fig = make_subplots( + rows=2, + cols=1, + row_heights=[0.62, 0.38], + vertical_spacing=0.08, + ) + + fig.add_trace( + go.Scatter( + x=g["Eref"], + y=g["Nref"], + mode="markers", + name="Nref/Eref", + customdata=np.stack([g["time_str"].to_numpy(), g["Href"].to_numpy()], axis=1), + hovertemplate=( + "Time: %{customdata[0]}
" + "Lat (Nref): %{y:.8f}
" + "Lon (Eref): %{x:.8f}
" + "H (Href): %{customdata[1]:.3f} m
" + "" + ), + ), + row=1, + col=1, + ) + + fig.add_trace( + go.Scatter( + x=g["time"], + y=g["Href"], + mode="lines+markers", + name="Href", + customdata=np.stack([g["Nref"].to_numpy(), g["Eref"].to_numpy()], axis=1), + hovertemplate=( + "Time: %{x|%Y-%m-%dT%H:%M:%S.%L}
" + "H (Href): %{y:.3f} m
" + "Lat (Nref): %{customdata[0]:.8f}
" + "Lon (Eref): %{customdata[1]:.8f}
" + "" + ), + ), + row=2, + col=1, + ) + + fig.update_layout( + title=title, + height=900, + margin=dict(l=70, r=40, t=70, b=60), + dragmode="zoom", + hovermode="closest", + showlegend=False, + ) + + fig.update_xaxes(title_text="Longitude (deg)", fixedrange=False, row=1, col=1) + fig.update_yaxes(title_text="Latitude (deg)", fixedrange=False, row=1, col=1) + fig.update_xaxes(title_text="Time", fixedrange=False, row=2, col=1) + fig.update_yaxes(title_text="Href (m)", fixedrange=False, row=2, col=1) + + return fig + + +def write_map_html_linked(fig: go.Figure, out_path: Path) -> None: + """ + Robust HTML writer with hover sync between trace 0 and 1 (single dataset). + """ + div_id = "spp_map_linked" + fig_dict = fig.to_plotly_json() + + data_json = json.dumps(fig_dict["data"], cls=PlotlyJSONEncoder) + layout_json = json.dumps(fig_dict["layout"], cls=PlotlyJSONEncoder) + config_json = json.dumps({"scrollZoom": True, "responsive": True, "displaylogo": False}) + + html = f""" + + + + SPP Map + + + + +
+
+
+ + + + +""" + out_path.write_text(html, encoding="utf-8") + + +def build_pl_scatter_figure(df: pd.DataFrame, title: str) -> go.Figure: + """ + Create a log/log integrity scatter plot: + |dH| vs HPL + |dU| vs VPL + with y = x reference line. + """ + # Use absolute errors + dH = np.abs(df["dH"].to_numpy()) + dU = np.abs(df["dU"].to_numpy()) + HPL = df["HPL"].to_numpy() + VPL = df["VPL"].to_numpy() + + # Keep only finite & positive values (required for log scale) + mask_h = np.isfinite(dH) & np.isfinite(HPL) & (dH > 0) & (HPL > 0) + mask_v = np.isfinite(dU) & np.isfinite(VPL) & (dU > 0) & (VPL > 0) + + fig = go.Figure() + + fig.add_trace( + go.Scatter( + x=dH[mask_h], + y=HPL[mask_h], + mode="markers", + name="Horizontal: |dH| vs HPL", + marker=dict(size=6), + ) + ) + + fig.add_trace( + go.Scatter( + x=dU[mask_v], + y=VPL[mask_v], + mode="markers", + name="Vertical: |dU| vs VPL", + marker=dict(size=6), + ) + ) + + # Diagonal y = x + all_vals = np.concatenate([dH[mask_h], HPL[mask_h], dU[mask_v], VPL[mask_v]]) + if all_vals.size > 0: + lo = all_vals.min() + hi = all_vals.max() + fig.add_trace( + go.Scatter( + x=[lo, hi], + y=[lo, hi], + mode="lines", + name="y = x", + line=dict(dash="dot", color="black"), + ) + ) + + fig.update_layout( + title=title, + xaxis=dict( + title="Position Error |d| (m)", + type="log", + ), + yaxis=dict( + title="Protection Level (m)", + type="log", + ), + height=700, + legend=dict(x=1.02, y=1.0, xanchor="left", yanchor="top"), + margin=dict(l=80, r=260, t=70, b=60), + dragmode="zoom", + ) + + return fig + + +def write_simple_html(fig: go.Figure, out_path: Path) -> None: + fig.write_html( + str(out_path), + include_plotlyjs="cdn", + full_html=True, + config={"scrollZoom": True, "responsive": True, "displaylogo": False}, + ) + + +def derive_map_output_path(main_out: Path) -> Path: + return main_out.with_name(f"{main_out.stem}_map{main_out.suffix}") + + +def main() -> int: + p = argparse.ArgumentParser(description="Plot Ginan SBAS .SPP output as Plotly HTML (concatenated inputs).") + p.add_argument("-i", "--input", required=True, nargs="+", help="One or more input .SPP / *_SPP.POS files") + p.add_argument("-o", "--output", default=None, help="Output .html file path (default: .html)") + p.add_argument("--title", default=None, help="Main plot title (default: derived from input filenames)") + p.add_argument("--map", action="store_true", help="Also write a separate linked-hover lat/lon + Href HTML") + p.add_argument( + "--pl", action="store_true", help="Also write a protection-level vs error log/log plot (dH vs HPL, dU vs VPL)" + ) + args = p.parse_args() + + in_paths = [Path(x).expanduser().resolve() for x in args.input] + for pth in in_paths: + if not pth.exists(): + raise FileNotFoundError(f"Input file not found: {pth}") + + main_out = Path(args.output).expanduser().resolve() if args.output else in_paths[0].with_suffix(".html") + main_out.parent.mkdir(parents=True, exist_ok=True) + + # Read and concatenate + dfs = [parse_spp_file(pth) for pth in in_paths] + df = pd.concat(dfs, ignore_index=True).sort_values("time").reset_index(drop=True) + + if args.title: + title = args.title + else: + title = ( + f"SPP Time Series: {len(in_paths)} file(s) (concatenated) (n={len(df)})" + if len(in_paths) > 1 + else f"SPP Time Series: {in_paths[0].name} (n={len(df)})" + ) + + fig = build_timeseries_figure(df, title=title) + stats = compute_stats_table(df) + write_html_with_stats(fig, stats, main_out) + print(f"✅ Wrote main: {main_out}") + + if args.map: + df_map = df.dropna(subset=["Nref", "Eref", "Href"]).copy() + if len(df_map) < 2: + print("⚠️ --map requested, but not enough valid Nref/Eref/Href samples to plot.") + return 0 + + map_out = derive_map_output_path(main_out) + map_title = f"Reference Position Map + Height (concatenated) (n={len(df_map)})" + fig_map = build_map_figure(df_map, title=map_title) + write_map_html_linked(fig_map, map_out) + print(f"✅ Wrote map: {map_out}") + + if args.pl: + pl_out = main_out.with_name(f"{main_out.stem}_pl{main_out.suffix}") + pl_title = "SPP Protection Level vs Position Error (log/log)" + fig_pl = build_pl_scatter_figure(df, pl_title) + write_simple_html(fig_pl, pl_out) + print(f"✅ Wrote PL plot: {pl_out}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plot_trace_res.py b/scripts/plot_trace_res.py index c6648c45e..024de637c 100644 --- a/scripts/plot_trace_res.py +++ b/scripts/plot_trace_res.py @@ -1,4 +1,5 @@ from __future__ import annotations + #!/usr/bin/env python3 # -*- coding: utf-8 -*- @@ -20,14 +21,15 @@ import os, glob import re import argparse -from pathlib import Path -from typing import Iterable, Optional, List, Dict, Tuple -from datetime import datetime +from pathlib import Path +from typing import Iterable, Optional, List, Dict, Tuple +from datetime import datetime def ensure_parent(p) -> None: """Create parent directory for a path if it doesn't exist.""" from pathlib import Path as _P + try: _P(p).parent.mkdir(parents=True, exist_ok=True) except Exception: @@ -44,9 +46,9 @@ def build_out_path( variant_suffix: str, short: str, *, - split: str | None = None, # "recv" | "sat" | None - key: str | None = None, # station or satellite ID - tag: str | None = None, # e.g., "residual", "h"/"v" for totals + split: str | None = None, # "recv" | "sat" | None + key: str | None = None, # station or satellite ID + tag: str | None = None, # e.g., "residual", "h"/"v" for totals ext: str = "html", ) -> str: """ @@ -61,13 +63,18 @@ def build_out_path( if key: parts.append(_sanitize_filename_piece(key)) return "_".join(parts) + f".{ext}" + + def slugify(text: str) -> str: """Return a safe slug for filenames: lowercase, alnum-plus-dashes.""" import re + t = re.sub(r"[^A-Za-z0-9]+", "-", text).strip("-").lower() return re.sub(r"-{2,}", "-", t) or "out" + import logging + logger = logging.getLogger("plot_trace_res") @@ -81,12 +88,14 @@ def _setup_logging(level: str = "INFO") -> None: ) logger.setLevel(lvl) + from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Sequence, Tuple import pandas as pd import plotly.graph_objects as go -from plotly.subplots import make_subplots +from plotly.subplots import make_subplots import plotly.io as pio + pio.templates.default = None import numpy as np @@ -145,7 +154,7 @@ def _setup_logging(level: str = "INFO") -> None: (?P\S+) (?P.*)$ """, - re.VERBOSE + re.VERBOSE, ) @@ -219,7 +228,7 @@ def _to_float_or_nan(x: str) -> float: def parse_trace_lines(lines: Iterable[str]) -> pd.DataFrame: records = [] for ln in lines: - if not ln.startswith('%'): + if not ln.startswith("%"): continue m = LINE_RE.match(ln) if not m: @@ -243,23 +252,34 @@ def parse_trace_lines(lines: Iterable[str]) -> pd.DataFrame: # Optional higher-trace columns pr = gd.get("prefit_ratio") por = gd.get("postfit_ratio") - rec["prefit_ratio"] = _to_float_or_nan(pr) if pr is not None else float("nan") + rec["prefit_ratio"] = _to_float_or_nan(pr) if pr is not None else float("nan") rec["postfit_ratio"] = _to_float_or_nan(por) if por is not None else float("nan") records.append(rec) if not records: - return pd.DataFrame(columns=[ - "iter","date","time","meas","sat","recv","sig", - "prefit","postfit","sigma","label","prefit_ratio","postfit_ratio","datetime" - ]) + return pd.DataFrame( + columns=[ + "iter", + "date", + "time", + "meas", + "sat", + "recv", + "sig", + "prefit", + "postfit", + "sigma", + "label", + "prefit_ratio", + "postfit_ratio", + "datetime", + ] + ) df = pd.DataFrame.from_records(records) # keep your strict format if your inputs are always YYYY-MM-DD - df["datetime"] = pd.to_datetime( - df["date"] + " " + df["time"], - format="%Y-%m-%d %H:%M:%S.%f", errors="coerce" - ) + df["datetime"] = pd.to_datetime(df["date"] + " " + df["time"], format="%Y-%m-%d %H:%M:%S.%f", errors="coerce") return df @@ -306,12 +326,20 @@ def parse_trace_lines_fast(lines: Iterable[str]) -> pd.DataFrame: prefit, postfit, sigma, labels = [], [], [], [] pratio, poratio = [], [] - a_it, a_d, a_t, a_m, a_s, a_r, a_g = iters.append, dates.append, times.append, meas.append, sats.append, recvs.append, sigs.append + a_it, a_d, a_t, a_m, a_s, a_r, a_g = ( + iters.append, + dates.append, + times.append, + meas.append, + sats.append, + recvs.append, + sigs.append, + ) a_pf, a_po, a_si, a_l = prefit.append, postfit.append, sigma.append, labels.append a_pr, a_por = pratio.append, poratio.append for ln in lines: - if not ln or ln[0] != '%': + if not ln or ln[0] != "%": continue parts = ln.split() # Classic must have at least 12 tokens; with ratios it's 14 tokens @@ -319,29 +347,65 @@ def parse_trace_lines_fast(lines: Iterable[str]) -> pd.DataFrame: continue # Common fields - a_it(int(parts[1])); a_d(parts[2]); a_t(parts[3]); a_m(parts[4]) - a_s(parts[5]); a_r(parts[6]); a_g(parts[7]) - a_pf(float(parts[8])); a_po(float(parts[9])); a_si(float(parts[10])) + a_it(int(parts[1])) + a_d(parts[2]) + a_t(parts[3]) + a_m(parts[4]) + a_s(parts[5]) + a_r(parts[6]) + a_g(parts[7]) + a_pf(float(parts[8])) + a_po(float(parts[9])) + a_si(float(parts[10])) if len(parts) >= 14: # With ratios - a_pr(float(parts[11])); a_por(float(parts[12])); a_l(parts[13]) + a_pr(float(parts[11])) + a_por(float(parts[12])) + a_l(parts[13]) else: # Classic - a_pr(float("nan")); a_por(float("nan")); a_l(parts[11]) + a_pr(float("nan")) + a_por(float("nan")) + a_l(parts[11]) if not iters: - return pd.DataFrame(columns=[ - "iter","date","time","meas","sat","recv","sig", - "prefit","postfit","sigma","label","prefit_ratio","postfit_ratio","datetime" - ]) - - df = pd.DataFrame({ - "iter": iters, "date": dates, "time": times, "meas": meas, - "sat": sats, "recv": recvs, "sig": sigs, - "prefit": prefit, "postfit": postfit, "sigma": sigma, "label": labels, - "prefit_ratio": pratio, "postfit_ratio": poratio, - }) + return pd.DataFrame( + columns=[ + "iter", + "date", + "time", + "meas", + "sat", + "recv", + "sig", + "prefit", + "postfit", + "sigma", + "label", + "prefit_ratio", + "postfit_ratio", + "datetime", + ] + ) + + df = pd.DataFrame( + { + "iter": iters, + "date": dates, + "time": times, + "meas": meas, + "sat": sats, + "recv": recvs, + "sig": sigs, + "prefit": prefit, + "postfit": postfit, + "sigma": sigma, + "label": labels, + "prefit_ratio": pratio, + "postfit_ratio": poratio, + } + ) # Flexible parsing here to tolerate either “-” or “/” in date, and variable subsecond df["datetime"] = pd.to_datetime(df["date"] + " " + df["time"], errors="coerce") return df @@ -360,33 +424,40 @@ def parse_large_errors(lines: Iterable[str]) -> pd.DataFrame: kind = gd["kind"] val = float(gd["value"]) if kind == "STATE": - recs.append({ - "datetime": dt, - "kind": kind, - "value": val, - "recv": gd["recv2"], - "param": gd["param"], - "comp": gd["comp"], - }) + recs.append( + { + "datetime": dt, + "kind": kind, + "value": val, + "recv": gd["recv2"], + "param": gd["param"], + "comp": gd["comp"], + } + ) else: - recs.append({ - "datetime": dt, - "kind": kind, - "value": val, - "meas_type": gd["meas_type"], - "sat": gd["sat"], - "recv": gd["recv"], - "sig": gd["sig"], - }) + recs.append( + { + "datetime": dt, + "kind": kind, + "value": val, + "meas_type": gd["meas_type"], + "sat": gd["sat"], + "recv": gd["recv"], + "sig": gd["sig"], + } + ) return pd.DataFrame.from_records(recs) + # -------- Filtering & last-iteration selection -------- + def filter_df( df: pd.DataFrame, receivers: Optional[List[str]], sats: Optional[List[str]], - label_regex: Optional[str],) -> pd.DataFrame: + label_regex: Optional[str], +) -> pd.DataFrame: out = df if receivers: recvu = [r.upper() for r in receivers] @@ -406,9 +477,9 @@ def keep_last_iteration(df: pd.DataFrame) -> pd.DataFrame: keys = ["datetime", "meas", "sat", "recv", "sig", "label"] return ( df.sort_values(["datetime", "iter"]) - .drop_duplicates(subset=keys, keep="last") - .sort_values(["datetime", "sat", "sig"]) - .reset_index(drop=True) + .drop_duplicates(subset=keys, keep="last") + .sort_values(["datetime", "sat", "sig"]) + .reset_index(drop=True) ) @@ -487,16 +558,19 @@ def require_cols(df, name, cols): def log_cols(df, tag): - logger.info("%s: %s", tag, list(df.columns)) + logger.info("%s: %s", tag, list(df.columns)) + # -------- Large-error filtering to match CLI/time -------- + def filter_large_errors( df_large: pd.DataFrame, receivers: Optional[List[str]], sats: Optional[List[str]], start_dt: Optional[pd.Timestamp], - end_dt: Optional[pd.Timestamp],) -> pd.DataFrame: + end_dt: Optional[pd.Timestamp], +) -> pd.DataFrame: if df_large is None or df_large.empty: return df_large @@ -529,10 +603,10 @@ def filter_large_errors( def _insert_gap_breaks_multi( - x: np.ndarray, # datetime64[ns] - y: np.ndarray, # numeric 1D - cd: np.ndarray, # customdata 2D (N,K) - gap_seconds: float = 3600.0 + x: np.ndarray, # datetime64[ns] + y: np.ndarray, # numeric 1D + cd: np.ndarray, # customdata 2D (N,K) + gap_seconds: float = 3600.0, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Insert NaN/NaT rows after any time gap > gap_seconds so Plotly breaks the line. @@ -574,7 +648,9 @@ def _insert_gap_breaks_multi( return x2, y2, cd2 -def build_lookup_cache(df_lookup: pd.DataFrame, yfield: str) -> Dict[Tuple[str, str, str], Tuple[np.ndarray, np.ndarray]]: +def build_lookup_cache( + df_lookup: pd.DataFrame, yfield: str +) -> Dict[Tuple[str, str, str], Tuple[np.ndarray, np.ndarray]]: """ Map (sat, recv, label) -> (t_ns_sorted:int64[ns], y_sorted:float64) for quick nearest lookup. """ @@ -608,7 +684,7 @@ def make_plot( hover_unified: bool = False, df_lookup: pd.DataFrame = None, lookup_cache: Dict[Tuple[str, str, str], Tuple[np.ndarray, np.ndarray]] = None, - *, # ← everything after this must be keyword-only + *, # ← everything after this must be keyword-only show_stats: bool = False, df_amb: pd.DataFrame = pd.DataFrame(), ) -> go.Figure: @@ -648,12 +724,14 @@ def make_plot( # ---------- Subplots: 3 rows when stats (main | slider | table), else 2 rows ---------- if show_stats: fig = make_subplots( - rows=3, cols=1, shared_xaxes=False, - row_heights=[0.72, 0.10, 0.18], # main | slider | table + rows=3, + cols=1, + shared_xaxes=False, + row_heights=[0.72, 0.10, 0.18], # main | slider | table vertical_spacing=0.06, specs=[ [{"type": "xy"}], - [{"type": "xy"}], # host the rangeslider only (no data) + [{"type": "xy"}], # host the rangeslider only (no data) [{"type": "table"}], ], ) @@ -669,15 +747,12 @@ def make_plot( fig_height = 600 # ---------- Main series ---------- - TraceCls = go.Scattergl if (use_webgl or len(df) > 20000) else go.Scatter + TraceCls = go.Scattergl if (use_webgl and len(df) > 20000) else go.Scatter sat_legend_shown: Dict[str, bool] = {} traces = [] # Natural satellite order (e.g., G01 < G02 < ... < R01 ...) - sorted_groups = sorted( - df.groupby(["sat", "label"], sort=False), - key=lambda kv: _sat_sort_key(kv[0][0]) - ) + sorted_groups = sorted(df.groupby(["sat", "label"], sort=False), key=lambda kv: _sat_sort_key(kv[0][0])) for (sat, label), g in sorted_groups: if g.empty: continue @@ -686,14 +761,16 @@ def make_plot( x = g["datetime"].to_numpy("datetime64[ns]") y = pd.to_numeric(g[residual_field], errors="coerce").to_numpy() - cd = np.column_stack([ - g["sat"].to_numpy(object), - g["recv"].to_numpy(object), - g["sig"].to_numpy(object), - pd.to_numeric(g["sigma"], errors="coerce").to_numpy(), - g["meas"].to_numpy(object), - g["label"].to_numpy(object), - ]) + cd = np.column_stack( + [ + g["sat"].to_numpy(object), + g["recv"].to_numpy(object), + g["sig"].to_numpy(object), + pd.to_numeric(g["sigma"], errors="coerce").to_numpy(), + g["meas"].to_numpy(object), + g["label"].to_numpy(object), + ] + ) x2, y2, cd2 = _insert_gap_breaks_multi(x, y, cd, gap_seconds=3600.0) @@ -702,8 +779,12 @@ def make_plot( traces.append( TraceCls( - x=x2, y=y2, mode="lines+markers", - name=str(sat), legendgroup=str(sat), showlegend=show_leg, + x=x2, + y=y2, + mode="lines+markers", + name=str(sat), + legendgroup=str(sat), + showlegend=show_leg, customdata=cd2, hovertemplate=( "Time=%{x|%Y-%m-%d %H:%M:%S.%f}
" @@ -727,7 +808,8 @@ def make_plot( y_vals = pd.to_numeric(df[residual_field], errors="coerce").to_numpy() finite = np.isfinite(y_vals) if finite.any(): - y_min = float(np.min(y_vals[finite])); y_max = float(np.max(y_vals[finite])) + y_min = float(np.min(y_vals[finite])) + y_max = float(np.max(y_vals[finite])) if y_max == y_min: y_min, y_max = y_min - 1.0, y_max + 1.0 else: @@ -735,18 +817,19 @@ def make_plot( y_rng = max(1e-12, (y_max - y_min)) # Marker anchors & v-line span - y_line_pad = 0.02 * y_rng + y_line_pad = 0.02 * y_rng # separate lanes for ambiguity markers: - y_top_marker_amb_preproc = y_max + 0.05 * y_rng # PREPROC (green) higher - y_top_marker_amb_reject = y_max + 0.04 * y_rng # REJECT (blue) a bit lower - y_top_marker_error = y_max + 0.03 * y_rng # large errors (▲/▼) below those - y_line_lo = y_min - y_line_pad - y_line_hi = max(y_max + y_line_pad, y_top_marker_amb_preproc + 0.02 * y_rng) + y_top_marker_amb_preproc = y_max + 0.05 * y_rng # PREPROC (green) higher + y_top_marker_amb_reject = y_max + 0.04 * y_rng # REJECT (blue) a bit lower + y_top_marker_error = y_max + 0.03 * y_rng # large errors (▲/▼) below those + y_line_lo = y_min - y_line_pad + y_line_hi = max(y_max + y_line_pad, y_top_marker_amb_preproc + 0.02 * y_rng) def _add_vline_trace(dt, *, color="gray", dash="dash", group=None, width=0.5): add_trace_xy( go.Scatter( - x=[dt, dt], y=[y_line_lo, y_line_hi], + x=[dt, dt], + y=[y_line_lo, y_line_hi], mode="lines", line=dict(color=color, width=width, dash=dash), hoverinfo="skip", @@ -757,7 +840,7 @@ def _add_vline_trace(dt, *, color="gray", dash="dash", group=None, width=0.5): # ---------- LARGE errors overlay ---------- if df_large is not None and not df_large.empty: - ctx_sat = (context or {}).get("sat") + ctx_sat = (context or {}).get("sat") ctx_recv = (context or {}).get("recv") ctx_meas = (context or {}).get("meas_type") @@ -767,8 +850,10 @@ def _add_vline_trace(dt, *, color="gray", dash="dash", group=None, width=0.5): if ctx_sat: dfL = dfL[(dfL["kind"] == "MEAS") & (dfL["sat"] == ctx_sat)] elif ctx_recv: - dfL = dfL[((dfL["kind"] == "MEAS") & (dfL["recv"] == ctx_recv)) | - ((dfL["kind"] == "STATE") & (dfL["recv"] == ctx_recv))] + dfL = dfL[ + ((dfL["kind"] == "MEAS") & (dfL["recv"] == ctx_recv)) + | ((dfL["kind"] == "STATE") & (dfL["recv"] == ctx_recv)) + ] for _, row in dfL.iterrows(): if row["kind"] == "STATE": @@ -782,7 +867,8 @@ def _add_vline_trace(dt, *, color="gray", dash="dash", group=None, width=0.5): _add_vline_trace(row["datetime"], color="orange", dash="dot", group=ctx_sat, width=0.6) add_trace_xy( go.Scatter( - x=[row["datetime"]], y=[y_top_marker_error], + x=[row["datetime"]], + y=[y_top_marker_error], mode="markers", marker=dict(color="orange", size=10, symbol="triangle-down"), hovertemplate=hovertext + "", @@ -821,7 +907,8 @@ def _add_vline_trace(dt, *, color="gray", dash="dash", group=None, width=0.5): add_trace_xy( go.Scatter( - x=[row["datetime"]], y=[y_val], + x=[row["datetime"]], + y=[y_val], mode="markers", marker=dict(color="black", size=10, symbol="triangle-up"), hovertemplate=hovertext + "", @@ -832,11 +919,11 @@ def _add_vline_trace(dt, *, color="gray", dash="dash", group=None, width=0.5): # ---------- Ambiguity resets (PHAS_MEAS only) ---------- ctx_recv = (context or {}).get("recv") - ctx_sat = (context or {}).get("sat") + ctx_sat = (context or {}).get("sat") ctx_meas = (context or {}).get("meas_type") amb_overlay = pd.DataFrame() - amb_counts = pd.DataFrame() + amb_counts = pd.DataFrame() if ctx_meas == "PHAS_MEAS" and df_amb is not None and not df_amb.empty: # Start from the single source df (already schema-checked in main/make_plot) @@ -850,7 +937,7 @@ def _add_vline_trace(dt, *, color="gray", dash="dash", group=None, width=0.5): if ctx_recv: amb_src = amb_src[amb_src["recv"].str.upper() == str(ctx_recv).upper()] if ctx_sat: - amb_src = amb_src[amb_src["sat"].str.upper() == str(ctx_sat).upper()] + amb_src = amb_src[amb_src["sat"].str.upper() == str(ctx_sat).upper()] # ---- 1) Overlays: keep per-signal rows for detailed hover text ---- # Don't aggregate here - let add_ambiguity_markers_combined create one marker @@ -890,7 +977,7 @@ def _add_vline_trace(dt, *, color="gray", dash="dash", group=None, width=0.5): y_span=(y_line_lo, y_line_hi), line_width=0.5, marker_size=11, - line_color_preproc="rgba(0,128,0,1.0)", # unused in this call + line_color_preproc="rgba(0,128,0,1.0)", # unused in this call line_color_reject="rgba(65,105,225,1.0)", split_by_reason=True, split_context=split_ctx, @@ -913,50 +1000,61 @@ def _add_vline_trace(dt, *, color="gray", dash="dash", group=None, width=0.5): # ---------- Stats table (with per-label counts) ---------- if show_stats: work = df.copy() - work["_y"] = pd.to_numeric(work[residual_field], errors="coerce") - work["_sigma"] = pd.to_numeric(work["sigma"], errors="coerce") + work["_y"] = pd.to_numeric(work[residual_field], errors="coerce") + work["_sigma"] = pd.to_numeric(work["sigma"], errors="coerce") work_w = work[np.isfinite(work["_y"]) & np.isfinite(work["_sigma"]) & (work["_sigma"] > 0)].copy() work_u = work[np.isfinite(work["_y"])].copy() def _uw_stats(grp: pd.DataFrame): - y = grp["_y"].to_numpy(float); n = y.size - if n == 0: return pd.Series({"N": 0, "Mean": np.nan, "Std": np.nan, "RMS": np.nan}) - return pd.Series({ - "N": int(n), - "Mean": float(np.nanmean(y)), - "Std": float(np.nanstd(y, ddof=1)) if n > 1 else np.nan, - "RMS": float(np.sqrt(np.nanmean(y**2))), - }) + y = grp["_y"].to_numpy(float) + n = y.size + if n == 0: + return pd.Series({"N": 0, "Mean": np.nan, "Std": np.nan, "RMS": np.nan}) + return pd.Series( + { + "N": int(n), + "Mean": float(np.nanmean(y)), + "Std": float(np.nanstd(y, ddof=1)) if n > 1 else np.nan, + "RMS": float(np.sqrt(np.nanmean(y**2))), + } + ) def _w_stats(grp: pd.DataFrame): y = grp["_y"].to_numpy(float) w = 1.0 / (grp["_sigma"].to_numpy(float) ** 2) - sw = float(w.sum()); n = y.size - if n == 0 or sw <= 0: return pd.Series({"WMean": np.nan, "WStd": np.nan, "WRMS": np.nan}) - mu = float((w * y).sum() / sw) + sw = float(w.sum()) + n = y.size + if n == 0 or sw <= 0: + return pd.Series({"WMean": np.nan, "WStd": np.nan, "WRMS": np.nan}) + mu = float((w * y).sum() / sw) var = float((w * (y - mu) ** 2).sum() / sw) - return pd.Series({"WMean": mu, "WStd": float(np.sqrt(var)), "WRMS": float(np.sqrt((w * (y ** 2)).sum() / sw))}) + return pd.Series( + {"WMean": mu, "WStd": float(np.sqrt(var)), "WRMS": float(np.sqrt((w * (y**2)).sum() / sw))} + ) try: uw = work_u.groupby("label", sort=False).apply(_uw_stats, include_groups=False).reset_index() except TypeError: - uw = work_u.groupby("label", sort=False, group_keys=False)\ - .apply(lambda g: _uw_stats(g.drop(columns=["label"])) )\ - .reset_index() + uw = ( + work_u.groupby("label", sort=False, group_keys=False) + .apply(lambda g: _uw_stats(g.drop(columns=["label"]))) + .reset_index() + ) if not work_w.empty: try: w = work_w.groupby("label", sort=False).apply(_w_stats, include_groups=False).reset_index() except TypeError: - w = work_w.groupby("label", sort=False, group_keys=False)\ - .apply(lambda g: _w_stats(g.drop(columns=["label"])) )\ - .reset_index() + w = ( + work_w.groupby("label", sort=False, group_keys=False) + .apply(lambda g: _w_stats(g.drop(columns=["label"]))) + .reset_index() + ) else: w = pd.DataFrame({"label": [], "WMean": [], "WStd": [], "WRMS": []}) - stats = pd.merge(uw, w, on="label", how="outer") - labels = stats["label"].astype(str).tolist() if not stats.empty \ - else sorted(df["label"].astype(str).unique()) + stats = pd.merge(uw, w, on="label", how="outer") + labels = stats["label"].astype(str).tolist() if not stats.empty else sorted(df["label"].astype(str).unique()) # ---- Per-label counts (only for PHAS_MEAS pages) ---- pp_map, kf_map = {}, {} @@ -971,8 +1069,10 @@ def _w_stats(grp: pd.DataFrame): me_map = {} if df_large is not None and not df_large.empty and ctx_meas == "PHAS_MEAS": dfl = df_large.copy() - if ctx_recv: dfl = dfl[dfl["recv"].astype(str) == str(ctx_recv)] - if ctx_sat: dfl = dfl[dfl["sat"].astype(str) == str(ctx_sat)] + if ctx_recv: + dfl = dfl[dfl["recv"].astype(str) == str(ctx_recv)] + if ctx_sat: + dfl = dfl[dfl["sat"].astype(str) == str(ctx_sat)] dfl_me = dfl[(dfl["kind"] == "MEAS") & (dfl["meas_type"] == "PHAS_MEAS")] if not dfl_me.empty and "sig" in dfl_me.columns: vc_me = ("L-" + dfl_me["sig"].astype(str)).value_counts() @@ -982,7 +1082,8 @@ def _w_stats(grp: pd.DataFrame): state_total = 0 if df_large is not None and not df_large.empty: dfl2 = df_large.copy() - if ctx_recv: dfl2 = dfl2[dfl2["recv"].astype(str) == str(ctx_recv)] + if ctx_recv: + dfl2 = dfl2[dfl2["recv"].astype(str) == str(ctx_recv)] state_total = int(dfl2[dfl2["kind"] == "STATE"].shape[0]) # Build columns aligned to the table’s label order @@ -992,26 +1093,39 @@ def _w_stats(grp: pd.DataFrame): se_col = [str(state_total) for _ in labels] headers = [ - "Signal", "N", - "N PP_AR", "N KF_AR", "N M_ERR", "N S_ERR", - "Mean", "Std", "RMS", "WMean", "WStd", "WRMS" + "Signal", + "N", + "N PP_AR", + "N KF_AR", + "N M_ERR", + "N S_ERR", + "Mean", + "Std", + "RMS", + "WMean", + "WStd", + "WRMS", ] def fmt(x): - try: return f"{x:.3f}" - except Exception: return "" + try: + return f"{x:.3f}" + except Exception: + return "" cells = [ labels, - [str(int(n)) if pd.notna(n) else "0" - for n in stats.get("N", pd.Series(dtype=float)).fillna(0)], - pp_col, kf_col, me_col, se_col, - [fmt(v) for v in stats.get("Mean", pd.Series(dtype=float))], - [fmt(v) for v in stats.get("Std", pd.Series(dtype=float))], - [fmt(v) for v in stats.get("RMS", pd.Series(dtype=float))], + [str(int(n)) if pd.notna(n) else "0" for n in stats.get("N", pd.Series(dtype=float)).fillna(0)], + pp_col, + kf_col, + me_col, + se_col, + [fmt(v) for v in stats.get("Mean", pd.Series(dtype=float))], + [fmt(v) for v in stats.get("Std", pd.Series(dtype=float))], + [fmt(v) for v in stats.get("RMS", pd.Series(dtype=float))], [fmt(v) for v in stats.get("WMean", pd.Series(dtype=float))], - [fmt(v) for v in stats.get("WStd", pd.Series(dtype=float))], - [fmt(v) for v in stats.get("WRMS", pd.Series(dtype=float))], + [fmt(v) for v in stats.get("WStd", pd.Series(dtype=float))], + [fmt(v) for v in stats.get("WRMS", pd.Series(dtype=float))], ] fig.add_trace( @@ -1019,30 +1133,36 @@ def fmt(x): header=dict(values=headers, fill_color="#f2f2f2", align="left"), cells=dict(values=cells, align="left"), ), - row=table_row, col=1 + row=table_row, + col=1, ) # ---------- Axes / slider / zoom ---------- if show_stats: # Row 1 (main): no rangeslider; keep quick range buttons here fig.update_xaxes( - row=1, col=1, - rangeslider=dict(visible=False), + row=1, + col=1, + rangeslider=dict(visible=True), rangeselector=dict( - y=0.98, yanchor="bottom", # nudge below title if desired + y=0.98, + yanchor="bottom", # nudge below title if desired buttons=[ - dict(count=1, label="1h", step="hour", stepmode="backward"), - dict(count=6, label="6h", step="hour", stepmode="backward"), + dict(count=1, label="1h", step="hour", stepmode="backward"), + dict(count=6, label="6h", step="hour", stepmode="backward"), dict(count=12, label="12h", step="hour", stepmode="backward"), - dict(count=1, label="1d", step="day", stepmode="backward"), + dict(count=1, label="1d", step="day", stepmode="backward"), dict(step="all", label="All"), ], ), ) # Row 2: the slider lane (linked to row 1) fig.update_xaxes( - row=2, col=1, - showgrid=False, zeroline=False, showticklabels=False, + row=2, + col=1, + showgrid=False, + zeroline=False, + showticklabels=False, rangeslider=dict(visible=True, thickness=0.20), matches="x1", ) @@ -1055,12 +1175,13 @@ def fmt(x): fig.update_xaxes( rangeslider=dict(visible=True, thickness=0.10), rangeselector=dict( - y=0.98, yanchor="bottom", + y=0.98, + yanchor="bottom", buttons=[ - dict(count=1, label="1h", step="hour", stepmode="backward"), - dict(count=6, label="6h", step="hour", stepmode="backward"), + dict(count=1, label="1h", step="hour", stepmode="backward"), + dict(count=6, label="6h", step="hour", stepmode="backward"), dict(count=12, label="12h", step="hour", stepmode="backward"), - dict(count=1, label="1d", step="day", stepmode="backward"), + dict(count=1, label="1d", step="day", stepmode="backward"), dict(step="all", label="All"), ], ), @@ -1069,8 +1190,11 @@ def fmt(x): # ---------- Final layout ---------- fig.update_layout(dragmode="zoom") # x+y box zoom hover_mode = "x unified" if hover_unified else "closest" - axis_label = "Normalised residual (res/σ)" if residual_field.startswith("norm_") \ - else f"{residual_field.capitalize()} residual" + axis_label = ( + "Normalised residual (res/σ)" + if residual_field.startswith("norm_") + else f"{residual_field.capitalize()} residual" + ) fig.update_layout( title=title, @@ -1091,11 +1215,14 @@ def fmt(x): return fig -def write_index_html(index_path: Path, - base_title: str, - meas_map: Dict[str, List[Tuple[str, str]]], - meta: Dict[str, str], - item_kind: str = "sat") -> None: + +def write_index_html( + index_path: Path, + base_title: str, + meas_map: Dict[str, List[Tuple[str, str]]], + meta: Dict[str, str], + item_kind: str = "sat", +) -> None: """ Write a lightweight index HTML with CODE and PHASE sections. Links are written as paths relative to the index file location. @@ -1117,16 +1244,16 @@ def write_index_html(index_path: Path, meas_map = {k: sorted(v, key=sort_fn) for k, v in meas_map.items()} def html_escape(s: str) -> str: - return (str(s).replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace('"', """) - .replace("'", "'")) - - meta_rows = "".join( - f"{html_escape(k)}{html_escape(v)}" - for k, v in meta.items() if v - ) + return ( + str(s) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + meta_rows = "".join(f"{html_escape(k)}{html_escape(v)}" for k, v in meta.items() if v) def _rel_href(target: str) -> str: """Return path to target relative to the index file directory.""" @@ -1146,15 +1273,15 @@ def section(meas_key: str, title: str) -> str: ( f'
  • ' f'' - f'{html_escape(item)} — {title} plot' - f'
  • ' + f"{html_escape(item)} — {title} plot" + f"" ) for item, fname in items ) return f'

    {title}

      \n{lis}\n
    ' - code_sec = section("code", "CODE") + code_sec = section("code", "CODE") phase_sec = section("phase", "PHASE") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -1249,13 +1376,13 @@ def build_recv_sat_stats(df: pd.DataFrame, yfield: str, weighted: bool) -> Dict[ if df is None or df.empty: return {"mean": pd.DataFrame(), "std": pd.DataFrame(), "rms": pd.DataFrame()} - work = df[[ "recv","sat", yfield, "sigma" ]].copy() + work = df[["recv", "sat", yfield, "sigma"]].copy() y = pd.to_numeric(work[yfield], errors="coerce").to_numpy() sig = pd.to_numeric(work["sigma"], errors="coerce").to_numpy() # masks: keep finite (and positive w when weighted) if weighted: - w = 1.0 / (sig ** 2) + w = 1.0 / (sig**2) m = np.isfinite(y) & np.isfinite(w) & (w > 0) else: w = None @@ -1265,12 +1392,12 @@ def build_recv_sat_stats(df: pd.DataFrame, yfield: str, weighted: bool) -> Dict[ y = y[m] recv_vals = work.loc[m, "recv"].astype(str).to_numpy() - sat_vals = work.loc[m, "sat"].astype(str).to_numpy() - w = (w[m] if weighted else None) + sat_vals = work.loc[m, "sat"].astype(str).to_numpy() + w = w[m] if weighted else None # Factorize to integer codes (preserves first-seen order) recv_codes, recv_uniques = pd.factorize(recv_vals, sort=False) - sat_codes, sat_uniques = pd.factorize(sat_vals, sort=False) + sat_codes, sat_uniques = pd.factorize(sat_vals, sort=False) R = recv_uniques.size S = sat_uniques.size @@ -1279,94 +1406,94 @@ def build_recv_sat_stats(df: pd.DataFrame, yfield: str, weighted: bool) -> Dict[ # Unweighted tallies if not weighted: - cnt = np.bincount(flat, minlength=R*S).astype(float) - sumy = np.bincount(flat, weights=y, minlength=R*S) - sumy2 = np.bincount(flat, weights=y*y, minlength=R*S) + cnt = np.bincount(flat, minlength=R * S).astype(float) + sumy = np.bincount(flat, weights=y, minlength=R * S) + sumy2 = np.bincount(flat, weights=y * y, minlength=R * S) - cnt2D = cnt.reshape(R, S) - sum2D = sumy.reshape(R, S) + cnt2D = cnt.reshape(R, S) + sum2D = sumy.reshape(R, S) sum22D = sumy2.reshape(R, S) # Mean with np.errstate(invalid="ignore", divide="ignore"): mean = np.where(cnt2D > 0, sum2D / cnt2D, np.nan) # Sample variance (ddof=1) where cnt>1 - var = np.where(cnt2D > 1, (sum22D - (sum2D*sum2D)/cnt2D) / (cnt2D - 1.0), np.nan) - std = np.sqrt(var) - rms = np.where(cnt2D > 0, np.sqrt(sum22D / cnt2D), np.nan) + var = np.where(cnt2D > 1, (sum22D - (sum2D * sum2D) / cnt2D) / (cnt2D - 1.0), np.nan) + std = np.sqrt(var) + rms = np.where(cnt2D > 0, np.sqrt(sum22D / cnt2D), np.nan) # Margins (per-receiver = across columns, per-sat = across rows) - cnt_r = cnt2D.sum(axis=1) - sum_r = sum2D.sum(axis=1) + cnt_r = cnt2D.sum(axis=1) + sum_r = sum2D.sum(axis=1) sum2_r = sum22D.sum(axis=1) - cnt_s = cnt2D.sum(axis=0) - sum_s = sum2D.sum(axis=0) + cnt_s = cnt2D.sum(axis=0) + sum_s = sum2D.sum(axis=0) sum2_s = sum22D.sum(axis=0) # per-receiver margins mean_r = np.where(cnt_r > 0, sum_r / cnt_r, np.nan) - var_r = np.where(cnt_r > 1, (sum2_r - (sum_r*sum_r)/cnt_r) / (cnt_r - 1.0), np.nan) - std_r = np.sqrt(var_r) - rms_r = np.where(cnt_r > 0, np.sqrt(sum2_r / cnt_r), np.nan) + var_r = np.where(cnt_r > 1, (sum2_r - (sum_r * sum_r) / cnt_r) / (cnt_r - 1.0), np.nan) + std_r = np.sqrt(var_r) + rms_r = np.where(cnt_r > 0, np.sqrt(sum2_r / cnt_r), np.nan) # per-sat margins mean_s = np.where(cnt_s > 0, sum_s / cnt_s, np.nan) - var_s = np.where(cnt_s > 1, (sum2_s - (sum_s*sum_s)/cnt_s) / (cnt_s - 1.0), np.nan) - std_s = np.sqrt(var_s) - rms_s = np.where(cnt_s > 0, np.sqrt(sum2_s / cnt_s), np.nan) + var_s = np.where(cnt_s > 1, (sum2_s - (sum_s * sum_s) / cnt_s) / (cnt_s - 1.0), np.nan) + std_s = np.sqrt(var_s) + rms_s = np.where(cnt_s > 0, np.sqrt(sum2_s / cnt_s), np.nan) # grand - cnt_all = cnt2D.sum() - sum_all = sum2D.sum() + cnt_all = cnt2D.sum() + sum_all = sum2D.sum() sum2_all = sum22D.sum() mean_all = (sum_all / cnt_all) if cnt_all > 0 else np.nan - var_all = ((sum2_all - (sum_all*sum_all)/cnt_all) / (cnt_all - 1.0)) if cnt_all > 1 else np.nan - std_all = np.sqrt(var_all) if np.isfinite(var_all) else np.nan - rms_all = np.sqrt(sum2_all / cnt_all) if cnt_all > 0 else np.nan + var_all = ((sum2_all - (sum_all * sum_all) / cnt_all) / (cnt_all - 1.0)) if cnt_all > 1 else np.nan + std_all = np.sqrt(var_all) if np.isfinite(var_all) else np.nan + rms_all = np.sqrt(sum2_all / cnt_all) if cnt_all > 0 else np.nan else: # Weighted tallies - wsum = np.bincount(flat, weights=w, minlength=R*S) - wysum = np.bincount(flat, weights=w*y, minlength=R*S) - wy2sum = np.bincount(flat, weights=w*y*y, minlength=R*S) + wsum = np.bincount(flat, weights=w, minlength=R * S) + wysum = np.bincount(flat, weights=w * y, minlength=R * S) + wy2sum = np.bincount(flat, weights=w * y * y, minlength=R * S) - wsum2D = wsum.reshape(R, S) + wsum2D = wsum.reshape(R, S) wysum2D = wysum.reshape(R, S) - wy22D = wy2sum.reshape(R, S) + wy22D = wy2sum.reshape(R, S) with np.errstate(invalid="ignore", divide="ignore"): mean = np.where(wsum2D > 0, wysum2D / wsum2D, np.nan) - var = np.where(wsum2D > 0, (wy22D / wsum2D) - mean*mean, np.nan) # your previous definition - std = np.sqrt(var) - rms = np.where(wsum2D > 0, np.sqrt(wy22D / wsum2D), np.nan) + var = np.where(wsum2D > 0, (wy22D / wsum2D) - mean * mean, np.nan) # your previous definition + std = np.sqrt(var) + rms = np.where(wsum2D > 0, np.sqrt(wy22D / wsum2D), np.nan) # Margins - wsum_r = wsum2D.sum(axis=1) + wsum_r = wsum2D.sum(axis=1) wysum_r = wysum2D.sum(axis=1) - wy2_r = wy22D.sum(axis=1) + wy2_r = wy22D.sum(axis=1) - wsum_s = wsum2D.sum(axis=0) + wsum_s = wsum2D.sum(axis=0) wysum_s = wysum2D.sum(axis=0) - wy2_s = wy22D.sum(axis=0) + wy2_s = wy22D.sum(axis=0) mean_r = np.where(wsum_r > 0, wysum_r / wsum_r, np.nan) - var_r = np.where(wsum_r > 0, (wy2_r / wsum_r) - mean_r*mean_r, np.nan) - std_r = np.sqrt(var_r) - rms_r = np.where(wsum_r > 0, np.sqrt(wy2_r / wsum_r), np.nan) + var_r = np.where(wsum_r > 0, (wy2_r / wsum_r) - mean_r * mean_r, np.nan) + std_r = np.sqrt(var_r) + rms_r = np.where(wsum_r > 0, np.sqrt(wy2_r / wsum_r), np.nan) mean_s = np.where(wsum_s > 0, wysum_s / wsum_s, np.nan) - var_s = np.where(wsum_s > 0, (wy2_s / wsum_s) - mean_s*mean_s, np.nan) - std_s = np.sqrt(var_s) - rms_s = np.where(wsum_s > 0, np.sqrt(wy2_s / wsum_s), np.nan) + var_s = np.where(wsum_s > 0, (wy2_s / wsum_s) - mean_s * mean_s, np.nan) + std_s = np.sqrt(var_s) + rms_s = np.where(wsum_s > 0, np.sqrt(wy2_s / wsum_s), np.nan) - wsum_all = wsum2D.sum() + wsum_all = wsum2D.sum() wysum_all = wysum2D.sum() - wy2_all = wy22D.sum() - mean_all = (wysum_all / wsum_all) if wsum_all > 0 else np.nan - var_all = ((wy2_all / wsum_all) - mean_all*mean_all) if wsum_all > 0 else np.nan - std_all = np.sqrt(var_all) if np.isfinite(var_all) else np.nan - rms_all = np.sqrt(wy2_all / wsum_all) if wsum_all > 0 else np.nan + wy2_all = wy22D.sum() + mean_all = (wysum_all / wsum_all) if wsum_all > 0 else np.nan + var_all = ((wy2_all / wsum_all) - mean_all * mean_all) if wsum_all > 0 else np.nan + std_all = np.sqrt(var_all) if np.isfinite(var_all) else np.nan + rms_all = np.sqrt(wy2_all / wsum_all) if wsum_all > 0 else np.nan # Build matrices with margins at top/left def _mk_df(mcore: np.ndarray, row_margin: np.ndarray, col_margin: np.ndarray, grand: float) -> pd.DataFrame: @@ -1376,10 +1503,10 @@ def _mk_df(mcore: np.ndarray, row_margin: np.ndarray, col_margin: np.ndarray, gr sats_sorted = sorted(list(sat_uniques), key=_sat_sort_key) # map sat_uniques -> order indices sat_order = np.array([np.where(sat_uniques == s)[0][0] for s in sats_sorted], dtype=int) - m_sorted = mcore[:, sat_order] + m_sorted = mcore[:, sat_order] # left column (per-recv margin) - col_left = row_margin.reshape(-1, 1) + col_left = row_margin.reshape(-1, 1) body_with_left = np.concatenate([col_left, m_sorted], axis=1) # top row (per-sat margin) @@ -1388,12 +1515,12 @@ def _mk_df(mcore: np.ndarray, row_margin: np.ndarray, col_margin: np.ndarray, gr full = np.concatenate([top_row, body_with_left], axis=0) rows = ["ALL_RECV"] + [str(r) for r in recv_uniques.tolist()] - cols = ["ALL_SAT"] + [str(s) for s in sats_sorted] + cols = ["ALL_SAT"] + [str(s) for s in sats_sorted] return pd.DataFrame(full, index=rows, columns=cols) mean_df = _mk_df(mean, mean_r, mean_s, mean_all) - std_df = _mk_df(std, std_r, std_s, std_all) - rms_df = _mk_df(rms, rms_r, rms_s, rms_all) + std_df = _mk_df(std, std_r, std_s, std_all) + rms_df = _mk_df(rms, rms_r, rms_s, rms_all) return {"mean": mean_df, "std": std_df, "rms": rms_df} @@ -1408,7 +1535,7 @@ def _counts_to_customdata(counts_sorted: Optional[pd.DataFrame], z_shape: tuple) if cd.shape != z_shape: logger.warning("Counts shape %s != Z shape %s; omitting customdata.", cd.shape, z_shape) return None - return cd[..., np.newaxis] # -> (rows, cols, 1) + return cd[..., np.newaxis] # -> (rows, cols, 1) except Exception as e: logger.warning("Failed to build numeric customdata: %s", e) return None @@ -1424,16 +1551,16 @@ def write_heatmap_html( zmin: Optional[float] = None, zmax: Optional[float] = None, # Annotation knobs: - annotate: str = "none", # "none" | "auto" | "all" | "margins" (bool True->"all", False->"none") + annotate: str = "none", # "none" | "auto" | "all" | "margins" (bool True->"all", False->"none") precision: int = 3, max_annot_cells: int = 2500, # Hover counts matrix (aligned to df_mat's index/columns) counts: Optional[pd.DataFrame] = None, # sorting controls - row_sort: str = "alpha", # "alpha" | "none" - sat_sort: str = "alpha", # "alpha" | "natural" | "none" + row_sort: str = "alpha", # "alpha" | "none" + sat_sort: str = "alpha", # "alpha" | "natural" | "none" # html writing - include_plotlyjs: str = "inline", # "inline" (default) or "cdn" + include_plotlyjs: str = "inline", # "inline" (default) or "cdn" ) -> str: """ Render a heatmap with optional annotations, sorted rows/columns, and robust hover counts. @@ -1463,7 +1590,7 @@ def write_heatmap_html( row_header, row_body = ["ALL_RECV"], rows[1:] else: row_header = [r for r in rows if r == "ALL_RECV"] - row_body = [r for r in rows if r != "ALL_RECV"] + row_body = [r for r in rows if r != "ALL_RECV"] if row_sort == "alpha": row_body_sorted = sorted(row_body, key=lambda s: str(s).upper()) @@ -1476,7 +1603,7 @@ def write_heatmap_html( col_header, col_body = ["ALL_SAT"], cols[1:] else: col_header = [c for c in cols if c == "ALL_SAT"] - col_body = [c for c in cols if c != "ALL_SAT"] + col_body = [c for c in cols if c != "ALL_SAT"] if sat_sort == "alpha": col_body_sorted = sorted(col_body, key=lambda s: str(s).upper()) @@ -1488,7 +1615,9 @@ def write_heatmap_html( # --- Reindex matrices to this order (keeps alignment with hover counts) --- mat_sorted = df_mat.reindex(index=rows_order, columns=cols_order) - counts_sorted = counts.reindex(index=rows_order, columns=cols_order) if (counts is not None and not counts.empty) else None + counts_sorted = ( + counts.reindex(index=rows_order, columns=cols_order) if (counts is not None and not counts.empty) else None + ) # --- Extract arrays --- Z = mat_sorted.values @@ -1532,15 +1661,13 @@ def write_heatmap_html( "" ) else: - hovertemplate = ( - "Receiver=%{y}
    Satellite=%{x}" - f"
    Value=%{{z:.{precision}f}}" - "" - ) + hovertemplate = "Receiver=%{y}
    Satellite=%{x}" f"
    Value=%{{z:.{precision}f}}" "" # --- Heatmap trace --- hm_kwargs = dict( - z=Z, x=X, y=Y, + z=Z, + x=X, + y=Y, colorscale=colorscale, reversescale=reversescale, hoverongaps=False, @@ -1548,9 +1675,12 @@ def write_heatmap_html( ) if customdata is not None: hm_kwargs["customdata"] = customdata - if zmid is not None: hm_kwargs["zmid"] = zmid - if zmin is not None: hm_kwargs["zmin"] = zmin - if zmax is not None: hm_kwargs["zmax"] = zmax + if zmid is not None: + hm_kwargs["zmid"] = zmid + if zmin is not None: + hm_kwargs["zmin"] = zmin + if zmax is not None: + hm_kwargs["zmax"] = zmax if do_text: hm_kwargs["text"] = text hm_kwargs["texttemplate"] = "%{text}" @@ -1703,12 +1833,7 @@ def plot_ambiguity_reason_counts( if g.empty: continue - counts = ( - g.groupby(["datetime", "reason"], sort=True) - .size() - .reset_index(name="count") - .sort_values("datetime") - ) + counts = g.groupby(["datetime", "reason"], sort=True).size().reset_index(name="count").sort_values("datetime") counts["cumcount"] = counts.groupby("reason")["count"].cumsum() pivot = counts.pivot(index="datetime", columns="reason", values="cumcount").ffill().fillna(0) @@ -1720,20 +1845,19 @@ def plot_ambiguity_reason_counts( if key in event_dict and not event_dict[key].empty: g_events = event_dict[key] event_counts = ( - g_events.groupby("datetime", sort=True) - .size() - .reset_index(name="count") - .sort_values("datetime") + g_events.groupby("datetime", sort=True).size().reset_index(name="count").sort_values("datetime") ) event_counts["cumcount"] = event_counts["count"].cumsum() - fig.add_trace(go.Scatter( - x=event_counts["datetime"], - y=event_counts["cumcount"], - mode="lines", - name="Unique Resets", - line=dict(dash="dash", width=2.5), - )) + fig.add_trace( + go.Scatter( + x=event_counts["datetime"], + y=event_counts["cumcount"], + mode="lines", + name="Unique Resets", + line=dict(dash="dash", width=2.5), + ) + ) title_suffix = { "recv": f"Receiver {key}", @@ -1789,16 +1913,15 @@ def plot_ambiguity_reason_totals( if key_name is None: gdf = df.groupby("reason", sort=True).size().rename("count").reset_index() gdf["group"] = "ALL" # AJC - Add group column for pivot - pivot = gdf.pivot_table(index="group", - columns="reason", values="count", fill_value=0) + pivot = gdf.pivot_table(index="group", columns="reason", values="count", fill_value=0) else: pivot = ( df.groupby([key_name, "reason"], sort=True) - .size() - .rename("count") - .reset_index() - .pivot(index=key_name, columns="reason", values="count") - .fillna(0) + .size() + .rename("count") + .reset_index() + .pivot(index=key_name, columns="reason", values="count") + .fillna(0) ) reason_totals = pivot.sum(axis=0).sort_values(ascending=False) @@ -1822,7 +1945,7 @@ def plot_ambiguity_reason_totals( fig = go.Figure() groups = pivot.index.astype(str).tolist() - is_h = (orientation == "h") + is_h = orientation == "h" x_title = "Total resets" if is_h else (key_name or "All") y_title = (key_name or "All") if is_h else "Total resets" @@ -1838,26 +1961,30 @@ def plot_ambiguity_reason_totals( if is_h: # Horizontal bars: place star markers at unique reset count position - fig.add_trace(go.Scatter( - x=unique_reset_vals, - y=groups, - mode='markers', - marker=dict(symbol='star', size=12, color='gold', line=dict(width=0)), - showlegend=True, - name='Unique Resets', - hovertemplate='Unique Resets: %{x}', - )) + fig.add_trace( + go.Scatter( + x=unique_reset_vals, + y=groups, + mode="markers", + marker=dict(symbol="star", size=12, color="gold", line=dict(width=0)), + showlegend=True, + name="Unique Resets", + hovertemplate="Unique Resets: %{x}", + ) + ) else: # Vertical bars: place star markers at unique reset count position - fig.add_trace(go.Scatter( - x=groups, - y=unique_reset_vals, - mode='markers', - marker=dict(symbol='star', size=12, color='gold', line=dict(width=0)), - showlegend=True, - name='Unique Resets', - hovertemplate='Unique Resets: %{y}', - )) + fig.add_trace( + go.Scatter( + x=groups, + y=unique_reset_vals, + mode="markers", + marker=dict(symbol="star", size=12, color="gold", line=dict(width=0)), + showlegend=True, + name="Unique Resets", + hovertemplate="Unique Resets: %{y}", + ) + ) split_title = {"recv": "by Receiver", "sat": "by Satellite", "combined": "All"}[split] fig.update_layout( @@ -1887,14 +2014,14 @@ def add_ambiguity_markers_combined( df_amb: pd.DataFrame, *, y_anchor: Optional[float] = None, - y_span: Optional[Tuple[float, float]] = None, # (y_lo, y_hi) for the v-line span + y_span: Optional[Tuple[float, float]] = None, # (y_lo, y_hi) for the v-line span line_width: float = 0.5, marker_size: int = 10, - line_color_preproc: str = "rgba(0,128,0,0.85)", # green - line_color_reject: str = "rgba(65,105,225,0.85)", # royal blue + line_color_preproc: str = "rgba(0,128,0,0.85)", # green + line_color_reject: str = "rgba(65,105,225,0.85)", # royal blue split_by_reason: bool = True, split_context: Optional[str] = None, # "recv" or "sat" to indicate grouping - add_trace_fn = None, # Function to add traces (handles subplot layout) + add_trace_fn=None, # Function to add traces (handles subplot layout) ) -> None: """ Add ambiguity-reset overlays (PREPROC/REJECT) that toggle with each satellite's legend item. @@ -1962,13 +2089,12 @@ def _add_sat_vline(ts, y_lo, y_hi, sat, color="rgba(120,120,120,0.55)"): for _, row in sat_grp.iterrows(): lines.append(f"Sig= {row.get('sig','')} | Reasons= {row.get('reasons','(none)')}") rows_html = lines - hover_recv = g_act.iloc[0].get('recv', '') + hover_recv = g_act.iloc[0].get("recv", "") hover = ( "Phase Ambiguity Reset
    " f"Time: {ts}
    " f"Action: {action_val}
    " - f"Recv= {hover_recv}
    " - + "
    ".join(rows_html) + f"Recv= {hover_recv}
    " + "
    ".join(rows_html) ) else: # Combined/default: show both Sat and Recv for each signal @@ -1979,8 +2105,7 @@ def _add_sat_vline(ts, y_lo, y_hi, sat, color="rgba(120,120,120,0.55)"): hover = ( "Phase Ambiguity Reset
    " f"Time: {ts}
    " - f"Action: {action_val}
    " - + "
    ".join(rows_html) + f"Action: {action_val}
    " + "
    ".join(rows_html) ) # Use first satellite for legendgroup (allows toggling visibility) first_sat = g_act.iloc[0].get("sat", "") if not g_act.empty else "" @@ -2004,14 +2129,16 @@ def _add_sat_vline(ts, y_lo, y_hi, sat, color="rgba(120,120,120,0.55)"): if split_context == "recv": # Group by Action, then Sat, then show Sig | Reasons lines = [] - for act_key, act_grp in g_ts.groupby(g_ts["action"].str.upper() if "action" in g_ts.columns else "", sort=False): + for act_key, act_grp in g_ts.groupby( + g_ts["action"].str.upper() if "action" in g_ts.columns else "", sort=False + ): if act_key: lines.append(f"Action: {act_key}") for sat_key, sat_grp in act_grp.groupby("sat", sort=False): lines.append(f"Sat= {sat_key}") for _, row in sat_grp.iterrows(): lines.append(f"Sig= {row.get('sig','')} | Reasons= {row.get('reasons','(none)')}") - hover_recv = g_ts.iloc[0].get('recv', '') if not g_ts.empty else '' + hover_recv = g_ts.iloc[0].get("recv", "") if not g_ts.empty else "" hover = f"Phase Ambiguity Reset
    Time: {ts}
    Recv= {hover_recv}
    " + "
    ".join(lines) else: # Combined/default @@ -2064,13 +2191,12 @@ def _add_sat_vline(ts, y_lo, y_hi, sat, color="rgba(120,120,120,0.55)"): for _, row in recv_grp.iterrows(): lines.append(f"Sig= {row.get('sig','')} | Reasons= {row.get('reasons','(none)')}") rows_html = lines - hover_sat = g_act.iloc[0].get('sat', '') if not g_act.empty else '' + hover_sat = g_act.iloc[0].get("sat", "") if not g_act.empty else "" hover = ( "Phase Ambiguity Reset
    " f"Time: {ts}
    " f"Action: {action_val}
    " - f"Sat= {hover_sat}
    " - + "
    ".join(rows_html) + f"Sat= {hover_sat}
    " + "
    ".join(rows_html) ) # Use first satellite for legendgroup first_sat = g_act.iloc[0].get("sat", "") if not g_act.empty else "" @@ -2092,14 +2218,16 @@ def _add_sat_vline(ts, y_lo, y_hi, sat, color="rgba(120,120,120,0.55)"): # Group by Action, then Recv, then show Sig | Reasons lines = [] - for act_key, act_grp in g_ts.groupby(g_ts["action"].str.upper() if "action" in g_ts.columns else "", sort=False): + for act_key, act_grp in g_ts.groupby( + g_ts["action"].str.upper() if "action" in g_ts.columns else "", sort=False + ): if act_key: lines.append(f"Action: {act_key}") for recv_key, recv_grp in act_grp.groupby("recv", sort=False): lines.append(f"Recv= {recv_key}") for _, row in recv_grp.iterrows(): lines.append(f"Sig= {row.get('sig','')} | Reasons= {row.get('reasons','(none)')}") - hover_sat = g_ts.iloc[0].get('sat', '') if not g_ts.empty else '' + hover_sat = g_ts.iloc[0].get("sat", "") if not g_ts.empty else "" hover = f"Phase Ambiguity Reset
    Time: {ts}
    Sat= {hover_sat}
    " + "
    ".join(lines) if actions == {"PREPROC"}: @@ -2122,8 +2250,10 @@ def _add_sat_vline(ts, y_lo, y_hi, sat, color="rgba(120,120,120,0.55)"): ) ) + # -------- CLI / Main -------- + def pair_forward_smoothed_files(in_paths: List[Path], use_forward_residuals: bool): """ Separate and pair forward and smoothed TRACE files. @@ -2225,8 +2355,14 @@ def pair_forward_smoothed_files(in_paths: List[Path], use_forward_residuals: boo def main(): + """CLI entry point for plot_trace_res - parses arguments and calls _process_and_plot_trace().""" p = argparse.ArgumentParser(description="Extract and plot GNSS residuals with optional large-error markers.") - p.add_argument("--files", required=True, nargs="+", help="One or more TRACE files (space and/or comma separated), e.g. 'A.trace B.trace,C.trace' (wildcards allowed eg. *.TRACE)") + p.add_argument( + "--files", + required=True, + nargs="+", + help="One or more TRACE files (space and/or comma separated), e.g. 'A.trace B.trace,C.trace' (wildcards allowed eg. *.TRACE)", + ) p.add_argument("--residual", choices=["prefit", "postfit"], default="postfit") p.add_argument("--receivers", help="One or more receiver names (comma or space separated), e.g. 'ABMF,CHUR ALGO' ") p.add_argument("--sat", "-s", action="append", help="Filter by satellite ID") @@ -2241,41 +2377,243 @@ def main(): p.add_argument("--out-dir", help="Output directory for HTML files; defaults to CWD.") p.add_argument("--basename", help="Base filename prefix for outputs (no extension).") p.add_argument("--webgl", action="store_true") - p.add_argument("--log-level", default="INFO", choices=["DEBUG","INFO","WARNING","ERROR","CRITICAL"], help="Logging verbosity.") + p.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Logging verbosity.", + ) p.add_argument("--out-prefix", default=None) p.add_argument("--mark-large-errors", action="store_true", help="Mark LARGE STATE/MEAS ERROR events on plots.") - p.add_argument("--hover-unified", action="store_true", help="Use unified hover tooltips across all traces (default: closest point hover).") - p.add_argument("--plot-normalised-res", action="store_true", help="Also generate plots of normalised residuals (residual / sigma).") + p.add_argument( + "--hover-unified", + action="store_true", + help="Use unified hover tooltips across all traces (default: closest point hover).", + ) + p.add_argument( + "--plot-normalised-res", + action="store_true", + help="Also generate plots of normalised residuals (residual / sigma).", + ) p.add_argument("--plot-normalized-res", action="store_true", help=argparse.SUPPRESS) - p.add_argument("--show-stats-table", action="store_true", help="Add a Mean / Std / RMS table per (sat × signal) at the bottom of each plot.") - p.add_argument("--stats-matrix", action="store_true", help="Generate receiver×satellite heatmaps (Mean/Std/RMS) aggregated across signals.") - p.add_argument("--stats-matrix-weighted", action="store_true", help="Use sigma-weighted statistics in the heatmaps (weights 1/σ²).") - p.add_argument("--annotate-stats-matrix", action="store_true", help="Write the numeric value (mean/std/rms) into each stats heatmap cell. Hover still shows full details.") - p.add_argument("--mark-amb-resets", action="store_true", help="Overlay PHASE ambiguity reset events (PREPROC=green, REJECT=blue) on PHASE per-receiver plots.") + p.add_argument( + "--show-stats-table", + action="store_true", + help="Add a Mean / Std / RMS table per (sat × signal) at the bottom of each plot.", + ) + p.add_argument( + "--stats-matrix", + action="store_true", + help="Generate receiver×satellite heatmaps (Mean/Std/RMS) aggregated across signals.", + ) + p.add_argument( + "--stats-matrix-weighted", + action="store_true", + help="Use sigma-weighted statistics in the heatmaps (weights 1/σ²).", + ) + p.add_argument( + "--annotate-stats-matrix", + action="store_true", + help="Write the numeric value (mean/std/rms) into each stats heatmap cell. Hover still shows full details.", + ) + p.add_argument( + "--mark-amb-resets", + action="store_true", + help="Overlay PHASE ambiguity reset events (PREPROC=green, REJECT=blue) on PHASE per-receiver plots.", + ) # Ambiguity reset plots (includes both reasons and unique satellite resets) - p.add_argument("--ambiguity-counts", action="store_true", help="Plot cumulative counts of ambiguity reset reasons and unique satellite resets over time.") - p.add_argument("--ambiguity-totals", action="store_true", help="Bar chart of total ambiguity reset reasons (diagnostic view of detection methods).") + p.add_argument( + "--ambiguity-counts", + action="store_true", + help="Plot cumulative counts of ambiguity reset reasons and unique satellite resets over time.", + ) + p.add_argument( + "--ambiguity-totals", + action="store_true", + help="Bar chart of total ambiguity reset reasons (diagnostic view of detection methods).", + ) - p.add_argument("--amb-totals-orient", choices=["h", "v"], default="h", help="Orientation for totals bar charts: 'h' (horizontal, default) or 'v' (vertical).") - p.add_argument("--amb-totals-topn", type=int, default=None, help="Show only the top-N receivers/satellites by total resets (to avoid clutter).") - p.add_argument("--use-forward-residuals", action="store_true", help="Use residuals from forward (non-smoothed) files instead of smoothed files (default: use smoothed for more accurate residuals).") + p.add_argument( + "--amb-totals-orient", + choices=["h", "v"], + default="h", + help="Orientation for totals bar charts: 'h' (horizontal, default) or 'v' (vertical).", + ) + p.add_argument( + "--amb-totals-topn", + type=int, + default=None, + help="Show only the top-N receivers/satellites by total resets (to avoid clutter).", + ) + p.add_argument( + "--use-forward-residuals", + action="store_true", + help="Use residuals from forward (non-smoothed) files instead of smoothed files (default: use smoothed for more accurate residuals).", + ) args = p.parse_args() + + # Call the shared processing function (CLI mode splits on comma / space) + _process_and_plot_trace(args, from_cli=True) + + +def plot_trace_res_files( + files: List[str], + out_dir: str, + mark_amb_resets: bool = True, + mark_large_errors: bool = True, + show_stats_table: bool = True, + ambiguity_counts: bool = True, + ambiguity_totals: bool = True, + amb_totals_orient: str = "h", + residual: str = "postfit", + receivers: Optional[str] = None, + sat: Optional[List[str]] = None, + label_regex: Optional[str] = None, + max_abs: Optional[float] = None, + start: Optional[str] = None, + end: Optional[str] = None, + decimate: int = 1, + split_per_sat: bool = False, + split_per_recv: bool = False, + basename: Optional[str] = None, + webgl: bool = False, + log_level: str = "INFO", + hover_unified: bool = False, + plot_normalised_res: bool = False, + stats_matrix: bool = False, + stats_matrix_weighted: bool = False, + annotate_stats_matrix: bool = False, + amb_totals_topn: Optional[int] = None, + use_forward_residuals: bool = False, + include_plotlyjs: bool = True, +) -> List[str]: + """ + Generate interactive HTML plots from TRACE residual files (programmatic call for Ginan-UI). + + This function provides a programmatic way of generating visualisations from TRACE files, + mirroring the CLI interface but suitable for calling from Python code. + + Arguments: + files (List[str]): List of TRACE file paths (wildcards allowed, e.g., "*.TRACE"). + out_dir (str): Output directory for HTML files. + mark_amb_resets (bool): Overlay PHASE ambiguity reset events on plots. + mark_large_errors (bool): Mark LARGE STATE / MEAS ERROR events on plots. + show_stats_table (bool): Add Mean / Std / RMS table per (sat * signal) at the bottom. + ambiguity_counts (bool): Plot cumulative ambiguity reset counts over time. + ambiguity_totals (bool): Bar chart of total ambiguity reset reasons. + amb_totals_orient (str): Orientation for totals bar charts ('h' or 'v'). + residual (str): Which residual to plot ('prefit' or 'postfit'). + receivers (str, optional): Comma/space separated receiver filter. + sat (List[str], optional): Filter by satellite ID(s). + label_regex (str, optional): Regex to filter labels. + max_abs (float, optional): Maximum absolute residual value to include. + start (str, optional): Start datetime or time-only filter. + end (str, optional): End datetime (exclusive) filter. + decimate (int): Decimation factor for plotting. + split_per_sat (bool): Create separate plots per satellite. + split_per_recv (bool): Create separate plots per receiver. + basename (str, optional): Base filename prefix for outputs. + webgl (bool): Use WebGL for rendering. + log_level (str): Logging verbosity level. + hover_unified (bool): Use unified hover tooltips. + plot_normalised_res (bool): Also generate normalised residual plots. + stats_matrix (bool): Generate receiver×satellite heatmaps. + stats_matrix_weighted (bool): Use sigma-weighted statistics in heatmaps. + annotate_stats_matrix (bool): Write numeric values into heatmap cells. + amb_totals_topn (int, optional): Show only top-N receivers/satellites by resets. + use_forward_residuals (bool): Use forward file residuals instead of smoothed. + include_plotlyjs (bool): If True (default), embed Plotly.js in HTML for offline viewing. + + Returns: + List[str]: Paths to generated HTML files. + + Example: + >>> html_files = plot_trace_res_files( + ... files=["output/Network*.TRACE"], + ... out_dir="output/visual", + ... mark_amb_resets=True, + ... show_stats_table=True, + ... ) + """ + + class Args: + def __init__(self): + self.files = files + self.out_dir = out_dir + self.mark_amb_resets = mark_amb_resets + self.mark_large_errors = mark_large_errors + self.show_stats_table = show_stats_table + self.ambiguity_counts = ambiguity_counts + self.ambiguity_totals = ambiguity_totals + self.amb_totals_orient = amb_totals_orient + self.residual = residual + self.receivers = receivers + self.sat = sat + self.label_regex = label_regex + self.max_abs = max_abs + self.start = start + self.end = end + self.decimate = decimate + self.split_per_sat = split_per_sat + self.split_per_recv = split_per_recv + self.basename = basename + self.webgl = webgl + self.log_level = log_level + self.hover_unified = hover_unified + self.plot_normalised_res = plot_normalised_res + self.stats_matrix = stats_matrix + self.stats_matrix_weighted = stats_matrix_weighted + self.annotate_stats_matrix = annotate_stats_matrix + self.amb_totals_topn = amb_totals_topn + self.use_forward_residuals = use_forward_residuals + self.out_prefix = None + self.include_plotlyjs = include_plotlyjs + + args = Args() + return _process_and_plot_trace(args, from_cli=False) + + +def _process_and_plot_trace(args, from_cli: bool = False) -> List[str]: + """ + Internal helper to process TRACE files and generate plots. + Shared function between CLI and programmatic calls. + + Arguments: + args: Arguments object with all configuration options. + from_cli: If True, split file arguments on comma / space (CLI mode). + If False, treat each file argument as a complete path (programmatic mode). + + Returns: + List[str]: Paths to generated HTML files. + """ _setup_logging(args.log_level) - args.plot_normalised_res = args.plot_normalised_res or args.plot_normalized_res + + # Handle normalised/normalized spelling + if hasattr(args, "plot_normalized_res"): + args.plot_normalised_res = args.plot_normalised_res or args.plot_normalized_res import glob, os, re from pathlib import Path from typing import List # --- Expand wildcards and handle comma/space separated patterns --- - # args.files is a list because of nargs="+". Each element itself may contain commas. + # For programmatic calls (from_cli=False), each element in args.files is treated + # as a complete path that may contain spaces. + # For CLI calls (from_cli=True), we split on comma/space for user convenience. patterns: List[str] = [] for tok in args.files: - # split any "A.trace,B.trace" into ["A.trace","B.trace"] - parts = [s for s in re.split(r"[,\s]+", str(tok)) if s] - patterns.extend(parts) + tok_str = str(tok) + if from_cli: + # CLI mode: split on comma/space for user convenience + # But only split on comma, not space, to better handle paths with spaces + # Actually, for CLI the shell typically handles quoting, so we split on comma only + parts = [s.strip() for s in tok_str.split(",") if s.strip()] + patterns.extend(parts) + else: + # Programmatic mode: treat each path as complete (may contain spaces) + patterns.append(tok_str) expanded_files: List[str] = [] for pattern in patterns: @@ -2289,7 +2627,8 @@ def main(): args.files = expanded_files if not args.files: - p.error("No input files found after wildcard expansion.") + logger.error("No input files found after wildcard expansion.") + return [] # --- Normalize into Path objects, validate existence, and de-duplicate (preserve order) --- in_paths: List[Path] = [] @@ -2297,26 +2636,27 @@ def main(): for f in args.files: pth = Path(f) if not pth.exists(): - raise SystemExit(f"File not found: {pth}") + logger.error(f"File not found: {pth}") + continue key = str(pth.resolve()) if key not in seen: seen.add(key) in_paths.append(pth) if not in_paths: - raise SystemExit("No input files provided to --files.") + logger.error("No valid input files provided.") + return [] # de-duplicate, preserve order seen = set() in_paths = [p for p in in_paths if not (str(p.resolve()) in seen or seen.add(str(p.resolve())))] if not in_paths: - raise SystemExit("No input files provided to --files.") + logger.error("No input files provided to --files.") + return [] # --- Pair forward and smoothed files --- - residual_paths, forward_paths, file_warnings = pair_forward_smoothed_files( - in_paths, args.use_forward_residuals - ) + residual_paths, forward_paths, file_warnings = pair_forward_smoothed_files(in_paths, args.use_forward_residuals) # Display any file pairing warnings for warn in file_warnings: @@ -2363,10 +2703,7 @@ def iter_all_lines(paths): # Merge on matching keys merge_keys = ["datetime", "meas", "sat", "recv", "sig"] df = df_forward.merge( - df_smoothed[merge_keys + ["postfit"]], - on=merge_keys, - how="left", - suffixes=("_fwd", "_smo") + df_smoothed[merge_keys + ["postfit"]], on=merge_keys, how="left", suffixes=("_fwd", "_smo") ) # Use smoothed postfit where available, otherwise fall back to forward @@ -2378,7 +2715,7 @@ def iter_all_lines(paths): # Recalculate postfit_ratio if it exists (postfit_ratio = postfit / sigma) if "postfit_ratio" in df.columns and "sigma" in df.columns: # Use smoothed postfit with forward sigma - df["postfit_ratio"] = df["postfit"] / df["sigma"].replace(0, float('nan')) + df["postfit_ratio"] = df["postfit"] / df["sigma"].replace(0, float("nan")) df = df.drop(columns=["postfit_fwd", "postfit_smo"]) logger.info(f"Merged: {len(df)} measurements total ({n_smoothed} from smoothed, {n_forward} forward-only)") @@ -2386,7 +2723,7 @@ def iter_all_lines(paths): logger.warning("Merge did not produce postfit_smo column - using forward values only") else: df = parse_trace_lines(iter_all_lines(residual_paths)) - #df = parse_trace_lines_fast(iter_all_lines(residual_paths)) + # df = parse_trace_lines_fast(iter_all_lines(residual_paths)) df_large = parse_large_errors(iter_all_lines(forward_paths)) if args.mark_large_errors else pd.DataFrame() @@ -2407,14 +2744,11 @@ def iter_all_lines(paths): df_large = filter_large_errors(df_large, receivers=recv_list, sats=args.sat, start_dt=start_dt, end_dt=end_dt) # Parse ambiguity resets if needed by any ambiguity feature (from forward files only) - need_amb = bool( - args.mark_amb_resets or - args.ambiguity_counts or args.ambiguity_totals - ) + need_amb = bool(args.mark_amb_resets or args.ambiguity_counts or args.ambiguity_totals) df_amb = parse_ambiguity_resets(iter_all_lines(forward_paths)) if need_amb else pd.DataFrame() # --- Schema normalize & assert (Part 1) --- - REQUIRED_AMB_COLS = ["datetime","sat","recv","action","sig","reasons"] + REQUIRED_AMB_COLS = ["datetime", "sat", "recv", "action", "sig", "reasons"] if not need_amb: # Ensure empty-but-well-formed frame so downstream never breaks @@ -2442,7 +2776,7 @@ def iter_all_lines(paths): # Coerce dtypes (lightweight, no extra normalization logic) df_amb["datetime"] = pd.to_datetime(df_amb["datetime"], errors="coerce") - for c in ("sat","recv","action","sig","reasons"): + for c in ("sat", "recv", "action", "sig", "reasons"): df_amb[c] = df_amb[c].astype(str) # --- Now apply the same CLI/time-window filters --- @@ -2471,7 +2805,7 @@ def iter_all_lines(paths): # Compute normalised residual columns sigma = pd.to_numeric(df["sigma"], errors="coerce") sigma_nz = sigma.mask(sigma == 0) # -> NaN where zero to avoid divide-by-zero - df["norm_prefit"] = pd.to_numeric(df["prefit"], errors="coerce") / sigma_nz + df["norm_prefit"] = pd.to_numeric(df["prefit"], errors="coerce") / sigma_nz df["norm_postfit"] = pd.to_numeric(df["postfit"], errors="coerce") / sigma_nz # Keep a non-decimated copy for precise y lookups of MEAS error markers @@ -2496,13 +2830,13 @@ def iter_all_lines(paths): # Find longest common prefix prefix = stems[0] for stem in stems[1:]: - while stem[:len(prefix)] != prefix and prefix: + while stem[: len(prefix)] != prefix and prefix: prefix = prefix[:-1] # Use common prefix if meaningful (at least 5 chars), otherwise use first file if len(prefix) >= 5: # Remove trailing dashes/underscores - prefix = prefix.rstrip('-_') + prefix = prefix.rstrip("-_") base_root = f"{prefix}_{len(stems)}_files" else: base_root = f"{stems[0]}_{len(stems)}_files" @@ -2529,12 +2863,15 @@ def iter_all_lines(paths): # Graphics acceleration use_webgl = args.webgl + include_plotlyjs = getattr(args, "include_plotlyjs", True) + plotlyjs_setting = True if include_plotlyjs else "cdn" + # Build which variants we will plot (raw + optional normalised) plot_variants = [("raw", yfield, "", "residual")] if args.plot_normalised_res: plot_variants.append(("norm", f"norm_{yfield}", "_norm", "normalised residual (res/σ)")) - any_outputs_global = False + all_outputs: List[str] = [] for variant_tag, variant_yfield, variant_suffix, ytitle in plot_variants: @@ -2548,7 +2885,7 @@ def iter_all_lines(paths): if df_m.empty: continue - is_phase = (meas_name == "PHAS_MEAS") + is_phase = meas_name == "PHAS_MEAS" if args.split_per_sat: for sat in sorted(df_m["sat"].unique(), key=_sat_sort_key): @@ -2556,7 +2893,8 @@ def iter_all_lines(paths): if df_ms.empty: continue fig = make_plot( - df_ms, variant_yfield, + df_ms, + variant_yfield, title=f"{sat} {short.upper()} {ytitle}", use_webgl=use_webgl, df_large=df_large, @@ -2568,14 +2906,18 @@ def iter_all_lines(paths): df_amb=(df_amb if (args.mark_amb_resets and is_phase) else pd.DataFrame()), ) out_html = build_out_path( - base, variant_suffix, short, - split="sat", - key=sat, - tag="residual", - ext="html", + base, + variant_suffix, + short, + split="sat", + key=sat, + tag="residual", + ext="html", ) ensure_parent(out_html) - fig.write_html(out_html, include_plotlyjs="cdn", validate=False, config={"scrollZoom": True}) + fig.write_html( + out_html, include_plotlyjs=plotlyjs_setting, validate=False, config={"scrollZoom": True} + ) logger.info("Wrote: %s", out_html) outputs_variant.append(out_html) meas_map[short].append((sat, out_html)) @@ -2587,7 +2929,7 @@ def iter_all_lines(paths): continue # right before the make_plot() call - is_phase = (meas_name == "PHAS_MEAS") # make this exact comparison + is_phase = meas_name == "PHAS_MEAS" # make this exact comparison df_amb_for_plot = df_amb if (args.mark_amb_resets and is_phase) else pd.DataFrame() fig = make_plot( @@ -2601,24 +2943,29 @@ def iter_all_lines(paths): df_lookup=df_lookup, lookup_cache=lookup_cache, show_stats=args.show_stats_table, - df_amb=df_amb_for_plot, # <- pass the guarded DF + df_amb=df_amb_for_plot, # <- pass the guarded DF ) out_html = build_out_path( - base, variant_suffix, short, - split="recv", - key=recv, - tag="residual", - ext="html", + base, + variant_suffix, + short, + split="recv", + key=recv, + tag="residual", + ext="html", ) ensure_parent(out_html) - fig.write_html(out_html, include_plotlyjs="cdn", validate=False, config={"scrollZoom": True}) + fig.write_html( + out_html, include_plotlyjs=plotlyjs_setting, validate=False, config={"scrollZoom": True} + ) logger.info("Wrote: %s", out_html) outputs_variant.append(out_html) meas_map[short].append((recv, out_html)) else: fig = make_plot( - df_m, variant_yfield, + df_m, + variant_yfield, title=f"GNSS {short.upper()} {ytitle}", use_webgl=use_webgl, df_large=df_large, @@ -2632,7 +2979,7 @@ def iter_all_lines(paths): out_html = build_out_path(base, variant_suffix, short, tag="residual", ext="html") ensure_parent(out_html) - fig.write_html(out_html, include_plotlyjs="cdn", validate=False, config={"scrollZoom": True}) + fig.write_html(out_html, include_plotlyjs=plotlyjs_setting, validate=False, config={"scrollZoom": True}) outputs_variant.append(out_html) # Build an index page for this variant if split mode was used @@ -2642,7 +2989,9 @@ def iter_all_lines(paths): # Build trace file info showing which files were used for what residual_file_names = ", ".join(p.name for p in residual_paths) if residual_paths != forward_paths: - trace_files_info = f"Residuals: {residual_file_names} | Amb/Errors: {', '.join(p.name for p in forward_paths)}" + trace_files_info = ( + f"Residuals: {residual_file_names} | Amb/Errors: {', '.join(p.name for p in forward_paths)}" + ) else: trace_files_info = residual_file_names @@ -2672,7 +3021,7 @@ def iter_all_lines(paths): # Print this variant’s outputs (once), then proceed if outputs_variant: - any_outputs_global = True + all_outputs.extend(outputs_variant) logger.info("Wrote %d plot files for variant '%s'.", len(outputs_variant), variant_tag) # ---- Receiver × Satellite heatmaps (weighted OR unweighted, not both) ---- @@ -2696,12 +3045,7 @@ def iter_all_lines(paths): # ---- Build counts matrix aligned to stats_mats ---- yv = pd.to_numeric(ms[variant_yfield], errors="coerce") - counts = ( - ms.loc[yv.notna()] - .groupby(["recv", "sat"], sort=False) - .size() - .unstack(fill_value=0) - ) + counts = ms.loc[yv.notna()].groupby(["recv", "sat"], sort=False).size().unstack(fill_value=0) # Add ALL_SAT (per receiver) as first column counts["ALL_SAT"] = counts.sum(axis=1) @@ -2723,8 +3067,8 @@ def _align_counts(mat: pd.DataFrame) -> pd.DataFrame: wtxt = "weighted" if weighted else "unweighted" titles = { "mean": f"{dtype.upper()} — Receiver × Satellite — Mean ({wtxt}) — {ytitle}", - "std": f"{dtype.upper()} — Receiver × Satellite — Std Dev ({wtxt}) — {ytitle}", - "rms": f"{dtype.upper()} — Receiver × Satellite — RMS ({wtxt}) — {ytitle}", + "std": f"{dtype.upper()} — Receiver × Satellite — Std Dev ({wtxt}) — {ytitle}", + "rms": f"{dtype.upper()} — Receiver × Satellite — RMS ({wtxt}) — {ytitle}", } # ---------- Mean (diverging, centered at 0.0) ---------- @@ -2733,15 +3077,21 @@ def _align_counts(mat: pd.DataFrame) -> pd.DataFrame: out_html = build_out_path(base, variant_suffix, f"stats_matrix_mean_{dtype}") ensure_parent(out_html) write_heatmap_html( - mat, titles["mean"], out_html, - colorscale="RdBu", reversescale=True, - zmid=0.0, zmin=None, zmax=None, - counts=_align_counts(mat).astype(float), + mat, + titles["mean"], + out_html, + colorscale="RdBu", + reversescale=True, + zmid=0.0, + zmin=None, + zmax=None, + counts=_align_counts(mat).astype(float), row_sort="alpha", sat_sort="natural", annotate=annotate_mode, ) logger.info("Wrote: %s", out_html) + all_outputs.append(out_html) else: logger.info("Mean matrix empty for %s; skipping.", dtype) @@ -2759,15 +3109,21 @@ def _align_counts(mat: pd.DataFrame) -> pd.DataFrame: ensure_parent(out_html) zmax = float(np.nanmax(mat.values)) if mat.size else None write_heatmap_html( - mat, titles[metric], out_html, - colorscale="Blues", reversescale=False, - zmid=None, zmin=0.0, zmax=zmax, - counts=_align_counts(mat).astype(float), + mat, + titles[metric], + out_html, + colorscale="Blues", + reversescale=False, + zmid=None, + zmin=0.0, + zmax=zmax, + counts=_align_counts(mat).astype(float), row_sort="alpha", sat_sort="natural", annotate=annotate_mode, ) logger.info("Wrote: %s", out_html) + all_outputs.append(out_html) # CSV out_csv = build_out_path(base, variant_suffix, short, ext="csv") @@ -2780,32 +3136,222 @@ def _align_counts(mat: pd.DataFrame) -> pd.DataFrame: # Ambiguity reset plots (reasons + unique resets) if args.ambiguity_counts: - if args.split_per_recv: - plot_ambiguity_reason_counts(df_amb, split="recv", base=base, variant_suffix=variant_suffix) - elif args.split_per_sat: - plot_ambiguity_reason_counts(df_amb, split="sat", base=base, variant_suffix=variant_suffix) - else: - plot_ambiguity_reason_counts(df_amb, split="combined", base=base, variant_suffix=variant_suffix) + split_mode = "recv" if args.split_per_recv else ("sat" if args.split_per_sat else "combined") + amb_count_outputs = plot_ambiguity_reason_counts_inline( + df_amb, + split=split_mode, + base=base, + variant_suffix=variant_suffix, + include_plotlyjs=plotlyjs_setting, + ) + all_outputs.extend(amb_count_outputs) if args.ambiguity_totals: - if args.split_per_recv: - plot_ambiguity_reason_totals(df_amb, split="recv", - orientation=args.amb_totals_orient, - top_n=args.amb_totals_topn, - base=base, variant_suffix=variant_suffix) - elif args.split_per_sat: - plot_ambiguity_reason_totals(df_amb, split="sat", - orientation=args.amb_totals_orient, - top_n=args.amb_totals_topn, - base=base, variant_suffix=variant_suffix) - else: - plot_ambiguity_reason_totals(df_amb, split="combined", - orientation=args.amb_totals_orient, - top_n=args.amb_totals_topn, - base=base, variant_suffix=variant_suffix) + split_mode = "recv" if args.split_per_recv else ("sat" if args.split_per_sat else "combined") + amb_total_outputs = plot_ambiguity_reason_totals_inline( + df_amb, + split=split_mode, + orientation=args.amb_totals_orient, + top_n=args.amb_totals_topn, + base=base, + variant_suffix=variant_suffix, + include_plotlyjs=plotlyjs_setting, + ) + all_outputs.extend(amb_total_outputs) - if not any_outputs_global: + if not all_outputs: logger.warning("No residuals matched your filters.") + return all_outputs + + +def plot_ambiguity_reason_counts_inline( + df_amb: pd.DataFrame, + split: str = "combined", + *, + base: str, + variant_suffix: str, + include_plotlyjs=True, +) -> List[str]: + """ + Generate cumulative ambiguity-reset reason count plots with configurable plotlyjs embedding. + This is a wrapper around the plotting logic for programmatic use. + """ + outputs = [] + if df_amb is None or df_amb.empty: + logger.warning("No ambiguity-reset data to plot.") + return outputs + + # Process and deduplicate ambiguity reasons + df_reasons = prepare_ambiguity_reasons(df_amb) + if df_reasons.empty: + logger.warning("No ambiguity-reset reasons found after processing.") + return outputs + + df_reasons = df_reasons.sort_values("datetime") + + # Also prepare unique satellite reset events + df_events = prepare_ambiguity_events(df_amb) + df_events = df_events.sort_values("datetime") if not df_events.empty else df_events + + # Choose grouping key + if split == "recv": + groups = df_reasons.groupby("recv", dropna=False) + event_groups = df_events.groupby("recv", dropna=False) if not df_events.empty else [] + elif split == "sat": + groups = df_reasons.groupby("sat", dropna=False) + event_groups = df_events.groupby("sat", dropna=False) if not df_events.empty else [] + else: + groups = [("ALL", df_reasons)] + event_groups = [("ALL", df_events)] if not df_events.empty else [] + + # Convert event_groups to dict for easy lookup + event_dict = {k: v for k, v in event_groups} + + for key, g in groups: + if g.empty: + continue + + counts = g.groupby(["datetime", "reason"], sort=True).size().reset_index(name="count").sort_values("datetime") + counts["cumcount"] = counts.groupby("reason")["count"].cumsum() + pivot = counts.pivot(index="datetime", columns="reason", values="cumcount").ffill().fillna(0) + + fig = go.Figure() + for reason in pivot.columns: + fig.add_trace(go.Scatter(x=pivot.index, y=pivot[reason], mode="lines", name=reason)) + + # Add unique resets trace if available + if key in event_dict and not event_dict[key].empty: + ev = event_dict[key] + ev_counts = ev.groupby("datetime").size().cumsum().reset_index(name="cumcount") + fig.add_trace( + go.Scatter( + x=ev_counts["datetime"], + y=ev_counts["cumcount"], + mode="lines", + name="Unique Resets", + line=dict(dash="dash", color="black", width=2), + ) + ) + + title_suffix = { + "recv": f"Receiver {key}", + "sat": f"Satellite {key}", + "combined": "All receivers/satellites", + }.get(split, "All") + + fig.update_layout( + title=f"Cumulative Ambiguity Resets — {title_suffix}
    Each reason counted once per epoch-satellite across all signals. 'Unique Resets' shows total satellites affected.", + xaxis_title="Time", + yaxis_title="Cumulative Count", + template="plotly_white", + legend_title="Metric", + hovermode="x unified", + ) + + safe_key = _sanitize_filename_piece(str(key)) + out_html = build_out_path(base, variant_suffix, "ambiguity_counts", split=split, key=safe_key) + ensure_parent(out_html) + fig.write_html(out_html, include_plotlyjs=include_plotlyjs, validate=False, config={"scrollZoom": True}) + logger.info(f"Wrote: {out_html}") + outputs.append(out_html) + + return outputs + + +def plot_ambiguity_reason_totals_inline( + df_amb: pd.DataFrame, + split: str = "combined", + orientation: str = "h", + top_n: int = None, + *, + base: str, + variant_suffix: str, + include_plotlyjs=True, +) -> List[str]: + """ + Generate stacked bar chart of total ambiguity reset reasons with configurable plotlyjs embedding. + This is a wrapper around the plotting logic for programmatic use. + """ + outputs = [] + if df_amb is None or df_amb.empty: + logger.warning("No ambiguity-reset data to plot.") + return outputs + + # Process and deduplicate ambiguity reasons + df_reasons = prepare_ambiguity_reasons(df_amb) + if df_reasons.empty: + logger.warning("No ambiguity-reset reasons found after processing.") + return outputs + + # Choose grouping key + if split == "recv": + group_col = "recv" + elif split == "sat": + group_col = "sat" + else: + group_col = None + + if group_col: + totals = df_reasons.groupby([group_col, "reason"]).size().unstack(fill_value=0) + totals["_total"] = totals.sum(axis=1) + totals = totals.sort_values("_total", ascending=False) + if top_n: + totals = totals.head(top_n) + totals = totals.drop(columns=["_total"]) + else: + totals = df_reasons.groupby("reason").size() + totals = totals.sort_values(ascending=False) + totals = totals.to_frame(name="count").T + totals.index = ["All"] + + fig = go.Figure() + reasons = [c for c in totals.columns if c != "_total"] + for reason in reasons: + if orientation == "h": + fig.add_trace( + go.Bar( + y=totals.index, + x=totals[reason], + name=reason, + orientation="h", + ) + ) + else: + fig.add_trace( + go.Bar( + x=totals.index, + y=totals[reason], + name=reason, + ) + ) + + title_suffix = { + "recv": "by Receiver", + "sat": "by Satellite", + "combined": "Total", + }.get(split, "") + + fig.update_layout( + title=f"Ambiguity Reset Reasons {title_suffix}", + barmode="stack", + template="plotly_white", + legend_title="Reason", + ) + + if orientation == "h": + fig.update_layout(xaxis_title="Count", yaxis_title=group_col.capitalize() if group_col else "") + else: + fig.update_layout(yaxis_title="Count", xaxis_title=group_col.capitalize() if group_col else "") + + out_html = build_out_path(base, variant_suffix, f"ambiguity_totals_{orientation}") + ensure_parent(out_html) + fig.write_html(out_html, include_plotlyjs=include_plotlyjs, validate=False, config={"scrollZoom": True}) + logger.info(f"Wrote: {out_html}") + outputs.append(out_html) + + return outputs + + if __name__ == "__main__": main() diff --git a/src/cpp/CMakeLists.txt b/src/cpp/CMakeLists.txt index e50198431..6e27c5c06 100644 --- a/src/cpp/CMakeLists.txt +++ b/src/cpp/CMakeLists.txt @@ -106,18 +106,21 @@ add_executable(pea common/rinexObsWrite.cpp common/tides.cpp common/ubxDecoder.cpp + common/sbfDecoder.cpp common/localAtmosRegion.cpp common/streamNtrip.cpp common/streamCustom.cpp common/streamSerial.cpp common/streamUbx.cpp + common/streamSbf.cpp common/streamParser.cpp iono/geomagField.cpp iono/ionex.cpp iono/ionoMeas.cpp iono/ionoModel.cpp + iono/ionoSBAS.cpp iono/ionoSpherical.cpp iono/ionoSphericalCaps.cpp iono/ionoBSplines.cpp diff --git a/src/cpp/common/acsConfig.cpp b/src/cpp/common/acsConfig.cpp index f8f10d3a3..f5f9ac81e 100644 --- a/src/cpp/common/acsConfig.cpp +++ b/src/cpp/common/acsConfig.cpp @@ -322,6 +322,7 @@ void replaceTags(string& str) ///< String to replace macros within repeat |= replaceString(str, "", acsConfig.rtcm_obs_directory); repeat |= replaceString(str, "", acsConfig.raw_custom_directory); repeat |= replaceString(str, "", acsConfig.raw_ubx_directory); + repeat |= replaceString(str, "", acsConfig.raw_sbf_directory); repeat |= replaceString(str, "", acsConfig.slr_obs_directory); repeat |= replaceString(str, "", acsConfig.trop_sinex_directory); repeat |= replaceString(str, "", acsConfig.ems_directory); @@ -858,7 +859,7 @@ void ACSConfig::outputDefaultConfiguration(int level) html << htmlHeaderTemplate << "\n"; - auto it = acsConfig.yamlDefaults.begin(); + auto it = yamlDefaults.begin(); Indentor indentor; Indentor htmlIndentor; @@ -1438,6 +1439,7 @@ void ACSConfig::info(Trace& s) ///< Trace file to output to ss << "\tMinimum Constraints: " << process_minimum_constraints << "\n"; ss << "\tIonospheric: " << process_ionosphere << "\n"; ss << "\tRTS Smoothing: " << process_rts << "\n"; + ss << "\tSBAS: " << process_sbas << "\n"; ss << "\n"; ss << "Systems:\n"; @@ -1838,6 +1840,15 @@ void setInited(BASE& base, COMP& comp, bool init = true) base.initialisedMap[offset] = true; } +/** Set an option manually + */ +template +void setOption(BASE& base, COMP& comp, VALUE value) +{ + comp = value; + setInited(base, comp); +} + /** Set the variables associated with kalman filter states from yaml */ void tryGetKalmanFromYaml( @@ -2066,7 +2077,7 @@ void tryGetStreamFromYaml( if (msgType == RtcmMessageType::IGS_SSR) for (auto subType : magic_enum::enum_values()) { - string str = (boost::format("@ rtcm_%4d_%03d") % static_cast(msgType) % + string str = (boost::format("@ rtcm_%04d_%03d") % rtcmTypeToMessageNumber(msgType) % static_cast(subType)) .str(); @@ -2088,7 +2099,7 @@ void tryGetStreamFromYaml( else if (msgType == RtcmMessageType::COMPACT_SSR) for (auto subType : magic_enum::enum_values()) { - string str = (boost::format("@ rtcm_%4d_%02d") % static_cast(msgType) % + string str = (boost::format("@ rtcm_%04d_%02d") % rtcmTypeToMessageNumber(msgType) % static_cast(subType)) .str(); @@ -2109,7 +2120,7 @@ void tryGetStreamFromYaml( else { - string str = "@ rtcm_" + std::to_string(static_cast(msgType)); + string str = (boost::format("@ rtcm_%04d") % rtcmTypeToMessageNumber(msgType)).str(); auto msgOptions = stringsToYamlObject(outStreamsYaml, {"0@ messages", str}, "Message type to output"); @@ -3386,7 +3397,7 @@ void getOptionsFromYaml( "Coherent ionosphere models can improve estimation of biases and allow use with single " "frequency receivers" ); - auto troposhpere = stringsToYamlObject( + auto troposphere = stringsToYamlObject( modelsNode, {"@ troposphere"}, "Tropospheric modelling accounts for delays due to refraction of light in water vapour" @@ -4328,6 +4339,7 @@ bool configure( ("erp_files", boost::program_options::value>()->multitoken(), "ERP files") ("rnx_inputs,r", boost::program_options::value>()->multitoken(), "RINEX receiver inputs") ("ubx_inputs", boost::program_options::value>()->multitoken(), "UBX receiver inputs") + ("sbf_inputs", boost::program_options::value>()->multitoken(), "SBF receiver inputs") ("rtcm_inputs", boost::program_options::value>()->multitoken(), "RTCM receiver inputs") ("egm_files", boost::program_options::value>()->multitoken(), "Earth gravity model coefficients file") ("crd_files", boost::program_options::value>()->multitoken(), "SLR CRD file") @@ -4509,7 +4521,7 @@ void ACSConfig::sanityChecks() BOOST_LOG_TRIVIAL(warning) << "ionospheric_components:outage_reset_limit < " "epoch_interval, but it probably shouldnt be"; - if (acsConfig.simulate_real_time == false) + if (simulate_real_time == false) { for (E_Sys sys : magic_enum::enum_values()) { @@ -4517,13 +4529,13 @@ void ACSConfig::sanityChecks() } } - if (acsConfig.pppOpts.ionoOpts.use_if_combo) + if (pppOpts.ionoOpts.use_if_combo) { for (auto& [id, recOpts] : recOptsMap) { if (recOpts.ionospheric_component2) { - recOpts.ionospheric_component2 = false; + setOption(recOpts, recOpts.ionospheric_component2, false); BOOST_LOG_TRIVIAL(warning) << "Higher-order ionospheric corrections are not supported when " "use_if_combo is enabled, " @@ -4531,7 +4543,7 @@ void ACSConfig::sanityChecks() } if (recOpts.ionospheric_component3) { - recOpts.ionospheric_component3 = false; + setOption(recOpts, recOpts.ionospheric_component3, false); BOOST_LOG_TRIVIAL(warning) << "Higher-order ionospheric corrections are not supported when " "use_if_combo is enabled, " @@ -4539,6 +4551,161 @@ void ACSConfig::sanityChecks() } } } + + if (process_sbas) + { + process_preprocessor = true; + process_spp = true; + + used_nav_types = sbsOpts.sbas_nav_types; + + for (auto& [id, satOpts] : satOptsMap) + { + vector sources = {E_Source::SBAS}; + setOption((CommonOptions&)satOpts, satOpts.posModel.enable, true); + setOption((CommonOptions&)satOpts, satOpts.posModel.sources, sources); + setOption((CommonOptions&)satOpts, satOpts.clockModel.enable, true); + setOption((CommonOptions&)satOpts, satOpts.clockModel.sources, sources); + } + + switch (sbsOpts.mode) + { + case E_SbasMode::L1: + { + BOOST_LOG_TRIVIAL(info) + << "L1 SBAS processing mode is selected, make sure that:\n" + " - You have inputs containing SBAS messages (sisnet, ems, sbf, etc.)\n" + " - Parameter `sbas_inputs: prec_approach` is set appropriately"; + + sbsInOpts.freq = 1; + + for (auto& [sys, process] : process_sys) + { + if (sys != E_Sys::GPS && sys != E_Sys::GLO && sys != E_Sys::SBS) + { + process = false; + } + else + { + code_priorities[sys] = {E_ObsCode::L1C}; + } + } + + sppOpts.trop_models = {E_TropModel::SBAS}; + sppOpts.iono_mode = E_IonoMode::SBAS; + + if (sppOpts.smooth_window != 100) + { + sppOpts.smooth_window = 100; + BOOST_LOG_TRIVIAL(warning) + << "It is recommended that a 100 second smoothing window be used for L1 " + "SBAS. Changing configuration"; + } + if (sppOpts.use_smooth_only == false) + { + sppOpts.use_smooth_only = true; + BOOST_LOG_TRIVIAL(warning) + << "It is NOT recommended that measurements be used for SBAS before " + "smoothing. Changing configuration"; + } + + if (sbsOpts.use_sbas_rec_var == false) + { + sbsOpts.use_sbas_rec_var = true; + BOOST_LOG_TRIVIAL(warning) + << "It is recommended that measurement variance specific for SBAS are " + "used. Changing configuration"; + } + + break; + } + + case E_SbasMode::DFMC: + { + BOOST_LOG_TRIVIAL(info) + << "DFMC processing mode is selected, make sure that:\n" + " - You have inputs containing SBAS messages (sisnet, ems, sbf, etc.)\n" + " - If using a service follwing DO-259 (instead of DO-259A), set " + "`sbas_inputs: use_do259: true`\n" + " - If using measurements from GLO or BDS, set the `code_priorities` and " + "`used_nav_type` properly\n"; + + sbsInOpts.freq = 5; + sbsInOpts.pvs_on_dfmc = false; + + for (auto& [sys, process] : process_sys) + { + if (sys == E_Sys::GLO || sys == E_Sys::LEO) + { + process = false; + } + else if (sys != E_Sys::BDS) + { + code_priorities[sys] = sbsOpts.sbas_code_priorities_map[sys]; + } + } + + sppOpts.trop_models = {E_TropModel::SBAS}; + sppOpts.iono_mode = E_IonoMode::SBAS; + + if (sppOpts.smooth_window < + 0) // Ken to update once the smooth window requirement is clear + BOOST_LOG_TRIVIAL(warning) + << "It is recommended that a 100 second smoothing window be used for DFMC. " + "Please check your configuration"; + + break; + } + + case E_SbasMode::PVS: + { + BOOST_LOG_TRIVIAL(info) + << "PVS-via-DFMC processing mode is selected, make sure that:\n" + " - You have inputs containing SBAS messages (sisnet, ems, sbf, etc.)\n" + " - The SBAS messages come from SouthPAN's DFMC services\n"; + + process_ppp = true; + + sbsInOpts.freq = 5; + sbsInOpts.pvs_on_dfmc = true; + + for (auto& [sys, process] : process_sys) + { + if (sys == E_Sys::GPS || sys == E_Sys::GAL) + { + process = true; + code_priorities[sys] = sbsOpts.sbas_code_priorities_map[sys]; + } + else + { + process = false; + } + } + + for (auto& [id, recOpts] : recOptsMap) + { + vector tropModels = {E_TropModel::GPT2}; + setOption(recOpts, recOpts.receiver_reference_system, E_Sys::GPS); + setOption(recOpts, recOpts.tropModel.enable, true); + setOption(recOpts, recOpts.tropModel.models, tropModels); + setOption(recOpts, recOpts.tideModels.otl, false); + setOption(recOpts, recOpts.tideModels.atl, false); + setOption(recOpts, recOpts.tideModels.spole, false); + setOption(recOpts, recOpts.tideModels.opole, false); + } + + sppOpts.always_reinitialise = true; + pppOpts.use_primary_signals = true; + // pppOpts.receiver_chunking = true; // Currently chunking may not work properly + errorAccumulation.enable = true; + ambErrors.phase_reject_limit = 2; + ambErrors.resetOnSlip.LLI = true; + ambErrors.resetOnSlip.retrack = true; + + break; + } + } + } } bool ACSConfig::parse() @@ -4961,7 +5128,7 @@ bool ACSConfig::parse( "in all trace files" ); - traceLevel = acsConfig.trace_level; + traceLevel = trace_level; tryGetFromYaml( output_residual_chain, @@ -5759,9 +5926,25 @@ bool ACSConfig::parse( tryGetFromYaml(raw_ubx_directory, raw_ubx, {"directory"}) ); conditionalPrefix( - "", + "", raw_ubx_filename, - tryGetFromYaml(raw_ubx_filename, raw_ubx, {"filename"}) + tryGetFromYaml(raw_ubx_filename, raw_ubx, {"@ filename"}) + ); + } + + { + auto raw_sbf = stringsToYamlObject(outputs, {"6@ raw_sbf"}); + + tryGetFromYaml(record_raw_sbf, raw_sbf, {"0 output"}); + conditionalPrefix( + "", + raw_sbf_directory, + tryGetFromYaml(raw_sbf_directory, raw_sbf, {"directory"}) + ); + conditionalPrefix( + "", + raw_sbf_filename, + tryGetFromYaml(raw_sbf_filename, raw_sbf, {"@ filename"}) ); } @@ -6220,6 +6403,14 @@ bool ACSConfig::parse( "", "List of ubxfiles inputs to use" ); + tryGetMappedList( + sbf_inputs, + commandOpts, + gnss_data, + {"1# sbf_inputs"}, + "", + "List of sbffiles inputs to use" + ); tryGetMappedList( custom_inputs, commandOpts, @@ -6560,17 +6751,12 @@ bool ACSConfig::parse( {"@ sbas_frequency"}, "Carrier frequency of SBAS channel" ); - tryGetFromYaml( - sbs_time_delay, - sbas_inputs, - {"@ sbas_time_delay"}, - "Time delay for SBAS corrections when simulating real-time in post-process" - ); tryGetFromYaml( sbsInOpts.mt0, sbas_inputs, {"@ sbas_message_0"}, - "Message type replaced by MT0 (use 65 for SouthPAN L5)" + "Message type replaced by MT0 (use 65 for SouthPAN L5, -1 will drop all " + "sbas data upon receipt of type 0)" ); tryGetFromYaml( sbsInOpts.use_do259, @@ -6591,32 +6777,12 @@ bool ACSConfig::parse( "Limit SBAS solutions to precision approach (which limits maximum SBAS " "correction age)" ); - tryGetFromYaml( - sbsInOpts.dfmc_uire, - sbas_inputs, - {"@ iono_residual_dfmc"}, - "Ionosphere residual from IF combination (use with DFMC only)" - ); tryGetFromYaml( sbsInOpts.ems_year, sbas_inputs, {"@ ems_reference_year"}, "Reference year for EMS files (should be within 50 year of real value)" ); - tryGetFromYaml( - sbsInOpts.smth_win, - sbas_inputs, - {"@ smoothing_window"}, - "Smoothing window to be used by SBAS (100, 1 second samples are normally " - "used)" - ); - tryGetFromYaml( - sbsInOpts.smth_out, - sbas_inputs, - {"@ max_smooth_outage"}, - "Maximum outage to reset smoothing (10 seconds or 3 x obs_rate is " - "recommended)" - ); } } } @@ -6668,6 +6834,12 @@ bool ACSConfig::parse( {"@ slr"}, "Process SLR observations" ); + tryGetFromYaml( + process_sbas, + process_modes, + {"! sbas"}, + "Perform PPP network or end user mode" + ); } // gnss_general @@ -7996,6 +8168,24 @@ bool ACSConfig::parse( {"@ always_reinitialise"}, "Reset SPP state to zero to avoid potential for lock-in of bad states" ); + tryGetFromYaml( + sppOpts.smooth_window, + spp, + {"@ smoothing_window"}, + "Smooth pseudorange with this time window (default: -1, do not apply smoothing)" + ); + tryGetFromYaml( + sppOpts.use_smooth_only, + spp, + {"@ use_smooth_only"}, + "Only use measurements that have been smoothed up to the smoothing window" + ); + tryGetFromYaml( + sppOpts.smooth_outage, + spp, + {"@ smoothing_outage"}, + "Outage time to reset carrier smoothing" + ); tryGetEnumOpt(sppOpts.iono_mode, spp, {"@ iono_mode"}); tryGetEnumVec( sppOpts.trop_models, @@ -8036,6 +8226,32 @@ bool ACSConfig::parse( getFilterOptions(spp, sppOpts); } + // sbas + { + auto sbas = stringsToYamlObject( + processing_options, + {"4! sbas"}, + "Configurations for SBAS processing and its sub processes" + ); + + tryGetEnumOpt(sbsOpts.mode, sbas, {"@ mode"}, "SBAS service/processing mode, "); + + tryGetFromYaml( + sbsOpts.sbas_time_delay, + sbas, + {"@ sbas_time_delay"}, + "Time delay for SBAS corrections when simulating real-time in post-process" + ); + + tryGetFromYaml( + sbsOpts.use_sbas_rec_var, + sbas, + {"@ use_sbas_rec_var"}, + "Override the receiver standard measurement variance models and replace it " + "with SBAS standard (AAD-A)" + ); + } + // preprocessor { auto preprocessor = stringsToYamlObject( @@ -8561,6 +8777,8 @@ bool ACSConfig::parse( globber(rnx_inputs); replaceTags(ubx_inputs); globber(ubx_inputs); + replaceTags(sbf_inputs); + globber(sbf_inputs); replaceTags(custom_inputs); globber(custom_inputs); replaceTags(obs_rtcm_inputs); @@ -8602,6 +8820,8 @@ bool ACSConfig::parse( replaceTags(ionstec_filename); replaceTags(raw_ubx_directory); replaceTags(raw_ubx_filename); + replaceTags(raw_sbf_directory); + replaceTags(raw_sbf_filename); replaceTags(rtcm_nav_directory); replaceTags(rtcm_nav_filename); replaceTags(rtcm_obs_directory); @@ -8668,7 +8888,7 @@ bool ACSConfig::parse( recurseYaml(filename, yaml); } - for (auto& [stack, defaults] : acsConfig.yamlDefaults) + for (auto& [stack, defaults] : yamlDefaults) { if (defaults.comment.empty()) { diff --git a/src/cpp/common/acsConfig.hpp b/src/cpp/common/acsConfig.hpp index cc206a036..32cd4dea4 100644 --- a/src/cpp/common/acsConfig.hpp +++ b/src/cpp/common/acsConfig.hpp @@ -53,7 +53,7 @@ struct SsrInputOptions bool one_freq_phase_bias = false; }; -struct SbsInputOptions +struct SbasInputOptions { string host; ///< hostname is passed as acsConfig.sisnet_inputs string port; ///< port of SISNet steam @@ -61,17 +61,13 @@ struct SbsInputOptions string pass; ///< Password for SISnet stream access int prn; ///< prn of SBAS satellite int freq; ///< freq (L1 or L5) of SBAS channel - int mt0 = 0; ///< message that is replaced by MT0 (use 65 for SouthPAN L5) + int mt0 = -1; ///< message that is replaced by MT0 (use 65 for SouthPAN L5) int ems_year = 2059; ///< reference year for EMS files (2059 should work between 2009 and 2158) bool use_do259 = false; ///< Use original standard DO-259, intead of DO-259A, for DFMC, Keep as ///< 'false' unless using DFMC bool pvs_on_dfmc = false; ///< Interpret DFMC messages as PVS messages bool prec_aproach = true; ///< Limit SBAS solutions to precision approach (which limits maximum ///< SBAS correction age) - bool dfmc_uire = false; ///< Ionosphere residual from IF combination (use with DFMC only) - int smth_win = - -1; ///< Smoothing window to be used by SBAS (100, 1 second samples are normally used) - double smth_out = 10; ///< Maximum outage to reset smoothing }; /** Input source filenames and directories @@ -128,6 +124,7 @@ struct InputOptions map> rnx_inputs; map> ubx_inputs; + map> sbf_inputs; map> custom_inputs; map> obs_rtcm_inputs; map> pseudo_sp3_inputs; @@ -165,10 +162,8 @@ struct InputOptions string stream_user; string stream_pass; - double sbs_time_delay = 0; - - SsrInputOptions ssrInOpts; - SbsInputOptions sbsInOpts; + SsrInputOptions ssrInOpts; + SbasInputOptions sbsInOpts; }; struct IonexOptions @@ -209,6 +204,10 @@ struct OutputOptions string raw_ubx_directory = ""; string raw_ubx_filename = "/--OBS.rtcm"; + bool record_raw_sbf = false; + string raw_sbf_directory = ""; + string raw_sbf_filename = "/--OBS.sbf"; + bool record_raw_custom = false; string raw_custom_directory = ""; string raw_custom_filename = "/--OBS.custom"; @@ -530,6 +529,7 @@ struct GlobalOptions bool process_rts = false; bool process_ppp = false; bool process_orbits = false; + bool process_sbas = false; map process_sys; map solve_amb_for; @@ -749,7 +749,10 @@ struct PppOptions : FilterOptions struct SppOptions : FilterOptions { bool always_reinitialise = false; + int smooth_window = -1; + bool use_smooth_only = false; int max_lsq_iterations = 12; + double smooth_outage = 10; double elevation_mask_deg = 0; double max_gdop = 30; double sigma_scaling = 1; @@ -778,6 +781,33 @@ struct IonModelOptions : FilterOptions KalmanModel ion; }; +struct SbasOptions +{ + E_SbasMode mode = E_SbasMode::L1; + + double sbas_time_delay = 0; + bool use_sbas_rec_var = false; + + map sbas_nav_types = { + {E_Sys::GPS, E_NavMsgType::LNAV}, + {E_Sys::GLO, E_NavMsgType::FDMA}, + {E_Sys::GAL, E_NavMsgType::FNAV}, + {E_Sys::BDS, E_NavMsgType::D1}, + {E_Sys::QZS, E_NavMsgType::LNAV} + }; + + map> sbas_code_priorities_map = { + {E_Sys::GPS, {E_ObsCode::L1C, E_ObsCode::L5Q, E_ObsCode::L5X}}, + {E_Sys::GAL, {E_ObsCode::L1C, E_ObsCode::L5Q, E_ObsCode::L1X, E_ObsCode::L5X}}, + {E_Sys::BDS, + {E_ObsCode::L1C, + E_ObsCode::L5Q, + E_ObsCode::L5X}}, // Eugene: May need to update this for BDS once ICD is released + {E_Sys::QZS, {E_ObsCode::L1C, E_ObsCode::L5Q, E_ObsCode::L5X}}, + {E_Sys::SBS, {E_ObsCode::L1C, E_ObsCode::L5Q}} + }; +}; + struct AmbROptions { E_ARmode mode = E_ARmode::OFF; @@ -1211,7 +1241,7 @@ struct ReceiverOptions : ReceiverKalmans, CommonOptions string domes_number; string site_description; string sat_id; - double elevation_mask_deg = 10; + double elevation_mask_deg = 5; E_Sys receiver_reference_system = E_Sys::NONE; struct @@ -1470,6 +1500,7 @@ struct ACSConfig : GlobalOptions, InputOptions, OutputOptions, DebugOptions SsrOptions ssrOpts; PppOptions pppOpts; SppOptions sppOpts; + SbasOptions sbsOpts; SlrOptions slrOpts; ExcludeOptions exclude; diff --git a/src/cpp/common/algebra.cpp b/src/cpp/common/algebra.cpp index 92d703435..11b49d68c 100644 --- a/src/cpp/common/algebra.cpp +++ b/src/cpp/common/algebra.cpp @@ -1132,6 +1132,7 @@ void KFState::preFitSigmaChecks( 0 ); // set ratio to 0 if corresponding variance is 0, e.g. ONE state, clk rate states stateRatios = stateRatios.isFinite().select(stateRatios, 0); + stateRatios = (P.diagonal().array() > 0).select(stateRatios, 0); kfMeas.prefitRatios.segment(begH, numH) = measRatios; this->prefitRatios.segment(begX, numX) = stateRatios; @@ -1164,11 +1165,9 @@ void KFState::preFitSigmaChecks( << time << "\tLargest meas error is : " << maxMeasRatio << "\tAT " << measChunkIndex << " :\t" << kfMeas.obsKeys[measChunkIndex] << "\n"; - auto mask = (H.col(stateIndex).array() != 0); // Mask out referencing measurements, i.e. - // non-zero values in column stateIndex of H - measRatios.array() *= - mask.cast(); // Set measRatios of non-referencing measurements to 0 - + measRatios = + (H.col(stateIndex).array() != 0) + .select(measRatios, 0); // set measRatios of non-referencing measurements to 0 maxMeasRatio = measRatios.abs().maxCoeff(&measIndex); measChunkIndex = measIndex + begH; @@ -1364,6 +1363,7 @@ void KFState::postFitSigmaChecks( 0 ); // set ratio to 0 if corresponding variance is 0, e.g. ONE state, clk rate states stateRatios = stateRatios.isFinite().select(stateRatios, 0); + stateRatios = (P.diagonal().array() > 0).select(stateRatios, 0); kfMeas.postfitRatios.segment(begH, numH) = measRatios; this->postfitRatios.segment(begX, numX) = stateRatios; @@ -1396,11 +1396,9 @@ void KFState::postFitSigmaChecks( << time << "\tLargest meas error is : " << maxMeasRatio << "\tAT " << measChunkIndex << " :\t" << kfMeas.obsKeys[measChunkIndex] << "\n"; - auto mask = (H.col(stateIndex).array() != 0); // Mask out referencing measurements, i.e. - // non-zero values in column stateIndex of H - measRatios.array() *= - mask.cast(); // Set measRatios of non-referencing measurements to 0 - + measRatios = + (H.col(stateIndex).array() != 0) + .select(measRatios, 0); // Set measRatios of non-referencing measurements to 0 maxMeasRatio = measRatios.abs().maxCoeff(&measIndex); measChunkIndex = measIndex + begH; diff --git a/src/cpp/common/enums.h b/src/cpp/common/enums.h index 0b43036d4..019ba2953 100644 --- a/src/cpp/common/enums.h +++ b/src/cpp/common/enums.h @@ -428,6 +428,13 @@ enum class E_IonoMapFn : int KLOBUCHAR ///< Klobuchar mapping function }; +enum class E_SbasMode : int +{ + L1, ///< L1-SBAS + DFMC, ///< Dual Frequency Multi-Constellation (DFMC) SBAS + PVS ///< Precise Point Positioning Via SouthPAN (PVS) +}; + enum class E_IonoFrame : int { EARTH_FIXED, ///< Earth-fixed reference frame @@ -1398,7 +1405,7 @@ enum class E_SlrRangeType : short int // from crd_v2.01.pdf p7 ONE_WAY = 1, // one-way ranging TWO_WAY = 2, // two-way ranging RX_ONLY = 3, // receive times only - MIXED = 3 + MIXED = 4 }; // mixed (for real-time data recording, and combination of one- and two-way ranging, e.g., T2L2) enum class E_UBXClass : short int diff --git a/src/cpp/common/ephSBAS.cpp b/src/cpp/common/ephSBAS.cpp index a8e9e6979..7a1d0769f 100644 --- a/src/cpp/common/ephSBAS.cpp +++ b/src/cpp/common/ephSBAS.cpp @@ -6,14 +6,8 @@ #include "common/navigation.hpp" #include "sbas/sbas.hpp" -bool dfmc2Pos(Trace& trace, GTime time, SatPos& satPos, Navigation& nav) -{ - return false; -} - bool satPosSBAS(Trace& trace, GTime time, GTime teph, SatPos& satPos, Navigation& nav) { - loadSBASdata(trace, teph, nav); SBASMaps& sbsMaps = satPos.satNav_ptr->currentSBAS; SatSys& Sat = satPos.Sat; Vector3d& rSat = satPos.rSatApc; @@ -30,51 +24,50 @@ bool satPosSBAS(Trace& trace, GTime time, GTime teph, SatPos& satPos, Navigation ephPosValid = false; ephClkValid = false; - double maxdt = 30; - switch (acsConfig.sbsInOpts.freq) - { - case 1: - maxdt = acsConfig.sbsInOpts.prec_aproach ? 12 : 16; - break; - case 5: - maxdt = acsConfig.sbsInOpts.prec_aproach ? 12 : 16; - break; - default: - return false; - } + double maxdt = acsConfig.sbsInOpts.prec_aproach ? 12 : 18; - bool pass = false; - SBASIntg sbsInt; - for (auto& [iodm, intData] : sbsMaps.Integrity) - { - if (fabs((time - intData.trec).to_double()) > maxdt) - continue; - pass = true; - sbsInt = intData; - } - if (!pass) + int selIODP = -1; + int selIODF = -1; + SBASFast sbsFast; + for (auto& [iodp, fastUMap] : sbsMaps.fastUpdt) + for (auto& [updtTime, iodf] : fastUMap) + { + if (fabs((time - updtTime).to_double()) > maxdt) + continue; + if (sbsMaps.fastCorr.find(iodf) == sbsMaps.fastCorr.end()) + continue; + if ((time - sbsMaps.fastCorr[iodf].tIntg).to_double() > maxdt) + continue; + + if (acsConfig.sbsInOpts.freq == 1 && + (time - sbsMaps.fastCorr[iodf].tFast).to_double() > sbsMaps.fastCorr[iodf].IValid) + continue; + + sbsFast = sbsMaps.fastCorr[iodf]; + selIODP = iodp; + selIODF = iodf; + break; + } + if (selIODP < 0) { tracepdeex(4, trace, "\nSBASEPH No Integrity data for %s", Sat.id().c_str()); return false; } - pass = false; int selIode = -1; - for (auto& [updtTime, iode] : sbsMaps.corrUpdt) + for (auto& [updtTime, iode] : sbsMaps.slowUpdt[selIODP]) { auto& slowCorr = sbsMaps.slowCorr[iode]; - if (slowCorr.Ivalid < 0) - { + if (slowCorr.iodp != selIODP) continue; - } + if (slowCorr.Ivalid < 0) + continue; if (fabs((time - slowCorr.trec).to_double()) > slowCorr.Ivalid) - { continue; - } - pass = true; + bool pass = true; pass &= satPosBroadcast(trace, time, teph, satPos, nav, iode); pass &= satClkBroadcast(trace, time, teph, satPos, nav, iode); if (pass) @@ -83,13 +76,29 @@ bool satPosSBAS(Trace& trace, GTime time, GTime teph, SatPos& satPos, Navigation break; } } - if (!pass) + if (selIode < 0) { tracepdeex(4, trace, "\nSBASEPH No Correction data for %s", Sat.id().c_str()); return false; } + + // SBAS variance should be treated separately + posVar = 0.0; + if (acsConfig.sbsInOpts.pvs_on_dfmc) + { + clkVar = 2.5E-6; + } + else + { + clkVar = 1E4; + usedSBASIODMap[Sat].tUsed = time; + usedSBASIODMap[Sat].iodp = selIODP; + usedSBASIODMap[Sat].iodf = selIODF; + usedSBASIODMap[Sat].iode = selIode; + } + tracepdeex( - 5, + 2, trace, "\nBRDCEPH %s %s %13.3f %13.3f %13.3f %13.3f %4d", time.to_string().c_str(), @@ -101,40 +110,55 @@ bool satPosSBAS(Trace& trace, GTime time, GTime teph, SatPos& satPos, Navigation selIode ); - switch (acsConfig.sbsInOpts.freq) + auto& sbsSlow = sbsMaps.slowCorr[selIode]; + double dt = (time - sbsSlow.toe).to_double(); + for (int i = 0; i < 3; i++) { - case 5: - clkVar = estimateDFMCVar(trace, time, satPos, sbsInt) / SQR(CLIGHT); - break; - default: - return false; + rSat[i] += sbsSlow.dPos[i] + dt * sbsSlow.ddPos[i]; + satVel[i] += sbsSlow.ddPos[i]; } - posVar = 0.0; - if (clkVar < 0) - { - tracepdeex(4, trace, "\nSBASEPH Unknown Vairance for %s", Sat.id().c_str()); - return false; - } - auto& sbs = sbsMaps.slowCorr[selIode]; - double dt = (time - sbs.toe).to_double(); - for (int i = 0; i < 3; i++) + satClk += (sbsSlow.dPos[3] + dt * sbsSlow.ddPos[3]) / CLIGHT; + satClkVel += (sbsSlow.ddPos[3]) / CLIGHT; + + tracepdeex( + 2, + trace, + "\n %s SC %.3f from %s", + Sat.id().c_str(), + sbsSlow.dPos[3], + sbsSlow.toe.to_string() + ); + + if (acsConfig.sbsInOpts.freq == 1) { - rSat[i] += sbs.dPos[i] + dt * sbs.ddPos[i]; - satVel[i] += sbs.ddPos[i]; + dt = (time - sbsFast.tFast).to_double(); + satClk += (sbsFast.dClk + dt * sbsFast.ddClk) / CLIGHT; + satClkVel += (sbsFast.ddClk) / CLIGHT; + + tracepdeex( + 2, + trace, + "\n %s FC %.3f from %s", + Sat.id().c_str(), + sbsFast.dClk + dt * sbsFast.ddClk, + sbsFast.tFast.to_string() + ); } - satClk += (sbs.dPos[3] + dt * sbs.ddPos[3]) / CLIGHT; - satClkVel += (sbs.ddPos[3]) / CLIGHT; - if (Sat.sys == E_Sys::GPS && acsConfig.sbsInOpts.use_do259) + if (Sat.sys == E_Sys::GPS) { - Eph* eph_ptr = seleph(trace, time, Sat, E_NavMsgType::LNAV, selIode, nav); - if (eph_ptr == nullptr) - return false; - satClk -= eph_ptr->tgd[0]; + if (acsConfig.sbsInOpts.freq == 1 || acsConfig.sbsInOpts.use_do259) + { + Eph* eph_ptr = seleph(trace, time, Sat, E_NavMsgType::LNAV, selIode, nav); + if (eph_ptr == nullptr) + return false; + satClk -= eph_ptr->tgd[0]; + tracepdeex(2, trace, "\n %s tgd %.3f", Sat.id().c_str(), -eph_ptr->tgd[0] * CLIGHT); + } } tracepdeex( - 5, + 2, trace, "\nSBASEPH %s %s %13.3f %13.3f %13.3f %13.3f %4d", time.to_string().c_str(), diff --git a/src/cpp/common/ephemeris.hpp b/src/cpp/common/ephemeris.hpp index d19a95ddf..93eaf1c13 100644 --- a/src/cpp/common/ephemeris.hpp +++ b/src/cpp/common/ephemeris.hpp @@ -491,5 +491,3 @@ bool satClkSSR(Trace& trace, GTime time, GTime teph, SatPos& satPos, Navigation& double relativity1(Vector3d& rSat, Vector3d& satVel); bool satPosSBAS(Trace& trace, GTime time, GTime teph, SatPos& satPos, Navigation& nav); - -bool satClkSBAS(Trace& trace, GTime time, GTime teph, SatPos& satPos, Navigation& nav); \ No newline at end of file diff --git a/src/cpp/common/ionModels.cpp b/src/cpp/common/ionModels.cpp index 92d543aab..468618fd6 100644 --- a/src/cpp/common/ionModels.cpp +++ b/src/cpp/common/ionModels.cpp @@ -12,6 +12,7 @@ #include "common/observations.hpp" #include "common/satStat.hpp" #include "common/trace.hpp" +#include "iono/ionoSBAS.hpp" #include "orbprop/coordinates.hpp" constexpr double VAR_IONO = 60.0 * 60.0; // init variance iono-delay @@ -479,6 +480,31 @@ bool ionoModel( return true; } + case E_IonoMode::SBAS: + { + dion = 0; + var = SQR(ERR_ION); + if (acsConfig.sbsInOpts.freq == 5) + { + double sig = 0.018 + 40 / (261 + SQR(azel.el * R2D)); + var = sig * sig; + return true; + } + + if (acsConfig.sbsInOpts.freq == 1) + { + dion = ionmodelSBAS(time, pos, azel, var); + if (var < 0) + { + dion = 0.0; + var = 1600; + return false; + } + return true; + } + + return false; + } default: { dion = 0; diff --git a/src/cpp/common/ntripBroadcast.cpp b/src/cpp/common/ntripBroadcast.cpp index 292f3c4a9..c38a22cba 100644 --- a/src/cpp/common/ntripBroadcast.cpp +++ b/src/cpp/common/ntripBroadcast.cpp @@ -252,15 +252,11 @@ void NtripUploader::messageTimeoutHandler(const boost::system::error_code& err) auto buffer = encodeSsrOrbClk(ssrOutMap, messCode); bool write = encodeWriteMessageToBuffer(buffer); - if (traceLevel > 5) + if (write == false) { - // debugSSR(t0, targetTime, sys, ssrOutMap); - - if (write == false) - { - std::cout << "RtcmMessageType::" << enum_to_string(messCode) - << " was not written" << "\n"; - } + std::cout << "RtcmMessageType::" << enum_to_string(messCode) + << " was not written" + << "\n"; } break; diff --git a/src/cpp/common/pos.cpp b/src/cpp/common/pos.cpp index 50951ba08..d312bc35b 100644 --- a/src/cpp/common/pos.cpp +++ b/src/cpp/common/pos.cpp @@ -110,7 +110,7 @@ void writePOSHeader(Trace& output, string name, GTime time) void writePOSEntry(Trace& output, Receiver& rec, KFState& kfState) { VectorEcef apriori = rec.aprioriPos; - VectorEcef xyz = apriori; + VectorEcef xyz; Matrix3d vcv; for (auto& [kfKey, index] : kfState.kfIndexMap) @@ -133,6 +133,11 @@ void writePOSEntry(Trace& output, Receiver& rec, KFState& kfState) } } + if (xyz.isZero()) + { + return; + } + VectorPos pos = ecef2pos(xyz); VectorEcef aprEcef = posAprioriValue[rec.id]; VectorPos aprPos = ecef2pos(posAprioriValue[rec.id]); diff --git a/src/cpp/common/rtcmTrace.cpp b/src/cpp/common/rtcmTrace.cpp index efcd0f2be..a71fa5d8c 100644 --- a/src/cpp/common/rtcmTrace.cpp +++ b/src/cpp/common/rtcmTrace.cpp @@ -98,7 +98,7 @@ void RtcmTrace::traceSsrEph(RtcmMessageType messCode, SatSys Sat, SSREph& ssrEph boost::json::object doc; doc["type"] = "ssrEph"; doc["Mountpoint"] = rtcmMountpoint; - doc["MessageNumber"] = static_cast(messCode); + doc["MessageNumber"] = rtcmTypeToMessageNumber(messCode); doc["MessageType"] = enum_to_string(messCode); doc["ReceivedSentTimeGPST"] = nearTime.to_string(); doc["EpochTimeGPST"] = ssrEph.ssrMeta.receivedTime.to_string(); @@ -144,7 +144,7 @@ void RtcmTrace::traceSsrClk(RtcmMessageType messCode, SatSys Sat, SSRClk& ssrClk boost::json::object doc; doc["type"] = "ssrClk"; doc["Mountpoint"] = rtcmMountpoint; - doc["MessageNumber"] = static_cast(messCode); + doc["MessageNumber"] = rtcmTypeToMessageNumber(messCode); doc["MessageType"] = enum_to_string(messCode); doc["ReceivedSentTimeGPST"] = nearTime.to_string(); doc["EpochTimeGPST"] = ssrClk.ssrMeta.receivedTime.to_string(); @@ -185,7 +185,7 @@ void RtcmTrace::traceSsrUra(RtcmMessageType messCode, SatSys Sat, SSRUra& ssrUra boost::json::object doc; doc["type"] = "ssrURA"; doc["Mountpoint"] = rtcmMountpoint; - doc["MessageNumber"] = static_cast(messCode); + doc["MessageNumber"] = rtcmTypeToMessageNumber(messCode); doc["MessageType"] = enum_to_string(messCode); doc["ReceivedSentTimeGPST"] = nearTime.to_string(); doc["EpochTimeGPST"] = ssrUra.ssrMeta.receivedTime.to_string(); @@ -222,7 +222,7 @@ void RtcmTrace::traceSsrHRClk(RtcmMessageType messCode, SatSys Sat, SSRHRClk& Ss boost::json::object doc; doc["type"] = "ssrHRClk"; doc["Mountpoint"] = rtcmMountpoint; - doc["MessageNumber"] = static_cast(messCode); + doc["MessageNumber"] = rtcmTypeToMessageNumber(messCode); doc["MessageType"] = enum_to_string(messCode); doc["ReceivedSentTimeGPST"] = nearTime.to_string(); doc["EpochTimeGPST"] = SsrHRClk.ssrMeta.receivedTime.to_string(); @@ -264,7 +264,7 @@ void RtcmTrace::traceSsrCodeBias( boost::json::object doc; doc["type"] = "ssrCodeBias"; doc["Mountpoint"] = rtcmMountpoint; - doc["MessageNumber"] = static_cast(messCode); + doc["MessageNumber"] = rtcmTypeToMessageNumber(messCode); doc["MessageType"] = enum_to_string(messCode); doc["ReceivedSentTimeGPST"] = nearTime.to_string(); doc["EpochTimeGPST"] = ssrBias.ssrMeta.receivedTime.to_string(); @@ -307,7 +307,7 @@ void RtcmTrace::traceSsrPhasBias( boost::json::object doc; doc["type"] = "ssrPhasBias"; doc["Mountpoint"] = rtcmMountpoint; - doc["MessageNumber"] = static_cast(messCode); + doc["MessageNumber"] = rtcmTypeToMessageNumber(messCode); doc["MessageType"] = enum_to_string(messCode); doc["ReceivedSentTimeGPST"] = nearTime.to_string(); doc["EpochTimeGPST"] = ssrBias.ssrMeta.receivedTime.to_string(); @@ -382,7 +382,7 @@ void RtcmTrace::traceBrdcEph( // todo aaron, template this for gps/glo? // Note the Satellite id is not set in rinex correctly as we a mixing GNSS systems. doc["type"] = "brdcEph"; doc["Mountpoint"] = rtcmMountpoint; - doc["MessageNumber"] = static_cast(messCode); + doc["MessageNumber"] = rtcmTypeToMessageNumber(messCode); doc["MessageType"] = enum_to_string(messCode); doc["ReceivedSentTimeGPST"] = nearTime.to_string(); doc["Type"] = enum_to_string(eph.type); @@ -418,7 +418,7 @@ void RtcmTrace::traceBrdcEph(RtcmMessageType messCode, Geph& geph) // Note the Satellite id is not set in rinex correctly as we a mixing GNSS systems. doc["type"] = "brdcEph"; doc["Mountpoint"] = rtcmMountpoint; - doc["MessageNumber"] = static_cast(messCode); + doc["MessageNumber"] = rtcmTypeToMessageNumber(messCode); doc["MessageType"] = enum_to_string(messCode); doc["ReceivedSentTimeGPST"] = nearTime.to_string(); doc["Sat"] = geph.Sat.id(); @@ -663,7 +663,7 @@ void RtcmTrace::traceMSM(RtcmMessageType messCode, GTime time, SatSys Sat, Sig& boost::json::object doc; doc["type"] = "MSM"; doc["Mountpoint"] = rtcmMountpoint; - doc["MessageNumber"] = static_cast(messCode); + doc["MessageNumber"] = rtcmTypeToMessageNumber(messCode); doc["MessageType"] = enum_to_string(messCode); doc["ReceivedSentTimeGPST"] = nearTime.to_string(); doc["EpochTimeGPST"] = time.to_string(); diff --git a/src/cpp/common/sbfDecoder.cpp b/src/cpp/common/sbfDecoder.cpp new file mode 100644 index 000000000..ee2f5d2b9 --- /dev/null +++ b/src/cpp/common/sbfDecoder.cpp @@ -0,0 +1,997 @@ +// #pragma GCC optimize ("O0") + +#include "common/sbfDecoder.hpp" + +#include "architectureDocs.hpp" +#include "common/constants.hpp" +#include "common/enums.h" +#include "common/gTime.hpp" +#include "common/icdDecoder.hpp" +#include "common/navigation.hpp" +#include "common/observations.hpp" +#include "common/streamSbf.hpp" +#include "sbas/sbas.hpp" + +using std::lock_guard; +using std::mutex; + +GTime lastObstime; + +SatSys sbsID2Sat(unsigned int id) +{ + SatSys sat; + if (id > 0 && id < 38) + { + sat.sys = E_Sys::GPS; + sat.prn = id; + return sat; + } + if (id > 37 && id < 62) + { + sat.sys = E_Sys::GLO; + sat.prn = id - 37; + return sat; + } + if (id == 62) + { + sat.sys = E_Sys::GLO; + sat.prn = 0; + return sat; + } + if (id > 62 && id < 69) + { + sat.sys = E_Sys::GLO; + sat.prn = id - 38; + return sat; + } + if (id > 70 && id < 107) + { + sat.sys = E_Sys::GAL; + sat.prn = id - 70; + return sat; + } + if (id > 119 && id < 141) + { + sat.sys = E_Sys::SBS; + sat.prn = id - 100; + return sat; + } + if (id > 140 && id < 181) + { + sat.sys = E_Sys::BDS; + sat.prn = id - 140; + return sat; + } + if (id > 180 && id < 191) + { + sat.sys = E_Sys::QZS; + sat.prn = id - 180; + return sat; + } + if (id > 190 && id < 198) + { + sat.sys = E_Sys::IRN; + sat.prn = id - 190; + return sat; + } + if (id > 197 && id < 216) + { + sat.sys = E_Sys::SBS; + sat.prn = id - 157; + return sat; + } + if (id > 215 && id < 223) + { + sat.sys = E_Sys::IRN; + sat.prn = id - 208; + return sat; + } + if (id > 222 && id < 246) + { + sat.sys = E_Sys::BDS; + sat.prn = id - 182; + return sat; + } + return sat; +} + +E_ObsCode sbdObsCode[40] = { + E_ObsCode::L1C, // GPS + E_ObsCode::L1W, // GPS + E_ObsCode::L2W, // GPS + E_ObsCode::L2L, // GPS + E_ObsCode::L5Q, // GPS + E_ObsCode::L1L, // GPS + E_ObsCode::L1C, // QZS + E_ObsCode::L2L, // QZS + E_ObsCode::L1C, // GLO + E_ObsCode::L1P, // GLO + E_ObsCode::L2P, // GLO + E_ObsCode::L2C, // GLO + E_ObsCode::L3Q, // GLO + E_ObsCode::L1P, // BDS + E_ObsCode::L5P, // BDS + E_ObsCode::L5A, // IRN + E_ObsCode::NONE, // - + E_ObsCode::L1C, // GAL + E_ObsCode::NONE, // - + E_ObsCode::L6C, // GAL + E_ObsCode::L5Q, // GAL + E_ObsCode::L7Q, // GAL + E_ObsCode::L8Q, // GAL + E_ObsCode::NONE, // - + E_ObsCode::L1C, // SBS + E_ObsCode::L5I, // SBS + E_ObsCode::L5Q, // QZS + E_ObsCode::L6C, // QZS + E_ObsCode::L5D, // BDS + E_ObsCode::L7I, // BDS + E_ObsCode::L6I, // BDS + E_ObsCode::L1L, // QZS + E_ObsCode::L1Z, // QZS + E_ObsCode::L7D, // BDS + E_ObsCode::NONE, // - + E_ObsCode::NONE, // - + E_ObsCode::L1P, // IRN + E_ObsCode::L1E, // QZS + E_ObsCode::L5P // QZS +}; + +unsigned int bin2unsigned(vector& data, int init, int size) +{ + uint64_t value = 0; + for (int i = 0; i < size; i++) + value += data[init + i] << (8 * i); + return value; +} + +int bin2signed(vector& data, int init, int size) +{ + unsigned int value = bin2unsigned(data, init, size); + unsigned int thres = 1 << (8 * size - 1); + int result = value; + if (value > thres) + result -= 2 * thres; + return result; +} + +int bin2float(vector& data, int init) +{ + float value; + std::memcpy(&value, &data[init], 4); + return value; +} + +int bin2double(vector& data, int init) +{ + double value; + std::memcpy(&value, &data[init], 8); + return value; +} + +void SbfDecoder::decodeMeasEpoch(GTime time, vector& data) +{ + int numMeas = data[6]; + int mess1size = data[7]; + int mess2size = data[8]; + int flags = data[9]; + double clock = data[10] * 0.001; + + map obsMap; + int ind = 12; + for (int i = 0; i < numMeas; i++) + { + int ind0 = ind; + ind++; + + int codeID = data[ind++] & 0x1F; + int satID = data[ind++]; + unsigned int pseudMSB = data[ind++] & 0x07; + unsigned int pseudLSB = bin2unsigned(data, ind, 4); + ind += 4; + int doppInt = bin2signed(data, ind, 4); + ind += 4; + unsigned int carriLSB = bin2unsigned(data, ind, 2); + ind += 2; + int carriMSB = bin2signed(data, ind, 1); + ind++; + unsigned int CN0Int = data[ind++]; + unsigned int lockTime = bin2unsigned(data, ind, 2); + ind += 2; + int codeID2 = data[ind++] >> 3; + unsigned int numMeas2 = data[ind++]; + + SatSys sat = sbsID2Sat(satID); + if (codeID == 31) + codeID = codeID2 + 32; + E_ObsCode code = sbdObsCode[codeID]; + auto ft = code2Freq[sat.sys][code]; + auto waveLen = genericWavelength[ft]; + double Pr1 = 4294967.296 * pseudMSB + 0.001 * pseudLSB; + double Ph1 = Pr1 / waveLen + 65.536 * carriMSB + (carriMSB > 0 ? 0.001 : -0.001) * carriLSB; + double Dp1 = doppInt * 0.0001; + + auto& obs = obsMap[sat]; + obs.Sat = sat; + obs.time = time; + obs.mount = recId; + + Sig sig; + sig.code = code; + sig.P = Pr1; + sig.L = Ph1; + sig.D = Dp1; + obs.sigsLists[ft].push_back(sig); + + ind = ind0 + mess1size; + for (int j = 0; j < numMeas2; j++) + { + int ind1 = ind; + codeID = data[ind++] & 0x1F; + lockTime = data[ind++]; + CN0Int = data[ind++]; + unsigned int offstMSB = data[ind++]; + carriMSB = bin2signed(data, ind, 1); + ind++; + codeID2 = data[ind++] >> 3; + unsigned int pseudLSB = bin2unsigned(data, ind, 2); + ind += 2; + carriLSB = bin2unsigned(data, ind, 2); + ind += 2; + unsigned int dopplLSB = bin2unsigned(data, ind, 2); + ind += 2; + + if (codeID == 31) + codeID = codeID2 + 32; + E_ObsCode code2 = sbdObsCode[codeID]; + auto ft2 = code2Freq[sat.sys][code2]; + auto waveLen2 = genericWavelength[ft2]; + int pseudMSB = offstMSB & 0x07; + if (pseudMSB > 4) + pseudMSB -= 7; + int dopplMSB = (offstMSB >> 3) & 0x07; + if (dopplMSB > 4) + dopplMSB -= 7; + + Sig sig2; + sig2.code = code2; + sig2.P = Pr1 + 65.536 * pseudMSB + 0.001 * pseudLSB; + sig2.L = sig2.P / waveLen2 + 65.536 * carriMSB + 0.001 * carriLSB; + sig2.D = Dp1 * waveLen2 / waveLen + 6.5536 * dopplMSB + 0.0001 * dopplLSB; + obs.sigsLists[ft2].push_back(sig2); + + ind = ind1 + mess2size; + } + } + + for (auto& [Sat, obs] : obsMap) + { + if (Sat.sys == E_Sys::GLO) + continue; // GLONASS not supported for now + sbfObsList.push_back((shared_ptr)obs); + } + obsListList.push_back(sbfObsList); + sbfObsList.clear(); + return; +} + +void SbfDecoder::decodeEndOfMeas(GTime time) +{ + obsListList.push_back(sbfObsList); + sbfObsList.clear(); + lastObstime = time; +} + +std::string val2Hex(unsigned char val) +{ + std::stringstream ss; + ss << std::hex << std::uppercase << std::setw(2) << std::setfill('0') << val; + return ss.str(); +} + +void decodeGEORawL1(GTime time, vector& data) +{ + SBASMessage sbs; + sbs.prn = data[6]; + if (sbs.prn > 197) + sbs.prn -= 57; + if (data[7] == 0) // SBAS CRC failed + return; + // to do: reject frame is data[8] (Viterbi error count) exceds a threshold + unsigned char navSource = data[9] & 0x1F; + if (navSource != 24) + { + return; + } + sbs.freq = 1; + sbs.type = data[13] >> 2; + + // data[10] (FreqNr) is not relevant for this message + // data[11] (RxChannel) is not supported by Ginan + for (int i = 0; i < 32; i++) + { + sbs.data[i] = data[i + 12]; + sbs.message += val2Hex(sbs.data[i]); + } + + { + lock_guard guard(sbasMessagesMutex); + + for (auto it = sbasMessages.begin(); it != sbasMessages.end();) + { + auto& [tof, sbasData] = *it; + if ((time - tof).to_double() > MAX_SBAS_MESS_AGE) + { + it = sbasMessages.erase(it); + } + else + { + it++; + } + } + + if (sbs.prn == acsConfig.sbsInOpts.prn && sbs.freq == acsConfig.sbsInOpts.freq) + sbasMessages[time] = sbs; + } +} + +void decodeGEORawL5(GTime time, vector& data) +{ + SBASMessage sbs; + sbs.prn = data[6]; + if (sbs.prn > 197) + sbs.prn -= 57; + if (data[7] == 0) // SBAS CRC failed + return; + // to do: reject frame if data[8] (Viterbi error count) exceds a threshold + unsigned char navSource = data[9] & 0x1F; + if (navSource != 25) + { + return; + } + sbs.freq = 5; + sbs.type = (data[12] & 0x0F) * 4 + (data[13] >> 6); + + // data[10] (FreqNr) is not relevant for this message + // data[11] (RxChannel) is not supported by Ginan + for (int i = 0; i < 32; i++) + { + sbs.data[i] = data[i + 12]; + sbs.message += val2Hex(sbs.data[i]); + } + + { + lock_guard guard(sbasMessagesMutex); + + for (auto it = sbasMessages.begin(); it != sbasMessages.end();) + { + auto& [tof, sbasData] = *it; + if ((time - tof).to_double() > MAX_SBAS_MESS_AGE) + { + it = sbasMessages.erase(it); + } + else + { + it++; + } + } + + if (sbs.prn == acsConfig.sbsInOpts.prn && sbs.freq == acsConfig.sbsInOpts.freq) + sbasMessages[time] = sbs; + } +} + +void decodeGPSNav(GTime time, vector& data) +{ + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::GPS) + return; + Eph eph; + eph.type = E_NavMsgType::LNAV; + eph.Sat = sat; + // data[7] : Reserved + int ind = 8; + eph.week = bin2unsigned(data, ind, 2); + ind += 2; + eph.code = data[ind++]; + eph.sva = data[ind++]; + int svh = data[ind++]; + eph.flag = data[ind++]; + eph.iodc = bin2unsigned(data, ind, 2); + ind += 2; + eph.iode = data[ind++]; + ind++; // data[17] : IODE in subframe 3 + eph.fit = data[ind++] ? 0 : 4; + ind++; // data[19] : Reserved + eph.tgd[0] = bin2float(data, ind); + ind += 4; + double toc = bin2unsigned(data, ind, 4); + ind += 4; + eph.f2 = bin2float(data, ind); + ind += 4; + eph.f1 = bin2float(data, ind); + ind += 4; + eph.f0 = bin2float(data, ind); + ind += 4; + eph.crs = bin2float(data, ind); + ind += 4; + eph.deln = bin2float(data, ind) * SC2RAD; + ind += 4; + eph.M0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.cuc = bin2float(data, ind); + ind += 4; + eph.e = bin2double(data, ind); + ind += 8; + eph.cus = bin2float(data, ind); + ind += 4; + eph.sqrtA = bin2double(data, ind); + ind += 8; + double toe = bin2unsigned(data, ind, 4); + ind += 4; + eph.cic = bin2float(data, ind); + ind += 4; + eph.OMG0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.cis = bin2float(data, ind); + ind += 4; + eph.i0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.crc = bin2float(data, ind); + ind += 4; + eph.omg = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.OMGd = bin2float(data, ind) * SC2RAD; + ind += 4; + eph.idot = bin2float(data, ind); + ind += 4; + int wn_toc = bin2unsigned(data, ind, 2); + ind += 2; + int wn_toe = bin2unsigned(data, ind, 2); + ind += 2; + + eph.ura[0] = svaToUra(eph.sva); + eph.svh = (E_Svh)svh; + wn_toc += 1024 * floor((eph.week - wn_toc + 512) / 1024); + wn_toe += 1024 * floor((eph.week - wn_toe + 512) / 1024); + eph.toc = gpst2time(wn_toc, toc); + eph.toe = gpst2time(wn_toe, toe); + nav.ephMap[eph.Sat][eph.type][eph.toe] = eph; +} + +void decodeGPSIon(GTime time, vector& data) +{ + ION ion; + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::GPS) + return; + ion.Sat = sat; + ion.type = E_NavMsgType::LNAV; + ion.ttm = time; + int ind = 8; + ion.a0 = bin2float(data, ind); + ind += 4; + ion.a1 = bin2float(data, ind); + ind += 4; + ion.a2 = bin2float(data, ind); + ind += 4; + ion.a3 = bin2float(data, ind); + ind += 4; + ion.b0 = bin2float(data, ind); + ind += 4; + ion.b1 = bin2float(data, ind); + ind += 4; + ion.b2 = bin2float(data, ind); + ind += 4; + ion.b3 = bin2float(data, ind); + ind += 4; + + nav.ionMap[ion.Sat.sys][ion.type][ion.ttm] = ion; +} + +void decodeGPSUtc(GTime time, vector& data) +{ + STO sto; + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::GPS) + return; + sto.Sat = sat; + sto.type = E_NavMsgType::LNAV; + sto.code = E_StoCode::GPUT; + sto.ttm = time; + int ind = 8; + sto.A1 = bin2float(data, ind); + ind += 4; + sto.A0 = bin2double(data, ind); + ind += 8; + double tot = bin2unsigned(data, ind, 4); + ind += 4; + double tot_ = tot + 86400; // GPS vs UTC week + if (tot_ > 604800) + tot_ -= 604800; // GPS v UTC week + sto.tot = GTime((GTow)tot_, time); + nav.stoMap[sto.code][sto.type][sto.tot] = sto; + + // to do: input leap seconds +} + +void decodeGALNav(GTime time, vector& data) +{ + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::GAL) + return; + Eph eph; + eph.Sat = sat; + int navSource = data[7]; + switch (navSource) + { + case 2: + eph.type = E_NavMsgType::INAV; + break; + case 16: + eph.type = E_NavMsgType::FNAV; + break; + default: + return; + } + + int ind = 8; + eph.sqrtA = bin2double(data, ind); + ind += 8; + eph.M0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.e = bin2double(data, ind); + ind += 8; + eph.i0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.omg = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.OMG0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.OMGd = bin2float(data, ind) * SC2RAD; + ind += 4; + eph.idot = bin2float(data, ind) * SC2RAD; + ind += 4; + eph.deln = bin2float(data, ind) * SC2RAD; + ind += 4; + eph.cuc = bin2float(data, ind); + ind += 4; + eph.cus = bin2float(data, ind); + ind += 4; + eph.crc = bin2float(data, ind); + ind += 4; + eph.crs = bin2float(data, ind); + ind += 4; + eph.cic = bin2float(data, ind); + ind += 4; + eph.cis = bin2float(data, ind); + ind += 4; + double toe = bin2unsigned(data, ind, 4); + ind += 4; + double toc = bin2unsigned(data, ind, 4); + ind += 4; + eph.f2 = bin2float(data, ind); + ind += 4; + eph.f1 = bin2float(data, ind); + ind += 4; + eph.f0 = bin2float(data, ind); + ind += 4; + int wn_toc = bin2unsigned(data, ind, 2); + ind += 2; + int wn_toe = bin2unsigned(data, ind, 2); + ind += 2; + eph.iodc = bin2unsigned(data, ind, 2); + ind += 2; + int svh = bin2unsigned(data, ind, 2); + ind += 2; + ind++; + eph.ura[0] = svaToSisa(data[ind++]); // SISA for L1L5 + eph.ura[1] = svaToSisa(data[ind++]); // SISA for L1L7 + eph.ura[2] = svaToSisa(data[ind++]); // SISA for L1L6 + eph.tgd[0] = bin2float(data, ind); + ind += 4; // BGD for L1L5 + eph.tgd[1] = bin2float(data, ind); + ind += 4; // BGD for L1L7 + eph.tgd[2] = bin2float(data, ind); + ind += 4; // BGD for L1L6 + + eph.iode = eph.iodc; + + wn_toc += 1024 * floor((eph.week - wn_toc + 512) / 1024); + wn_toe += 1024 * floor((eph.week - wn_toe + 512) / 1024); + eph.toc = gpst2time(wn_toc, toc); + eph.toe = gpst2time(wn_toe, toe); + + int l1svh = svh & 0x0F; + int l5svh = svh >> 4 & 0x0F; + int l7svh = svh >> 8 & 0x0F; + if (l1svh & 1) + { + eph.e1_dvs = l1svh >> 1 & 1; + eph.e1_hs = l1svh >> 2 & 3; + } + if (l5svh & 1) + { + eph.e5a_dvs = l5svh >> 1 & 1; + eph.e5a_hs = l5svh >> 2 & 3; + } + if (l7svh & 1) + { + eph.e5b_dvs = l7svh >> 1 & 1; + eph.e5b_hs = l7svh >> 2 & 3; + } + nav.ephMap[eph.Sat][eph.type][eph.toe] = eph; +} + +void decodeGALIon(GTime time, vector& data) +{ + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::GAL) + return; + ION ion; + ion.Sat = sat; + int navSource = data[7]; + switch (navSource) + { + case 2: + ion.type = E_NavMsgType::INAV; + break; + case 16: + ion.type = E_NavMsgType::FNAV; + break; + default: + return; + } + ion.ttm = time; + int ind = 8; + ion.ai0 = bin2float(data, ind); + ind += 4; + ion.ai1 = bin2float(data, ind); + ind += 4; + ion.ai2 = bin2float(data, ind); + ind += 4; + ion.flag = data[ind]; + nav.ionMap[ion.Sat.sys][ion.type][ion.ttm] = ion; +} + +void decodeGALUtc(GTime time, vector& data) +{ + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::GAL) + return; + STO sto; + sto.Sat = sat; + int navSource = data[7]; + switch (navSource) + { + case 2: + sto.type = E_NavMsgType::INAV; + break; + case 16: + sto.type = E_NavMsgType::FNAV; + break; + default: + return; + } + sto.code = E_StoCode::GLUT; + sto.ttm = time; + int ind = 8; + sto.A1 = bin2float(data, ind); + ind += 4; + sto.A0 = bin2double(data, ind); + ind += 8; + double tot = bin2unsigned(data, ind, 4); + ind += 4; + double tot_ = tot + 86400; // GPS vs UTC week + if (tot_ > 604800) + tot_ -= 604800; // GPS v UTC week + sto.tot = GTime((GTow)tot_, time); + nav.stoMap[sto.code][sto.type][sto.tot] = sto; + + // to do: input leap seconds +} + +void decodeGALGstGps(GTime time, vector& data) +{ + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::GAL) + return; + STO sto; + sto.Sat = sat; + int navSource = data[7]; + switch (navSource) + { + case 2: + sto.type = E_NavMsgType::INAV; + break; + case 16: + sto.type = E_NavMsgType::FNAV; + break; + default: + return; + } + sto.code = E_StoCode::GAGP; + sto.ttm = time; + int ind = 8; + sto.A1 = bin2float(data, ind) * 1e9; + ind += 4; + sto.A0 = bin2double(data, ind) * 1e9; + ind += 8; + double tot = bin2unsigned(data, ind, 4); + ind += 4; + double tot_ = tot + 86400; // GPS vs UTC week + if (tot_ > 604800) + tot_ -= 604800; // GPS v UTC week + sto.tot = GTime((GTow)tot_, time); + nav.stoMap[sto.code][sto.type][sto.tot] = sto; + + // to do: input leap seconds +} + +void decodeBDSNav(GTime time, vector& data) +{ + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::BDS) + return; + Eph eph; + eph.Sat = sat; + eph.type = E_NavMsgType::D1; + int ind = 8; + eph.week = bin2unsigned(data, ind, 2); + ind += 2; + eph.sva = data[ind++]; + int svh = data[ind++]; + eph.iodc = data[ind++]; + eph.iode = data[ind++]; + ind += 2; + eph.tgd[0] = bin2float(data, ind); + ind += 4; + eph.tgd[1] = bin2float(data, ind); + ind += 4; + double toc = bin2unsigned(data, ind, 4); + ind += 4; + eph.f2 = bin2float(data, ind); + ind += 4; + eph.f1 = bin2float(data, ind); + ind += 4; + eph.f0 = bin2float(data, ind); + ind += 4; + eph.crs = bin2float(data, ind); + ind += 4; + eph.deln = bin2float(data, ind) * SC2RAD; + ind += 4; + eph.M0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.cuc = bin2float(data, ind); + ind += 4; + eph.e = bin2double(data, ind); + ind += 8; + eph.cus = bin2float(data, ind); + ind += 4; + eph.sqrtA = bin2double(data, ind); + ind += 8; + double toe = bin2unsigned(data, ind, 4); + ind += 4; + eph.cic = bin2float(data, ind); + ind += 4; + eph.OMG0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.cis = bin2float(data, ind); + ind += 4; + eph.i0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.crc = bin2float(data, ind); + ind += 4; + eph.omg = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.OMGd = bin2float(data, ind) * SC2RAD; + ind += 4; + eph.idot = bin2float(data, ind) * SC2RAD; + ind += 4; + int wn_toc = bin2unsigned(data, ind, 2); + ind += 2; + int wn_toe = bin2unsigned(data, ind, 2); + ind += 2; + + eph.ura[0] = svaToUra(eph.sva); + wn_toc += 8192 * floor((eph.week - wn_toc + 4096) / 8192); + wn_toe += 8192 * floor((eph.week - wn_toe + 4096) / 8192); + eph.toc = GTime(BWeek(wn_toc), BTow(toc)); + eph.toe = GTime(BWeek(wn_toe), BTow(toe)); + + nav.ephMap[eph.Sat][eph.type][eph.toe] = eph; +} + +void decodeBDSIon(GTime time, vector& data) +{ + ION ion; + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::BDS) + return; + ion.Sat = sat; + ion.type = E_NavMsgType::D1; + ion.ttm = time; + int ind = 8; + ion.a0 = bin2float(data, ind); + ind += 4; + ion.a1 = bin2float(data, ind); + ind += 4; + ion.a2 = bin2float(data, ind); + ind += 4; + ion.a3 = bin2float(data, ind); + ind += 4; + ion.b0 = bin2float(data, ind); + ind += 4; + ion.b1 = bin2float(data, ind); + ind += 4; + ion.b2 = bin2float(data, ind); + ind += 4; + ion.b3 = bin2float(data, ind); + ind += 4; + + nav.ionMap[ion.Sat.sys][ion.type][ion.ttm] = ion; +} + +void decodeBDSUtc(GTime time, vector& data) +{ + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::BDS) + return; + STO sto; + sto.Sat = sat; + sto.type = E_NavMsgType::D1; + sto.code = E_StoCode::BDUT; + sto.ttm = time; + int ind = 8; + sto.A1 = bin2float(data, ind); + ind += 4; + sto.A0 = bin2double(data, ind); + ind += 8; + sto.tot = time; + nav.stoMap[sto.code][sto.type][sto.tot] = sto; + + // to do: input leap seconds +} + +void decodeQZSNav(GTime time, vector& data) +{ + SatSys sat = sbsID2Sat(data[6]); + if (sat.sys != E_Sys::QZS) + return; + Eph eph; + eph.type = E_NavMsgType::LNAV; + eph.Sat = sat; + // data[7] : Reserved + int ind = 8; + eph.week = bin2unsigned(data, ind, 2); + ind += 2; + eph.code = data[ind++]; + eph.sva = data[ind++]; + int svh = data[ind++]; + eph.flag = data[ind++]; + eph.iodc = bin2unsigned(data, ind, 2); + ind += 2; + eph.iode = data[ind++]; + ind++; // data[17] : IODE in subframe 3 + eph.fit = data[ind++] ? 0 : 4; + ind++; // data[19] : Reserved + eph.tgd[0] = bin2float(data, ind); + ind += 4; + double toc = bin2unsigned(data, ind, 4); + ind += 4; + eph.f2 = bin2float(data, ind); + ind += 4; + eph.f1 = bin2float(data, ind); + ind += 4; + eph.f0 = bin2float(data, ind); + ind += 4; + eph.crs = bin2float(data, ind); + ind += 4; + eph.deln = bin2float(data, ind) * SC2RAD; + ind += 4; + eph.M0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.cuc = bin2float(data, ind); + ind += 4; + eph.e = bin2double(data, ind); + ind += 8; + eph.cus = bin2float(data, ind); + ind += 4; + eph.sqrtA = bin2double(data, ind); + ind += 8; + double toe = bin2unsigned(data, ind, 4); + ind += 4; + eph.cic = bin2float(data, ind); + ind += 4; + eph.OMG0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.cis = bin2float(data, ind); + ind += 4; + eph.i0 = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.crc = bin2float(data, ind); + ind += 4; + eph.omg = bin2double(data, ind) * SC2RAD; + ind += 8; + eph.OMGd = bin2float(data, ind) * SC2RAD; + ind += 4; + eph.idot = bin2float(data, ind); + ind += 4; + int wn_toc = bin2unsigned(data, ind, 2); + ind += 2; + int wn_toe = bin2unsigned(data, ind, 2); + ind += 2; + + eph.ura[0] = svaToUra(eph.sva); + eph.svh = (E_Svh)svh; + wn_toc += 1024 * floor((eph.week - wn_toc + 512) / 1024); + wn_toe += 1024 * floor((eph.week - wn_toe + 512) / 1024); + eph.toc = gpst2time(wn_toc, toc); + eph.toe = gpst2time(wn_toe, toe); + nav.ephMap[eph.Sat][eph.type][eph.toe] = eph; +} + +void SbfDecoder::decode(unsigned short int id, vector& data) +{ + if (data.size() < 8) + return; + double tow = bin2unsigned(data, 0, 4) * 0.001; + int week = bin2unsigned(data, 4, 2); + + GTime time = gpst2time(week, tow); + // std::cout << "\nSBF message type " << id << ", " << time.to_string(3); + + switch (id) + { + case 4002: + decodeGALNav(time, data); + return; + case 4004: /*decodeGLONav(time,data); */ + return; // GLONASS not supported + case 4020: + decodeGEORawL1(time, data); + return; + case 4021: + decodeGEORawL5(time, data); + return; + case 4027: + decodeMeasEpoch(time, data); + return; + case 4030: + decodeGALIon(time, data); + return; + case 4031: + decodeGALUtc(time, data); + return; + case 4032: + decodeGALGstGps(time, data); + return; + case 4036: /*decodeGLOTime(time,data); */ + return; // GLONASS not supported + case 4081: + decodeBDSNav(time, data); + return; + case 4095: + decodeQZSNav(time, data); + return; + case 4120: + decodeBDSIon(time, data); + return; + case 4121: + decodeBDSUtc(time, data); + return; + case 5891: + decodeGPSNav(time, data); + return; + case 5893: + decodeGPSIon(time, data); + return; + case 5894: + decodeGPSUtc(time, data); + return; + case 5922: + decodeEndOfMeas(time); + return; + // default: std::cout << " ... not supported, yet"; return; + } +} diff --git a/src/cpp/common/sbfDecoder.hpp b/src/cpp/common/sbfDecoder.hpp new file mode 100644 index 000000000..e25b57c7b --- /dev/null +++ b/src/cpp/common/sbfDecoder.hpp @@ -0,0 +1,59 @@ +#pragma once +// #pragma GCC optimize ("O0") + +#include +#include +#include "common/enums.h" +#include "common/gTime.hpp" +#include "common/icdDecoder.hpp" +#include "common/streamObs.hpp" + +using std::map; +using std::vector; + +#define SBF_PREAMBLE1 0x24 +#define SBF_PREAMBLE2 0x40 + +struct SbfDecoder : ObsLister, IcdDecoder +{ + unsigned int lastTimeTag = 0; + GTime lastTime; + + string recId; + + string raw_sbf_filename; + + ObsList sbfObsList; + GTime lastObstime; + + void decode(unsigned short int id, vector& data); + void decodeMeasEpoch(GTime time, vector& data); + void decodeEndOfMeas(GTime time); + + void recordFrame(unsigned short int id, vector& data, unsigned short int crcRead) + { + if (raw_sbf_filename.empty()) + { + return; + } + + std::ofstream ofs(raw_sbf_filename, std::ofstream::app); + + if (!ofs) + { + return; + } + + // copy the message to the output file + unsigned char c1 = SBF_PREAMBLE1; + unsigned char c2 = SBF_PREAMBLE2; + unsigned short int payloadLength = data.size(); + + ofs.write((char*)&c1, 1); + ofs.write((char*)&c2, 1); + ofs.write((char*)&crcRead, 2); + ofs.write((char*)&id, 2); + ofs.write((char*)&payloadLength, 2); + ofs.write((char*)data.data(), data.size()); + } +}; diff --git a/src/cpp/common/streamSbf.cpp b/src/cpp/common/streamSbf.cpp new file mode 100644 index 000000000..4d0a39bca --- /dev/null +++ b/src/cpp/common/streamSbf.cpp @@ -0,0 +1,85 @@ +#include "common/streamSbf.hpp" +#include +#include "common/packetStatistics.hpp" +#define CLEAN_UP_AND_RETURN_ON_FAILURE \ + \ + if (inputStream.fail()) \ + { \ + inputStream.clear(); \ + inputStream.seekg(pos); \ + return; \ + } + +void SbfParser::parse(std::istream& inputStream) +{ + // std::cout << "Parsing sbf" << "\n"; + + while (inputStream) + { + int pos; + unsigned char c1 = 0; + unsigned char c2 = 0; + while (true) + { + pos = inputStream.tellg(); + + // move c2 back one and replace, (check one byte at a time, not in pairs) + c1 = c2; + inputStream.read((char*)&c2, 1); + + if (inputStream) + { + if (c1 == SBF_PREAMBLE1 && c2 == SBF_PREAMBLE2) + { + break; + } + } + else + { + return; + } + nonFrameByteFound(c2); + } + CLEAN_UP_AND_RETURN_ON_FAILURE; + + preambleFound(); + + unsigned short int crcRead = 0; + inputStream.read((char*)&crcRead, 2); + CLEAN_UP_AND_RETURN_ON_FAILURE; + + unsigned short int id = 0; + inputStream.read((char*)&id, 2); + CLEAN_UP_AND_RETURN_ON_FAILURE; + id = id & 0x1FFF; + + unsigned short int payload_length = 0; + inputStream.read((char*)&payload_length, 2); + CLEAN_UP_AND_RETURN_ON_FAILURE; + + vector payload(payload_length - 8); + inputStream.read((char*)payload.data(), payload_length - 8); + CLEAN_UP_AND_RETURN_ON_FAILURE; // Read the frame data (include the header) + + // todo: calculate crcRead + + if (0) + { + checksumFailure(); + + inputStream.seekg(pos); + + continue; + } + + checksumSuccess(); + + decode(id, payload); + + if (obsListList.size() > 2) + { + return; + } + } + inputStream.clear(); +} diff --git a/src/cpp/common/streamSbf.hpp b/src/cpp/common/streamSbf.hpp new file mode 100644 index 000000000..dfd81351c --- /dev/null +++ b/src/cpp/common/streamSbf.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "common/packetStatistics.hpp" +#include "common/sbfDecoder.hpp" +#include "common/streamObs.hpp" +#include "common/streamParser.hpp" + +struct SbfParser : Parser, SbfDecoder, PacketStatistics +{ + void parse(std::istream& inputStream); + + string parserType() { return "SbfParser"; } +}; diff --git a/src/cpp/iono/ionoSBAS.cpp b/src/cpp/iono/ionoSBAS.cpp new file mode 100644 index 000000000..070a5eb9f --- /dev/null +++ b/src/cpp/iono/ionoSBAS.cpp @@ -0,0 +1,872 @@ +#include "iono/ionoSBAS.hpp" +#include +#include "common/common.hpp" +#include "common/constants.hpp" +#include "common/trace.hpp" +#include "sbas/sbas.hpp" + +using std::map; + +#define SBAS_GIV_OUTAGE 600 +#define SBAS_IGP_OUTAGE 1200 +#define IONO_DEBUG_TRACE_LEVEL 4 + +map> latiVects = { + {0, { -55, -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}}, + {1, { -75, -65, -55, -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 65, 75}}, + {2, {-85, -75, -65, -55, -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 65, 75}}, + {3, { -75, -65, -55, -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 65, 75, 85}} +}; +map>> Iono_Bands = { + {0, + {{0, { 1, 28, -180, 3}}, + {1, { 29, 51, -175, 0}}, + {2, { 52, 78, -170, 1}}, + {3, { 79, 101, -165, 0}}, + {4, {102, 128, -160, 1}}, + {5, {129, 151, -155, 0}}, + {6, {152, 178, -150, 1}}, + {7, {179, 201, -145, 0}}}}, + {1, + {{0, { 1, 28, -140, 2}}, + {1, { 29, 51, -135, 0}}, + {2, { 52, 78, -130, 1}}, + {3, { 79, 101, -125, 0}}, + {4, {102, 128, -120, 1}}, + {5, {129, 151, -115, 0}}, + {6, {152, 178, -110, 1}}, + {7, {179, 201, -105, 0}}}}, + {2, + {{0, { 1, 27, -100, 1}}, + {1, { 28, 50, -95, 0}}, + {2, { 51, 78, -90, 3}}, + {3, { 79, 101, -85, 0}}, + {4, {102, 128, -80, 1}}, + {5, {129, 151, -75, 0}}, + {6, {152, 178, -70, 1}}, + {7, {179, 201, -65, 0}}}}, + {3, + {{0, { 1, 27, -60, 1}}, + {1, { 28, 50, -55, 0}}, + {2, { 51, 78, -50, 2}}, + {3, { 79, 101, -45, 0}}, + {4, {102, 128, -40, 1}}, + {5, {129, 151, -35, 0}}, + {6, {152, 178, -30, 1}}, + {7, {179, 201, -25, 0}}}}, + {4, + {{0, { 1, 27, -20, 1}}, + {1, { 28, 50, -15, 0}}, + {2, { 51, 77, -10, 1}}, + {3, { 78, 100, -5, 0}}, + {4, {101, 128, 0, 3}}, + {5, {129, 151, 5, 0}}, + {6, {152, 178, 10, 1}}, + {7, {179, 201, 15, 0}}}}, + {5, + {{0, { 1, 27, 20, 1}}, + {1, { 28, 50, 25, 0}}, + {2, { 51, 77, 30, 1}}, + {3, { 78, 100, 35, 0}}, + {4, {101, 128, 40, 2}}, + {5, {129, 151, 45, 0}}, + {6, {152, 178, 50, 1}}, + {7, {179, 201, 55, 0}}}}, + {6, + {{0, { 1, 27, 60, 1}}, + {1, { 28, 50, 65, 0}}, + {2, { 51, 77, 70, 1}}, + {3, { 78, 100, 75, 0}}, + {4, {101, 127, 80, 1}}, + {5, {128, 150, 85, 0}}, + {6, {151, 178, 90, 3}}, + {7, {179, 201, 95, 0}}}}, + {7, + {{0, { 1, 27, 100, 1}}, + {1, { 28, 50, 105, 0}}, + {2, { 51, 77, 110, 1}}, + {3, { 78, 100, 115, 0}}, + {4, {101, 127, 120, 1}}, + {5, {128, 150, 125, 0}}, + {6, {151, 178, 130, 2}}, + {7, {179, 201, 135, 0}}}}, + {8, + {{0, { 1, 27, 140, 1}}, + {1, { 28, 50, 145, 0}}, + {2, { 51, 77, 150, 1}}, + {3, { 78, 100, 155, 0}}, + {4, {101, 127, 160, 1}}, + {5, {128, 150, 165, 0}}, + {6, {151, 177, 170, 1}}, + {7, {178, 200, 175, 0}}}}, + {9, + {{0, { 1, 72, 60, -180, 5, 170}}, + {1, { 73, 108, 65, -180, 10, 170}}, + {2, {143, 144, 70, -180, 10, 170}}, + {3, {179, 180, 75, -180, 10, 170}}, + {4, {181, 192, 85, -180, 30, 150}}}}, + {10, + {{0, { 1, 72, -60, -180, 5, 170}}, + {1, { 73, 108, -65, -180, 10, 170}}, + {2, {143, 144, -70, -180, 10, 170}}, + {3, {179, 180, -75, -180, 10, 170}}, + {4, {181, 192, -85, -170, 30, 160}}}} +}; + +double giveTable[16] = { + 0.0084, + 0.0333, + 0.0749, + 0.1331, + 0.2079, + 0.2994, + 0.4075, + 0.5322, + 0.6735, + 0.8315, + 1.1974, + 1.8709, + 3.3260, + 20.7870, + 187.0826, + -1 +}; + +struct ionoGridPoint +{ + int IODI = -1; + GTime IGPTime = GTime::noTime(); + GTime GIVTime = GTime::noTime(); + int lat; + int lon; + double GIVD = 0; + double GIVE = 9999999; +}; + +struct GridMap +{ + map> IonoGridLati; // IGP Latitude indexed by Band and entry number + map> IonoGridLong; + bool complete = false; +}; + +map ionoGridMap; +map> IonoGridData; // IGP data indexed by latitude, longitude +bool incBand9_10 = false; + +bool addSBASIGP(Trace& trace, int IODI, int band, int ID, int entry, GTime tof, int nband) +{ + if (ID < 1) + return false; + if (band < 0 || band > 10) + return false; + auto& bandData = Iono_Bands[band]; + int subBand = -1; + for (auto& [sub, Data] : bandData) + if (Data[1] >= ID) + { + subBand = sub; + break; + } + if (subBand < 0) + return false; + + auto& blockData = bandData[subBand]; + if (band == 9 || band == 10) + { + incBand9_10 = true; + int lat = blockData[2]; + int lon = blockData[3] + blockData[4] * (ID - blockData[0]); + ionoGridMap[IODI].IonoGridLati[band][entry] = lat; + ionoGridMap[IODI].IonoGridLong[band][entry] = lon; + IonoGridData[lat][lon].IODI = IODI; + IonoGridData[lat][lon].IGPTime = tof; + if (ionoGridMap[IODI].IonoGridLati.size() == nband) + ionoGridMap[IODI].complete = true; + tracepdeex( + IONO_DEBUG_TRACE_LEVEL, + trace, + "%s IGP data for IGP[%2d][%3d]: lat=%3d; lon=%4d; ID=%3d; nband=%d\n", + tof.to_string(), + band, + entry, + lat, + lon, + ID, + nband + ); + return true; + } + + int lon = blockData[2]; + auto& lasVec = latiVects[blockData[3]]; + int lat = lasVec[ID - blockData[0]]; + ionoGridMap[IODI].IonoGridLati[band][entry] = lat; + ionoGridMap[IODI].IonoGridLong[band][entry] = lon; + IonoGridData[lat][lon].IGPTime = tof; + IonoGridData[lat][lon].IODI = IODI; + if (ionoGridMap[IODI].IonoGridLati.size() == nband) + { + ionoGridMap[IODI].complete = true; + tracepdeex(IONO_DEBUG_TRACE_LEVEL, trace, "IGP map (%d) is comprete\n", IODI); + } + + tracepdeex( + IONO_DEBUG_TRACE_LEVEL, + trace, + "%s IGP data for IGP[%1d][%3d]: lat=%3d; lon=%4d; ID=%3d; nband=%d\n", + tof.to_string(), + band, + entry, + lat, + lon, + ID, + nband + ); + return true; +} + +bool writeIonoData(Trace& trace, int IODI, int band, int entry, GTime tof, int GIVDI, int GIVEI) +{ + if (ionoGridMap.find(IODI) == ionoGridMap.end()) + return false; + + if (ionoGridMap[IODI].IonoGridLati.find(band) == ionoGridMap[IODI].IonoGridLati.end()) + return false; + + if (ionoGridMap[IODI].IonoGridLati[band].find(entry) == + ionoGridMap[IODI].IonoGridLati[band].end()) + return false; + + int lat = ionoGridMap[IODI].IonoGridLati[band][entry]; + int lon = ionoGridMap[IODI].IonoGridLong[band][entry]; + + IonoGridData[lat][lon].IODI = IODI; + IonoGridData[lat][lon].GIVTime = tof; + IonoGridData[lat][lon].lat = lat; + IonoGridData[lat][lon].lon = lon; + IonoGridData[lat][lon].GIVD = GIVDI * 0.125; + if (GIVEI == 15) + IonoGridData[lat][lon].GIVE = -1; + else + IonoGridData[lat][lon].GIVE = sqrt(giveTable[GIVEI]); + + tracepdeex( + IONO_DEBUG_TRACE_LEVEL, + trace, + "%s VTEC data for IGP[%2d][%3d]: lat=%3d; lon=%4d; GIVD=%6.3f; GIVE=%7.5f;\n", + tof.to_string(), + band, + entry, + lat, + lon, + IonoGridData[lat][lon].GIVD, + IonoGridData[lat][lon].GIVE + ); + return true; +} + +double ionppp(const VectorPos& pos, const AzEl& azel, double& ippLat, double& ippLon) +{ + double rp = 0.94797965 * cos(azel.el); + double ap = PI / 2 - azel.el - asin(rp); + double sinap = sin(ap); + double tanap = tan(ap); + double cosaz = cos(azel.az); + double sinaz = sin(azel.az); + ippLat = asin(sin(pos.lat()) * cos(ap) + cos(pos.lat()) * sinap * cosaz); + + if ((pos.latDeg() > +70 && +tanap * cosaz > tan(PI / 2 - pos.lat())) || + (pos.latDeg() < -70 && -tanap * cosaz > tan(PI / 2 + pos.lat()))) + { + ippLon = pos.lon() + PI - asin(sinap * sinaz / cos(ippLat)); + } + else + { + ippLon = pos.lon() + asin(sinap * sinaz / cos(ippLat)); + } + + if ((ippLon) > 180 * D2R) + ippLon -= 360 * D2R; + + return 1 / sqrt(1 - SQR(rp)); +} + +int selectIGP85(GTime t, double ippLat, vector& selIGPs) +{ + selIGPs.clear(); + int lat = -85; + int lon[4] = {-140, -50, 40, 130}; + if (ippLat > 0) + { + lat = 85; + lon[0] = -180; + lon[1] = -90; + lon[2] = 0; + lon[3] = 90; + } + + if (IonoGridData.find(lat) == IonoGridData.end()) + return 0; + int iodi = -1; + for (int i = 0; i < 4; i++) + { + if (IonoGridData[lat].find(lon[i]) == IonoGridData[lat].end()) + return 0; + auto ionData = IonoGridData[lat][lon[i]]; + if (fabs((t - ionData.IGPTime).to_double()) > SBAS_IGP_OUTAGE) + return 0; + if (!ionoGridMap[ionData.IODI].complete) + return 0; + if (iodi < 0) + iodi = ionData.IODI; + if (ionData.IODI != iodi) + return 0; + selIGPs.push_back(ionData); + } + + return selIGPs.size() == 4 ? 4 : 0; +} + +int selectIGP75(GTime t, double ippLat, double ippLon, vector& selIGPs) +{ + selIGPs.clear(); + int lat[4] = {-75, -75, -85, -85}; + int spa = 90; + int off = 40; + if (incBand9_10) + spa = 30; + if (ippLat > 0) + { + off = 0; + for (int i = 0; i < 4; i++) + lat[i] *= -1; + } + + double lonDeg = ippLon * R2D; + int lon[4]; + lon[0] = 10 * floor(lonDeg / 10); + lon[1] = lon[0] + 10; + lon[2] = spa * floor((lonDeg - off) / spa) + off; + if (lon[2] < -180) + lon[2] += 360; + lon[3] = lon[2] + spa; + if (lon[3] > 180) + lon[3] -= 360; + int iodi = -1; + vector candIGPs; + for (int i = 0; i < 4; i++) + { + if (IonoGridData.find(lat[i]) == IonoGridData.end()) + return 0; + if (IonoGridData[lat[i]].find(lon[i]) == IonoGridData[lat[i]].end()) + return 0; + auto ionData = IonoGridData[lat[i]][lon[i]]; + if (ionData.GIVE < 0) + return 0; + if (fabs((t - ionData.IGPTime).to_double()) > SBAS_IGP_OUTAGE) + return 0; + if (!ionoGridMap[ionData.IODI].complete) + return 0; + if (iodi < 0) + iodi = ionData.IODI; + if (ionData.IODI != iodi) + return 0; + candIGPs[i] = ionData; + } + + selIGPs.push_back(candIGPs[0]); + selIGPs.push_back(candIGPs[1]); + + ionoGridPoint intrpIGP = candIGPs[2]; + double dLon = lon[0] - lon[2]; + if (dLon < 0) + dLon += 360; + dLon /= spa; + intrpIGP.lon = lon[0]; + intrpIGP.GIVD = dLon * candIGPs[3].GIVD + (1 - dLon) * candIGPs[2].GIVD; + intrpIGP.GIVE = dLon * candIGPs[3].GIVE + (1 - dLon) * candIGPs[2].GIVE; + selIGPs.push_back(intrpIGP); + + intrpIGP = candIGPs[3]; + dLon = lon[1] - lon[3]; + if (dLon < 0) + dLon += 360; + dLon /= spa; + intrpIGP.lon = lon[1]; + intrpIGP.GIVD = dLon * candIGPs[2].GIVD + (1 - dLon) * candIGPs[3].GIVD; + intrpIGP.GIVE = dLon * candIGPs[2].GIVE + (1 - dLon) * candIGPs[3].GIVE; + selIGPs.push_back(intrpIGP); + + return 4; +} + +bool checkTriangular(double ippLat, double ippLon, vector selIGPs) +{ + double lat0 = selIGPs[0].lat; + double dlat; + if (selIGPs[0].lat == selIGPs[1].lat) + dlat = selIGPs[2].lat - lat0; + else if (selIGPs[0].lat == selIGPs[2].lat) + dlat = selIGPs[1].lat - lat0; + else + { + lat0 = selIGPs[1].lat; + dlat = selIGPs[0].lat - lat0; + } + + double lon0 = selIGPs[0].lon; + double dlon; + if (selIGPs[0].lon == selIGPs[1].lon) + dlon = selIGPs[2].lon - lon0; + else if (selIGPs[0].lon == selIGPs[2].lon) + dlon = selIGPs[1].lon - lon0; + else + { + lon0 = selIGPs[1].lon; + dlon = selIGPs[0].lon - lon0; + } + + if (dlon > 180) + dlon -= 360; + if (dlon < -180) + dlon += 360; + + double dlati = ippLat * R2D - lat0; + double dloni = ippLon * R2D - lon0; + + if (dloni > 180) + dloni -= 360; + if (dloni < -180) + dloni += 360; + return (dlati / dlat + dloni / dlon) <= 1; +} + +int selectIGP60(GTime t, double ippLat, double ippLon, vector& selIGPs) +{ + selIGPs.clear(); + int lat0 = 5 * floor(ippLat * R2D / 5); + int lon0 = 10 * floor(ippLon * R2D / 10); + + int iodi = -1; + for (int i = 0; i < 2; i++) + { + int lat = lat0 + 5 * i; + if (IonoGridData.find(lat) == IonoGridData.end()) + continue; + for (int j = 0; j < 2; j++) + { + int lon = lon0 + 10 * j; + if (lon >= 180) + lon -= 360; + if (IonoGridData[lat].find(lon) == IonoGridData[lat].end()) + continue; + + auto ionData = IonoGridData[lat][lon]; + if (fabs((t - ionData.IGPTime).to_double()) > SBAS_IGP_OUTAGE) + continue; + if (!ionoGridMap[ionData.IODI].complete) + continue; + if (iodi < 0) + iodi = ionData.IODI; + if (ionData.IODI != iodi) + continue; + selIGPs.push_back(ionData); + } + } + + if (selIGPs.empty()) + return 0; + + if (selIGPs.size() == 4) + return 4; + if (selIGPs.size() == 3 && checkTriangular(ippLat, ippLon, selIGPs)) + return 3; + + vector selIGPcopy; + for (auto& igp : selIGPs) + selIGPcopy.push_back(igp); + + for (auto& igp : selIGPcopy) + { + int lat1 = igp.lat == lat0 ? lat0 : lat0 - 5; + int lon1 = igp.lon == lon0 ? lon0 : lon0 - 10; + if (lon1 < -180) + lon1 += 360; + iodi = -1; + vector candIGPs; + for (int i = 0; i < 2; i++) + { + int lat = lat1 + 10 * i; + if (IonoGridData.find(lat) == IonoGridData.end()) + continue; + for (int j = 0; j < 2; j++) + { + int lon = lon1 + 10 * j; + if (lon >= 180) + lon -= 360; + if (IonoGridData[lat].find(lon) == IonoGridData[lat].end()) + continue; + + auto ionData = IonoGridData[lat][lon]; + if (fabs((t - ionData.IGPTime).to_double()) > SBAS_IGP_OUTAGE) + continue; + if (!ionoGridMap[ionData.IODI].complete) + continue; + if (iodi < 0) + iodi = ionData.IODI; + if (ionData.IODI != iodi) + continue; + candIGPs.push_back(ionData); + } + } + + if (candIGPs.size() == 4) + { + selIGPs.clear(); + for (auto& cand : candIGPs) + selIGPs.push_back(cand); + return 4; + } + + if (candIGPs.size() == 3 && checkTriangular(ippLat, ippLon, candIGPs)) + { + selIGPs.clear(); + for (auto& cand : candIGPs) + selIGPs.push_back(cand); + return 3; + } + } + + return selIGPs.size(); +} + +int selectIGP00(GTime t, double ippLat, double ippLon, vector& selIGPs) +{ + selIGPs.clear(); + int lat0 = 5 * floor(ippLat * R2D / 5); + int lon0 = 5 * floor(ippLon * R2D / 5); + + int iodi = -1; + for (int i = 0; i < 2; i++) + { + int lat = lat0 + 5 * i; + if (IonoGridData.find(lat) == IonoGridData.end()) + continue; + for (int j = 0; j < 2; j++) + { + int lon = lon0 + 5 * j; + if (lon >= 180) + lon -= 360; + if (IonoGridData[lat].find(lon) == IonoGridData[lat].end()) + continue; + + auto ionData = IonoGridData[lat][lon]; + if (fabs((t - ionData.IGPTime).to_double()) > SBAS_IGP_OUTAGE) + continue; + if (!ionoGridMap[ionData.IODI].complete) + continue; + if (iodi < 0) + iodi = ionData.IODI; + if (ionData.IODI != iodi) + continue; + selIGPs.push_back(ionData); + } + } + + if (selIGPs.empty()) + return 0; + + if (selIGPs.size() == 4) + return 4; + + if (selIGPs.size() == 3 && checkTriangular(ippLat, ippLon, selIGPs)) + return 3; + + vector selIGPcopy; + for (auto& igp : selIGPs) + selIGPcopy.push_back(igp); + + for (auto& igp : selIGPcopy) + { + int lat1 = igp.lat == lat0 ? lat0 : lat0 - 5; + int lon1 = igp.lon == lon0 ? lon0 : lon0 - 5; + if (lon1 < -180) + lon1 += 360; + iodi = -1; + vector candIGPs; + for (int i = 0; i < 2; i++) + { + int lat = lat1 + 10 * i; + if (IonoGridData.find(lat) == IonoGridData.end()) + continue; + for (int j = 0; j < 2; j++) + { + int lon = lon1 + 10 * j; + if (lon >= 180) + lon -= 360; + if (IonoGridData[lat].find(lon) == IonoGridData[lat].end()) + continue; + + auto ionData = IonoGridData[lat][lon]; + if (fabs((t - ionData.IGPTime).to_double()) > SBAS_IGP_OUTAGE) + continue; + if (!ionoGridMap[ionData.IODI].complete) + continue; + if (iodi < 0) + iodi = ionData.IODI; + if (ionData.IODI != iodi) + continue; + candIGPs.push_back(ionData); + } + } + + if (candIGPs.size() == 4) + { + selIGPs.clear(); + for (auto& cand : candIGPs) + selIGPs.push_back(cand); + return 4; + } + + if (candIGPs.size() == 3 && checkTriangular(ippLat, ippLon, candIGPs)) + { + selIGPs.clear(); + for (auto& cand : candIGPs) + selIGPs.push_back(cand); + return 3; + } + } + + return selIGPs.size(); +} + +int selectIGPs(GTime t, double ippLat, double ippLon, vector& selIGPs) +{ + if (abs(ippLat) > 85 * D2R) + return selectIGP85(t, ippLat, selIGPs); + else if (abs(ippLat) > 75 * D2R) + return selectIGP75(t, ippLat, ippLon, selIGPs); + else if (abs(ippLat) > 60 * D2R) + return selectIGP60(t, ippLat, ippLon, selIGPs); + + return selectIGP00(t, ippLat, ippLon, selIGPs); +} + +double +iono3IGP(GTime t, double ippLat, double ippLon, vector selIGPs, double& ionVar) +{ + ionVar = -1; + if (!checkTriangular(ippLat, ippLon, selIGPs)) + return 0.0; + if (fabs(ippLat * R2D) > 75) + return 0.0; + + int remap[3]; + if (selIGPs[0].lat == selIGPs[1].lat && selIGPs[0].lon == selIGPs[2].lon) + { + remap[0] = 1; + remap[1] = 0; + remap[2] = 2; + } + if (selIGPs[0].lat == selIGPs[2].lat && selIGPs[0].lon == selIGPs[1].lon) + { + remap[0] = 2; + remap[1] = 0; + remap[2] = 1; + } + if (selIGPs[1].lat == selIGPs[0].lat && selIGPs[1].lon == selIGPs[2].lon) + { + remap[0] = 0; + remap[1] = 1; + remap[2] = 2; + } + if (selIGPs[1].lat == selIGPs[2].lat && selIGPs[1].lon == selIGPs[0].lon) + { + remap[0] = 2; + remap[1] = 1; + remap[2] = 0; + } + if (selIGPs[2].lat == selIGPs[0].lat && selIGPs[2].lon == selIGPs[1].lon) + { + remap[0] = 0; + remap[1] = 2; + remap[2] = 1; + } + if (selIGPs[2].lat == selIGPs[1].lat && selIGPs[2].lon == selIGPs[0].lon) + { + remap[0] = 1; + remap[1] = 2; + remap[2] = 0; + } + + double latRng = selIGPs[remap[1]].lat - selIGPs[remap[2]].lat; + double dLat = fabs((ippLat * R2D - selIGPs[1].lat) / latRng); + + double lonRng = fabs(selIGPs[remap[0]].lon - selIGPs[remap[1]].lon); + if (lonRng > 180) + lonRng = 360 - lonRng; + double dLon = fabs((ippLon * R2D - selIGPs[1].lon)); + if (dLon > 180) + dLon = 360 - dLon; + dLon /= lonRng; + + double Wi[3]; + Wi[remap[0]] = dLat; + Wi[remap[1]] = 1 - dLat - dLon; + Wi[remap[2]] = dLon; + + double iono = 0; + ionVar = 0; + for (int i = 0; i < 3; i++) + { + double varIono = estimateIonoVar(t, selIGPs[i].GIVTime, selIGPs[i].GIVE); + if (varIono < 0) + { + ionVar = -1; + return 0.0; + } + + iono += Wi[i] * selIGPs[i].GIVD; + ionVar += Wi[i] * varIono; + } + + return iono; +} + +double +iono4IGP(GTime t, double ippLat, double ippLon, vector selIGPs, double& ionVar) +{ + ionVar = -1; + if (fabs(ippLat * R2D) > 85) + { + double dlat = (fabs(ippLat * R2D) - 85) / 10; + double dlon = 0; + map remap; + for (int i = 0; i < 4; i++) + { + double dif = ippLon * R2D - selIGPs[i].lon; + if (dif < -180) + dif += 360; + if (dif >= 90) + remap[i] = 2; + else if (dif >= 0) + { + remap[i] = 3; + dlon = dif * (1 - 2 * dlat) / 90 + dlat; + } + else if (dif >= -90) + remap[i] = 4; + else + remap[i] = 1; + } + double iono = 0; + ionVar = 0; + for (int i = 0; i < 4; i++) + { + double Wi = 0; + double varIono = estimateIonoVar(t, selIGPs[i].GIVTime, selIGPs[i].GIVE); + if (varIono < 0) + { + ionVar = -1; + return 0.0; + } + switch (remap[i]) + { + case 1: + Wi = dlat * dlon; + break; + case 2: + Wi = (1 - dlat) * dlon; + break; + case 3: + Wi = (1 - dlat) * (1 - dlon); + break; + case 4: + Wi = dlat * (1 - dlon); + break; + } + iono += Wi * selIGPs[i].GIVD; + + ionVar += Wi * varIono; + } + + return iono; + } + + double latRng = fabs(selIGPs[3].lat - selIGPs[0].lat); + if (latRng == 0) + latRng = fabs(selIGPs[1].lat - selIGPs[0].lat); + + double lonRng = fabs(selIGPs[3].lon - selIGPs[0].lon); + if (lonRng == 0) + lonRng = fabs(selIGPs[1].lon - selIGPs[0].lon); + + if (lonRng > 180) + lonRng = 360 - lonRng; + + double iono = 0; + ionVar = 0; + for (int i = 0; i < 4; i++) + { + double varIono = estimateIonoVar(t, selIGPs[i].GIVTime, selIGPs[i].GIVE); + if (varIono < 0) + { + selIGPs.erase(selIGPs.begin() + i); + ionVar = -1; + return iono3IGP(t, ippLat, ippLon, selIGPs, ionVar); + } + double dlat = fabs(ippLat * R2D - selIGPs[i].lat) / latRng; + double dlon = fabs(ippLon * R2D - selIGPs[i].lon); + if (dlon > 180) + dlon = 360 - dlon; + dlon /= lonRng; + iono += dlat * dlon * selIGPs[i].GIVD; + ionVar += dlat * dlon * varIono; + } + + return iono; +} + +double ionmodelSBAS(GTime t, const VectorPos& pos, const AzEl& azel, double& ionVar) +{ + double ippLat; + double ippLon; + double mapf = ionppp(pos, azel, ippLat, ippLon); + + ionVar = -1; + vector selIGPs; + int nIGP = selectIGPs(t, ippLat, ippLon, selIGPs); + + if (nIGP < 3) + return 0.0; + + vector goodIGPs; + for (auto ionData : selIGPs) + { + if (fabs((t - ionData.GIVTime).to_double()) > SBAS_GIV_OUTAGE) + continue; + if (ionData.GIVE < 0) + continue; + goodIGPs.push_back(ionData); + } + + double iono = 0; + switch (goodIGPs.size()) + { + case 4: + iono = iono4IGP(t, ippLat, ippLon, goodIGPs, ionVar); + break; + case 3: + iono = iono3IGP(t, ippLat, ippLon, goodIGPs, ionVar); + break; + default: + return 0.0; + } + + if (ionVar < 0) + return 0.0; + + ionVar *= mapf * mapf; + return iono * mapf; +} \ No newline at end of file diff --git a/src/cpp/iono/ionoSBAS.hpp b/src/cpp/iono/ionoSBAS.hpp new file mode 100644 index 000000000..aa19e2917 --- /dev/null +++ b/src/cpp/iono/ionoSBAS.hpp @@ -0,0 +1,9 @@ +#pragma once +#include "common/gTime.hpp" +#include "common/receiver.hpp" + +bool addSBASIGP(Trace& trace, int IODI, int Band, int ID, int entry, GTime tof, int nband); + +bool writeIonoData(Trace& trace, int IODI, int band, int entry, GTime tof, int GIVDI, int GIVEI); + +double ionmodelSBAS(GTime t, const VectorPos& pos, const AzEl& azel, double& ionVar); \ No newline at end of file diff --git a/src/cpp/pea/inputs.cpp b/src/cpp/pea/inputs.cpp index ec4f48d47..49940c29f 100644 --- a/src/cpp/pea/inputs.cpp +++ b/src/cpp/pea/inputs.cpp @@ -15,6 +15,7 @@ #include "common/streamNtrip.hpp" #include "common/streamRinex.hpp" #include "common/streamRtcm.hpp" +#include "common/streamSbf.hpp" #include "common/streamSerial.hpp" #include "common/streamSlr.hpp" #include "common/streamSp3.hpp" @@ -232,6 +233,11 @@ void addReceiverData( parser_ptr = make_unique(); static_cast(parser_ptr.get())->recId = id; } + else if (inputFormat == "SBF") + { + parser_ptr = make_unique(); + static_cast(parser_ptr.get())->recId = id; + } else if (inputFormat == "CUSTOM") { parser_ptr = make_unique(); @@ -740,6 +746,10 @@ void reloadInputFiles() { addReceiverData(id, ubxinputs, "UBX", "OBS"); } + for (auto& [id, sbfinputs] : acsConfig.sbf_inputs) + { + addReceiverData(id, sbfinputs, "SBF", "OBS"); + } for (auto& [id, custominputs] : acsConfig.custom_inputs) { addReceiverData(id, custominputs, "CUSTOM", "OBS"); diff --git a/src/cpp/pea/main.cpp b/src/cpp/pea/main.cpp index 7cd4c5f90..e8f750848 100644 --- a/src/cpp/pea/main.cpp +++ b/src/cpp/pea/main.cpp @@ -376,6 +376,8 @@ void mainOncePerEpoch(Network& pppNet, Network& ionNet, ReceiverMap& receiverMap frameSwapper.setCache(dt); } + loadSBASdata(pppTrace, time, nav); + // try to get svns & block types of all used satellites for (auto& [Sat, satNav] : nav.satNavMap) { diff --git a/src/cpp/pea/outputs.cpp b/src/cpp/pea/outputs.cpp index f1da1e371..26da9c84b 100644 --- a/src/cpp/pea/outputs.cpp +++ b/src/cpp/pea/outputs.cpp @@ -27,6 +27,7 @@ #include "common/streamCustom.hpp" #include "common/streamParser.hpp" #include "common/streamRtcm.hpp" +#include "common/streamSbf.hpp" #include "common/streamUbx.hpp" #include "common/summary.hpp" #include "iono/ionoModel.hpp" @@ -668,6 +669,26 @@ void createTracefiles(ReceiverMap& receiverMap, Network& pppNet, Network& ionNet { /* Ignore expected bad casts for different types */ } + for (auto& [id, streamParser_ptr] : streamParserMultimap) + try + { + auto& sbfParser = dynamic_cast(streamParser_ptr->parser); + + if (acsConfig.record_raw_sbf) + { + createNewTraceFile( + id, + streamParser_ptr->stream.sourceString, + logptime, + acsConfig.raw_sbf_filename, + sbfParser.raw_sbf_filename + ); + } + } + catch (std::bad_cast& e) + { /* Ignore expected bad casts for different types */ + } + for (auto& [id, streamParser_ptr] : streamParserMultimap) try { diff --git a/src/cpp/pea/pea_snx.cpp b/src/cpp/pea/pea_snx.cpp index 9e0b09cc9..7e041919e 100644 --- a/src/cpp/pea/pea_snx.cpp +++ b/src/cpp/pea/pea_snx.cpp @@ -30,6 +30,10 @@ void sinexPostProcessing(GTime time, map& receiverMap, KFState { sinexAddFiles(acsConfig.analysis_agency, time, ubxinput, "UBX"); } + for (auto& [id, sbfinput] : acsConfig.sbf_inputs) + { + sinexAddFiles(acsConfig.analysis_agency, time, sbfinput, "SBF"); + } for (auto& [id, rnxinput] : acsConfig.rnx_inputs) { sinexAddFiles(acsConfig.analysis_agency, time, rnxinput, "RINEX v3.x"); diff --git a/src/cpp/pea/spp.cpp b/src/cpp/pea/spp.cpp index e757e293a..366dfd592 100644 --- a/src/cpp/pea/spp.cpp +++ b/src/cpp/pea/spp.cpp @@ -27,6 +27,77 @@ using std::ostringstream; Architecture SPP__() {} +struct smoothControl +{ + GTime lastUpdate; + int numMea = 0; + double ambEst = 0; + double ambVar = -1; + double estSmooth = 0; + double varSmooth = -1; +}; +map> smoothedMeasMap; + +/** Carrier-smoothing of code pseudoranges + * Ref: https://gssc.esa.int/navipedia/index.php/Carrier-smoothing_of_code_pseudoranges (eq (2)) + */ +bool smoothedPsudo( + Trace& trace, + GObs& obs, ///< Observation to calculate pseudorange for + double& meaP, + double meaL, + double& varP, + double varL, + bool update, + bool LLI +) +{ + if (varP < 0 || varL < 0) + return false; + + auto& smCtrl = smoothedMeasMap[obs.Sat][obs.mount]; + if (update) + { + bool slip = LLI; + if ((obs.time - smCtrl.lastUpdate).to_double() > acsConfig.sppOpts.smooth_outage) + slip = true; + if (smCtrl.ambVar < 0) + slip = true; + if (smCtrl.numMea <= 0) + slip = true; + + double ambMea = meaP - meaL; + if (fabs(ambMea - smCtrl.ambEst) > 4 * sqrt(smCtrl.ambVar + varP + varL)) + slip = true; // Try replacing this with a outputs from preprocessor + + smCtrl.lastUpdate = obs.time; + if (slip) + { + smCtrl.numMea = 0; + smCtrl.ambEst = 0; + smCtrl.ambVar = 0; + } + + smCtrl.numMea++; + if (smCtrl.numMea > acsConfig.sppOpts.smooth_window) + smCtrl.numMea = acsConfig.sppOpts.smooth_window; + double fact = 1.0 / smCtrl.numMea; + + smCtrl.ambEst += fact * (ambMea - smCtrl.ambEst); + smCtrl.ambVar = SQR(fact) * (varP + varL) + SQR(1 - fact) * smCtrl.ambVar; + + smCtrl.estSmooth = smCtrl.ambEst + meaL; + smCtrl.varSmooth = (1 - 2 * fact) * varL + smCtrl.ambVar; + } + + if (acsConfig.sppOpts.use_smooth_only && (smCtrl.numMea < acsConfig.sppOpts.smooth_window)) + return false; + + varP = smCtrl.varSmooth; + meaP = smCtrl.estSmooth; + return true; +} + /** Calculate pseudorange and code bias correction */ bool prange( @@ -143,7 +214,12 @@ bool prange( bias = bias_A; biasVar = varBias_A; - if (ionoMode == E_IonoMode::IONO_FREE_LINEAR_COMBO) + bool dualFreq = (ionoMode == E_IonoMode::IONO_FREE_LINEAR_COMBO) || + (ionoMode == E_IonoMode::SBAS && acsConfig.sbsInOpts.freq == 5); + double c1 = 1; + double c2 = 0; + + if (dualFreq) { double P_B = 0; double var_B = 0; @@ -153,6 +229,9 @@ bool prange( if (P_B == 0 || ft_B == NONE) { + if (ionoMode == E_IonoMode::SBAS) + return false; + BOOST_LOG_TRIVIAL(warning) << "Code measurement not available on secondary frequency for " << obs.Sat.id() << " at " << obs.mount << ", falling back to single-frequency"; @@ -162,52 +241,43 @@ bool prange( else { // Iono-free combination - double c1 = SQR(lam[ft_B]) / (SQR(lam[ft_B]) - SQR(lam[ft_A])); - double c2 = 1 - c1; - - range = c1 * P_A + c2 * P_B; - bias = c1 * bias_A + c2 * bias_B; + c1 = SQR(lam[ft_B]) / (SQR(lam[ft_B]) - SQR(lam[ft_A])); + c2 = 1 - c1; + range = c1 * P_A + c2 * P_B; + bias = c1 * bias_A + c2 * bias_B; measVar = SQR(c1) * var_A + SQR(c2) * var_B; - biasVar = abs(SQR(c1) * varBias_A - SQR(c2) * varBias_B); // Eugene: bias_A and - // bias_B are expected to be fully correlated? + biasVar = SQR(c1) * varBias_A + SQR(c2) * varBias_B; } } - if (acsConfig.sbsInOpts.smth_win > 0) + if (acsConfig.sppOpts.smooth_window > 0) { - double LC = obs.sigs[ft_A].L * lam[ft_A]; - double varL = obs.sigs[ft_A].phasVar; - if (LC == 0) + double meaL = obs.sigs[ft_A].L * lam[ft_A]; + if (meaL == 0 && acsConfig.sppOpts.use_smooth_only) return false; + double varL = obs.sigs[ft_A].phasVar; + bool lli = obs.sigs[ft_A].LLI; - if (ionoMode == E_IonoMode::IONO_FREE_LINEAR_COMBO) + if (dualFreq) { - double L2 = obs.sigs[ft_B].L * lam[ft_B]; - if (L2 == 0) - return false; - - double c1 = SQR(lam[ft_B]) / (SQR(lam[ft_B]) - SQR(lam[ft_A])); - double c2 = 1 - c1; - LC = c1 * LC + c2 * L2; - varL = c1 * c1 * varL + c2 * c2 * obs.sigs[ft_B].phasVar; + double meaL_B = obs.sigs[ft_B].L * lam[ft_B]; + if (meaL_B == 0) + { + if (ionoMode == E_IonoMode::SBAS) + return false; + } + else + { + double varL_B = obs.sigs[ft_B].phasVar; + meaL = c1 * meaL + c2 * meaL_B; + varL = SQR(c1) * varL + SQR(c2) * varL_B; + lli = obs.sigs[ft_A].LLI || obs.sigs[ft_B].LLI; + } } - range = sbasSmoothedPsudo( - trace, - obs.time, - obs.Sat, - obs.mount, - range, - LC, - measVar, - varL, - measVar, - smooth - ); - biasVar = 0; - - if (measVar < 0) + if (!smoothedPsudo(trace, obs, range, meaL, measVar, varL, smooth, lli) && + acsConfig.sppOpts.use_smooth_only) return false; } @@ -599,6 +669,12 @@ E_Solution estpos( tracepdeex(2, trace, ", el=%.2f", elevation); + if (acsConfig.sbsOpts.use_sbas_rec_var) + { + varMeas = SQR(0.36) + SQR(0.13 + 0.53 * exp(-elevation * R2D / 10)); + varBias = 0; + } + // Sat clock if (obs.ephClkValid == false) { @@ -611,6 +687,22 @@ E_Solution estpos( double dtSat = -obs.satClk * CLIGHT; double varSatClk = obs.satClkVar * SQR(CLIGHT); + auto& satOpts = acsConfig.getSatOpts(obs.Sat); + if (satOpts.posModel.sources[0] == E_Source::SBAS) + { + double sbasVar = + checkSBASVar(trace, obs.time, obs.Sat, rRec, rSat, obs.satNav_ptr->currentSBAS); + + if (sbasVar <= 0) + { + tracepdeex(2, trace, " ... Sat clk fail (sbas)"); + continue; + } + bias = 0; + varBias = 0; + varSatPos = 0; + varSatClk = sbasVar; + } tracepdeex(2, trace, ", satClk=%.3f", dtSat); @@ -642,10 +734,6 @@ E_Solution estpos( dIono *= ionC; varIono *= SQR(ionC); } - - if (acsConfig.sbsInOpts.dfmc_uire) - varIono = SQR(0.018 + 40 / (261 + SQR(satStat.el * R2D))); - tracepdeex(2, trace, ", dIono=%.5f", dIono); // Tropospheric correction diff --git a/src/cpp/sbas/decodeL1.cpp b/src/cpp/sbas/decodeL1.cpp index 146e331a8..725712430 100644 --- a/src/cpp/sbas/decodeL1.cpp +++ b/src/cpp/sbas/decodeL1.cpp @@ -1,37 +1,797 @@ #include "common/acsConfig.hpp" #include "common/navigation.hpp" +#include "common/rtcmDecoder.hpp" +#include "iono/ionoSBAS.hpp" +#include "orbprop/coordinates.hpp" #include "sbas/sbas.hpp" +#define SBAS_DEBUG_TRACE_LEVEL 2 +#define SBAS_DEGR_OUTAGE 360 + +map> + l1SBASSatMasks; // Satellite mask updated by MT1, l1SBASSatMasks[IODP][index] = SatSys; +int lastIODP = -1; + +// Fast degradation +int degrFCTlat = 0; +map degrL1Ind; +GTime fstDegrUpdate; + +double sig2UDREI[16] = { + 0.052, + 0.0924, + 0.1444, + 0.283, + 0.4678, + 0.8315, + 1.2992, + 1.8709, + 2.5465, + 3.326, + 5.1968, + 20.7870, + 230.9661, + 2078.695, + -1, + -2 +}; +double sig_UDREI[16] = + {0.75, 1.0, 1.25, 1.75, 2.25, 3.0, 3.75, 4.5, 5.25, 6.0, 7.5, 15.0, 50, 150, -1, -2}; +double degrL1Err[16] = {0, 5, 9, 12, 15, 20, 30, 45, 60, 90, 150, 210, 270, 330, 460, 580}; +double degrL1Out[16] = {120, 120, 102, 90, 90, 78, 66, 54, 42, 30, 30, 18, 18, 18, 12, 12}; + +double sbasGEOUra[16] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; + +double dUDRETable[16] = {1, 1.1, 1.25, 1.5, 2, 3, 4, 5, 6, 8, 10, 20, 30, 40, 50, 100}; +struct SBASRegn +{ + double lat1; + double lat2; + double lon1; + double lon2; + bool triangular = false; + double in_Factor; + double outFactor; + GTime time; +}; +struct SBASRegnMap +{ + map> regions; + map mess; + int totmess = 8; + GTime tUpdate; +}; +map regnMaps; + +struct SBASDegrL1 +{ + GTime tUpdate; + double Brrc; + double Cltc_lsb; + double Cltc_v1; + double Iltc_v1; + double Cltc_v0; + double Iltc_v0; + double Cgeo_lsb; + double Cgeo_v; + double Igeo_v; + double Cer; + double Ciono_step; + double Ciono_ramp; + double Iiono = -1; + bool RSSudre; + bool RSSiono; + double Ccovar; + bool velCode = 0; +}; +SBASDegrL1 degrL1Slow; + +SatSys l1SatIndex(int sati) +{ + SatSys sat; + sat.sys = E_Sys::NONE; + sat.prn = 0; + if (sati > 0 && sati < 38) + { + sat.sys = E_Sys::GPS; + sat.prn = sati; + } + else if (sati > 37 && sati < 62) + { + sat.sys = E_Sys::GLO; + sat.prn = sati - 37; + } + else if (sati > 119 && sati < 159) + { + sat.sys = E_Sys::SBS; + sat.prn = sati - 100; + } + return sat; +} + +void decodeL1SBASMask(Trace& trace, unsigned char* data) +{ + int iodp = getbitu(data, 224, 2); + tracepdeex(SBAS_DEBUG_TRACE_LEVEL, trace, "L1 mask IODP: %1d", iodp); + + if (l1SBASSatMasks.find(iodp) != l1SBASSatMasks.end()) + l1SBASSatMasks[iodp].clear(); + + int i = 0; + for (int ind = 1; ind <= 214; ind++) + if (getbitu(data, ind + 13, 1)) + { + SatSys sat = l1SatIndex(ind); + if (!sat) + continue; + l1SBASSatMasks[iodp][i++] = sat; + + tracepdeex(SBAS_DEBUG_TRACE_LEVEL, trace, ", %s", sat.id().c_str()); + } + tracepdeex(SBAS_DEBUG_TRACE_LEVEL, trace, "\n"); + lastIODP = iodp; +} + +void decodeL1FastCorr( + Trace& trace, + GTime frameTime, + Navigation& nav, + unsigned char* data, + int start +) +{ + int iodf = getbitu(data, 14, 2); + int iodp = getbitu(data, 16, 2); + + if (l1SBASSatMasks.find(iodp) == l1SBASSatMasks.end()) + return; + + for (int i = 0; i < 13; i++) + { + int slot = i + start; + if (l1SBASSatMasks[iodp].find(slot) == l1SBASSatMasks[iodp].end()) + continue; + SatSys sat = l1SBASSatMasks[iodp][slot]; + if (degrL1Ind.find(sat) == degrL1Ind.end()) + continue; + + int iFast = 18 + 12 * i; + double dClk = getbits(data, iFast, 12) * 0.125; + double degrIfc = degrL1Out[degrL1Ind[sat]]; + double ddClk = 0; + int dIODF = -1; + + auto& sbs = nav.satNavMap[sat].currentSBAS; + if (!sbs.fastUpdt[iodp].empty()) + { + auto it = sbs.fastUpdt[iodp].begin(); + int prevIODF = it->second; + if (sbs.fastCorr.find(prevIODF) != sbs.fastCorr.end()) + { + double dt = (frameTime - sbs.fastCorr[prevIODF].tFast).to_double(); + if (degrL1Ind[sat] != 0 && dt < degrIfc) + { + ddClk = (dClk - sbs.fastCorr[prevIODF].dClk) / dt; + dIODF = (iodf - prevIODF) % 3; + if (iodf == 3) + dIODF = 3; + } + } + } + + int iUdre = 174 + 4 * i; + int UDREI = getbitu(data, iUdre, 4); + if (UDREI > 13) + { + sbs.fastCorr.clear(); + sbs.fastUpdt[iodp].clear(); + ddClk = 0.0; + dIODF = -1; + } + + sbs.fastUpdt[iodp][frameTime] = iodf; + sbs.fastCorr[iodf].tFast = frameTime; + sbs.fastCorr[iodf].dClk = dClk; + sbs.fastCorr[iodf].ddClk = ddClk; + sbs.fastCorr[iodf].dIODF = dIODF; + sbs.fastCorr[iodf].IValid = degrIfc; + sbs.fastCorr[iodf].accDegr = degrL1Err[degrL1Ind[sat]] * 1e-5; + + tracepdeex( + SBAS_DEBUG_TRACE_LEVEL, + trace, + "L1 Fast Correction %s(%1d); dClk = %7.3f; ddClk = %7.3f; UDREI = %d;\n", + sat.id().c_str(), + iodf, + dClk, + ddClk, + UDREI + ); + if (iodf == 3) + { + for (auto& [iod, integr] : sbs.fastCorr) + { + integr.tIntg = frameTime; + integr.REint = UDREI; + } + } + else + { + sbs.fastCorr[iodf].tIntg = frameTime; + sbs.fastCorr[iodf].REint = UDREI; + } + } +} + +void decodeL1UDREIall(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data) +{ + if (lastIODP < 0) + return; + if (l1SBASSatMasks.find(lastIODP) == l1SBASSatMasks.end()) + return; + + int iodfInd[4]; + iodfInd[0] = getbitu(data, 14, 2); + iodfInd[1] = getbitu(data, 16, 2); + iodfInd[2] = getbitu(data, 18, 2); + iodfInd[3] = getbitu(data, 20, 2); + for (int i = 0; i <= 51; i++) + { + if (l1SBASSatMasks[lastIODP].find(i) == l1SBASSatMasks[lastIODP].end()) + continue; + SatSys sat = l1SBASSatMasks[lastIODP][i]; + auto& sbs = nav.satNavMap[sat].currentSBAS; + int j = i / 13; + int iodf = iodfInd[j]; + int iUdre = 22 + 4 * i; + int UDREI = getbitu(data, iUdre, 4); + if (UDREI > 13) + { + sbs.fastCorr.clear(); + sbs.fastUpdt[lastIODP].clear(); + } + tracepdeex( + SBAS_DEBUG_TRACE_LEVEL, + trace, + "L1 integrity %s(%d) UDREI = %d\n", + sat.id().c_str(), + iodf, + UDREI + ); + if (iodf == 3) + for (auto& [iod, integr] : sbs.fastCorr) + { + integr.tIntg = frameTime; + integr.REint = UDREI; + } + else + { + sbs.fastCorr[iodf].tIntg = frameTime; + sbs.fastCorr[iodf].REint = UDREI; + } + sbs.fastUpdt[lastIODP][frameTime] = iodf; + } +} + +void decodeL1FastDegr(Trace& trace, GTime frameTime, unsigned char* data) +{ + degrFCTlat = getbitu(data, 14, 4); + int iodp = getbitu(data, 18, 2); + + if (l1SBASSatMasks.find(iodp) == l1SBASSatMasks.end()) + return; + for (int i = 0; i <= 51; i++) + { + if (l1SBASSatMasks[iodp].find(i) == l1SBASSatMasks[iodp].end()) + continue; + SatSys sat = l1SBASSatMasks[iodp][i]; + degrL1Ind[sat] = getbitu(data, 22 + 4 * i, 4); + tracepdeex( + SBAS_DEBUG_TRACE_LEVEL, + trace, + "L1 fast degradation %s FDegr = %d\n", + sat.id().c_str(), + degrL1Ind[sat] + ); + } + fstDegrUpdate = frameTime; +} + +void decodeL1SlowDegr(Trace& trace, GTime frameTime, unsigned char* data) +{ + degrL1Slow.tUpdate = frameTime; + int i = 14; + degrL1Slow.Brrc = getbituInc(data, i, 10) * 2e-3; + degrL1Slow.Cltc_lsb = getbituInc(data, i, 10) * 2e-3; + degrL1Slow.Cltc_v1 = getbituInc(data, i, 10) * 5e-5; + degrL1Slow.Iltc_v1 = getbituInc(data, i, 9) * 1.0; + degrL1Slow.Cltc_v0 = getbituInc(data, i, 10) * 2e-3; + degrL1Slow.Iltc_v0 = getbituInc(data, i, 9) * 1.0; + degrL1Slow.Cgeo_lsb = getbituInc(data, i, 10) * 5e-4; + degrL1Slow.Cgeo_v = getbituInc(data, i, 10) * 5e-5; + degrL1Slow.Igeo_v = getbituInc(data, i, 9) * 1.0; + degrL1Slow.Cer = getbituInc(data, i, 6) * 0.5; + degrL1Slow.Ciono_step = getbituInc(data, i, 10) * 1e-3; + degrL1Slow.Iiono = getbituInc(data, i, 9) * 1.0; + degrL1Slow.Ciono_ramp = getbituInc(data, i, 10) * 5e-6; + degrL1Slow.RSSudre = getbituInc(data, i, 1) ? true : false; + degrL1Slow.RSSiono = getbituInc(data, i, 1) ? true : false; + degrL1Slow.Ccovar = getbituInc(data, i, 7) * 0.1; + + if (degrL1Slow.Iiono == 0) + degrL1Slow.Iiono = 1; + + if (degrL1Slow.Iltc_v0 == 0) + degrL1Slow.Iltc_v0 = 1; + + tracepdeex( + SBAS_DEBUG_TRACE_LEVEL, + trace, + "L1 slow degradation: %f %f %f\n", + degrL1Slow.Iiono, + degrL1Slow.Ciono_step, + degrL1Slow.Ciono_ramp + ); +} + +void decodeL1PosBlock(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data, int& ind) +{ + int slot1 = getbituInc(data, ind, 6) - 1; + int iode1 = getbituInc(data, ind, 8); + double ecefX1 = getbitsInc(data, ind, 9) * 0.125; + double ecefY1 = getbitsInc(data, ind, 9) * 0.125; + double ecefZ1 = getbitsInc(data, ind, 9) * 0.125; + double dClk_1 = getbitsInc(data, ind, 10) * (P2_31 * CLIGHT); + int slot2 = getbituInc(data, ind, 6) - 1; + int iode2 = getbituInc(data, ind, 8); + double ecefX2 = getbitsInc(data, ind, 9) * 0.125; + double ecefY2 = getbitsInc(data, ind, 9) * 0.125; + double ecefZ2 = getbitsInc(data, ind, 9) * 0.125; + double dClk_2 = getbitsInc(data, ind, 10) * (P2_31 * CLIGHT); + int iodp = getbituInc(data, ind, 2); + tracepdeex( + SBAS_DEBUG_TRACE_LEVEL, + trace, + "L1 slow correction IODP: %1d; slot: %2d\n", + iodp, + slot1 + ); + ind++; + if (l1SBASSatMasks.find(iodp) == l1SBASSatMasks.end()) + return; + if (l1SBASSatMasks[iodp].find(slot1) != l1SBASSatMasks[iodp].end()) + { + SatSys sat = l1SBASSatMasks[iodp][slot1]; + auto& sbs = nav.satNavMap[sat].currentSBAS; + sbs.slowCorr[iode1].iodp = iodp; + sbs.slowCorr[iode1].iode = iode1; + sbs.slowCorr[iode1].dPos[0] = ecefX1; + sbs.slowCorr[iode1].dPos[1] = ecefY1; + sbs.slowCorr[iode1].dPos[2] = ecefZ1; + sbs.slowCorr[iode1].dPos[3] = dClk_1; + sbs.slowCorr[iode1].ddPos[0] = 0.0; + sbs.slowCorr[iode1].ddPos[1] = 0.0; + sbs.slowCorr[iode1].ddPos[2] = 0.0; + sbs.slowCorr[iode1].ddPos[3] = 0.0; + sbs.slowCorr[iode1].toe = frameTime; + sbs.slowCorr[iode1].trec = frameTime; + sbs.slowCorr[iode1].Ivalid = acsConfig.sbsInOpts.prec_aproach ? 240 : 360; + sbs.slowUpdt[iodp][frameTime] = iode1; + tracepdeex( + SBAS_DEBUG_TRACE_LEVEL, + trace, + " %s dPos = (%7.3f,%7.3f,%7.3f,%7.3f); IODE: %d\n", + sat.id().c_str(), + ecefX1, + ecefY1, + ecefZ1, + dClk_1, + iode1 + ); + } + if (l1SBASSatMasks[iodp].find(slot2) != l1SBASSatMasks[iodp].end()) + { + SatSys sat = l1SBASSatMasks[iodp][slot2]; + auto& sbs = nav.satNavMap[sat].currentSBAS; + sbs.slowCorr[iode2].iodp = iodp; + sbs.slowCorr[iode2].iode = iode2; + sbs.slowCorr[iode2].dPos[0] = ecefX2; + sbs.slowCorr[iode2].dPos[1] = ecefY2; + sbs.slowCorr[iode2].dPos[2] = ecefZ2; + sbs.slowCorr[iode2].dPos[3] = dClk_2; + sbs.slowCorr[iode2].ddPos[0] = 0.0; + sbs.slowCorr[iode2].ddPos[1] = 0.0; + sbs.slowCorr[iode2].ddPos[2] = 0.0; + sbs.slowCorr[iode2].ddPos[3] = 0.0; + sbs.slowCorr[iode2].toe = frameTime; + sbs.slowCorr[iode2].trec = frameTime; + sbs.slowCorr[iode1].Ivalid = acsConfig.sbsInOpts.prec_aproach ? 240 : 360; + sbs.slowUpdt[iodp][frameTime] = iode2; + tracepdeex( + SBAS_DEBUG_TRACE_LEVEL, + trace, + " %s dPos = (%7.3f,%7.3f,%7.3f,%7.3f); IODE: %d\n", + sat.id().c_str(), + ecefX2, + ecefY2, + ecefZ2, + dClk_2 * CLIGHT, + iode2 + ); + } +} + +void decodeL1VelBlock(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data, int& ind) +{ + int slot1 = getbituInc(data, ind, 6) - 1; + int iode1 = getbituInc(data, ind, 8); + double ecefX1 = getbitsInc(data, ind, 11) * 0.125; + double ecefY1 = getbitsInc(data, ind, 11) * 0.125; + double ecefZ1 = getbitsInc(data, ind, 11) * 0.125; + double dClk_1 = getbitsInc(data, ind, 11) * (P2_31 * CLIGHT); + double vel_X1 = getbitsInc(data, ind, 8) * P2_11; + double vel_Y1 = getbitsInc(data, ind, 8) * P2_11; + double vel_Z1 = getbitsInc(data, ind, 8) * P2_11; + double ddClk1 = getbitsInc(data, ind, 8) * (P2_39 * CLIGHT); + double tod = getbituInc(data, ind, 13) * 16.0; + int iodp = getbituInc(data, ind, 2); + if (l1SBASSatMasks.find(iodp) == l1SBASSatMasks.end()) + return; + if (l1SBASSatMasks[iodp].find(slot1) != l1SBASSatMasks[iodp].end()) + { + SatSys sat = l1SBASSatMasks[iodp][slot1]; + auto& sbs = nav.satNavMap[sat].currentSBAS; + sbs.slowCorr[iode1].iodp = iodp; + sbs.slowCorr[iode1].iode = iode1; + sbs.slowCorr[iode1].dPos[0] = ecefX1; + sbs.slowCorr[iode1].dPos[1] = ecefY1; + sbs.slowCorr[iode1].dPos[2] = ecefZ1; + sbs.slowCorr[iode1].dPos[3] = dClk_1; + sbs.slowCorr[iode1].ddPos[0] = vel_X1; + sbs.slowCorr[iode1].ddPos[1] = vel_Y1; + sbs.slowCorr[iode1].ddPos[2] = vel_Z1; + sbs.slowCorr[iode1].ddPos[3] = ddClk1; + sbs.slowCorr[iode1].toe = adjustDay(tod, frameTime); + sbs.slowCorr[iode1].trec = frameTime; + sbs.slowCorr[iode1].Ivalid = acsConfig.sbsInOpts.prec_aproach ? 240 : 360; + sbs.slowUpdt[iodp][frameTime] = iode1; + } +} + +void decodeL1SlowCorr(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data) +{ + int ind = 14; + int velCode = getbituInc(data, ind, 1); + degrL1Slow.velCode = velCode; + if (velCode == 0) + decodeL1PosBlock(trace, frameTime, nav, data, ind); + else + decodeL1VelBlock(trace, frameTime, nav, data, ind); + velCode = getbituInc(data, ind, 1); + if (velCode == 0) + decodeL1PosBlock(trace, frameTime, nav, data, ind); + else + decodeL1VelBlock(trace, frameTime, nav, data, ind); +} + +void decodeL1MixdCorr(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data) +{ + int iodp = getbitu(data, 110, 2); + int strt = getbitu(data, 112, 2) * 13; + int iodf = getbitu(data, 114, 2); + + if (l1SBASSatMasks.find(iodp) == l1SBASSatMasks.end()) + return; + for (int i = 0; i < 6; i++) + { + int slot = i + strt; + if (l1SBASSatMasks[iodp].find(slot) == l1SBASSatMasks[iodp].end()) + continue; + SatSys sat = l1SBASSatMasks[iodp][slot]; + + int iFast = 14 + 12 * i; + int dIODF = -1; + double dClk = getbitu(data, iFast, 12) * 0.125; + double degrIfc = degrL1Out[degrL1Ind[sat]]; + double ddClk = 0; + + auto& sbs = nav.satNavMap[sat].currentSBAS; + if (!sbs.fastUpdt[iodp].empty()) + { + auto it = sbs.fastUpdt[iodp].begin(); + int prevIODF = it->second; + if (sbs.fastCorr.find(prevIODF) != sbs.fastCorr.end()) + { + double dt = (frameTime - sbs.fastCorr[prevIODF].tFast).to_double(); + if (degrL1Ind[sat] != 0 && dt < degrIfc) + { + ddClk = (dClk - sbs.fastCorr[prevIODF].dClk) / dt; + dIODF = (iodf - prevIODF) % 3; + if (iodf == 3) + dIODF = 3; + } + } + } + + int iUdre = 86 + 4 * i; + int UDREI = getbitu(data, iUdre, 4); + if (UDREI > 13) + { + sbs.fastCorr.clear(); + sbs.fastUpdt[iodp].clear(); + ddClk = 0.0; + dIODF = -1; + } + + sbs.fastUpdt[iodp][frameTime] = iodf; + sbs.fastCorr[iodf].tFast = frameTime; + sbs.fastCorr[iodf].dClk = dClk; + sbs.fastCorr[iodf].ddClk = ddClk; + sbs.fastCorr[iodf].dIODF = dIODF; + sbs.fastCorr[iodf].IValid = degrIfc; + sbs.fastCorr[iodf].accDegr = degrL1Err[degrL1Ind[sat]] * 1e-5; + + tracepdeex( + SBAS_DEBUG_TRACE_LEVEL, + trace, + "L1 Fast Correction %s(%1d); dClk =%7.3f; UDREI = %d;\n", + sat.id().c_str(), + iodf, + dClk, + UDREI + ); + if (iodf == 3) + { + for (auto& [iod, integr] : sbs.fastCorr) + { + integr.tIntg = frameTime; + integr.REint = UDREI; + } + } + else + { + sbs.fastCorr[iodf].tIntg = frameTime; + sbs.fastCorr[iodf].REint = UDREI; + } + } + int ind = 120; + int velCode = getbituInc(data, ind, 1); + if (velCode == 0) + decodeL1PosBlock(trace, frameTime, nav, data, ind); + else + decodeL1VelBlock(trace, frameTime, nav, data, ind); +} + +void decodeL1IonoGrid(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data) +{ + int nband = getbitu(data, 14, 4); + int band = getbitu(data, 18, 4); + int iodi = getbitu(data, 22, 2); + int entry = 0; + tracepdeex(SBAS_DEBUG_TRACE_LEVEL, trace, "\nIGP map for Band %d: ", band); + for (int ind = 1; ind <= 201; ind++) + if (getbitu(data, ind + 23, 1)) + addSBASIGP(trace, iodi, band, ind, entry++, frameTime, nband); + + tracepdeex(SBAS_DEBUG_TRACE_LEVEL, trace, " %d points ", entry); +} + +void decodeL1IonoGIVD(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data) +{ + int band = getbitu(data, 14, 4); + int block = getbitu(data, 18, 4); + int iodi = getbitu(data, 217, 2); + int entry = block * 15; + int ind = 22; + for (int i = 0; i < 15; i++) + { + int givdi = getbitu(data, 13 * i + 22, 9); + int givei = getbitu(data, 13 * i + 31, 4); + if (givdi == 511) + givei = 15; + writeIonoData(trace, iodi, band, entry + i, frameTime, givdi, givei); + } +} + +void decodeL1GEO_Navg(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data, int prn) +{ + Seph seph = {}; + int ind = 22; + double tod = getbituInc(data, ind, 13) * 16.0; + GTime t0 = adjustDay(tod, frameTime); + int ura = getbituInc(data, ind, 4); + if (ura == 15) + return; + seph.type = E_NavMsgType::SBAS; + seph.Sat.sys = E_Sys::SBS; + seph.Sat.prn = prn - 100; + seph.t0 = t0; + seph.tof = frameTime; + seph.ura = sbasGEOUra[ura]; + seph.pos[0] = getbitsInc(data, ind, 30) * 0.08; + seph.pos[1] = getbitsInc(data, ind, 30) * 0.08; + seph.pos[2] = getbitsInc(data, ind, 25) * 0.4; + seph.vel[0] = getbitsInc(data, ind, 17) * 0.000625; + seph.vel[1] = getbitsInc(data, ind, 17) * 0.000625; + seph.vel[2] = getbitsInc(data, ind, 18) * 0.004; + seph.acc[0] = getbitsInc(data, ind, 10) * 0.0000125; + seph.acc[1] = getbitsInc(data, ind, 10) * 0.0000125; + seph.acc[2] = getbitsInc(data, ind, 10) * 0.000625; + seph.af0 = getbitsInc(data, ind, 12) * P2_31; + seph.af0 = getbitsInc(data, ind, 8) * P2_40; + + nav.sephMap[seph.Sat][seph.type][seph.t0] = seph; +} + +void decodeL1UDRERegn(Trace& trace, GTime frameTime, unsigned char* data) +{ + for (auto it = regnMaps.begin(); it != regnMaps.end();) + { + auto regMap = it->second; + if ((frameTime - regMap.tUpdate).to_double() > 86400) + it = regnMaps.erase(it); + else + it++; + } + + int ind = 14; + int iods = getbituInc(data, ind, 3); + int nmes = getbituInc(data, ind, 3) + 1; + int imes = getbituInc(data, ind, 3); + int nreg = getbituInc(data, ind, 3); + int prio = getbituInc(data, ind, 2); + int inFact = getbituInc(data, ind, 4); + int ouFact = getbituInc(data, ind, 4); + + regnMaps[iods].totmess = nmes; + regnMaps[iods].tUpdate = frameTime; + regnMaps[iods].mess[imes] = nreg; + + for (int i = 0; i < nreg; i++) + { + int ireg = imes * 5 + i; + auto& reg = regnMaps[iods].regions[prio][ireg]; + reg.lat1 = getbitsInc(data, ind, 8) * 1.0; + reg.lon1 = getbitsInc(data, ind, 9) * 1.0; + reg.lat2 = getbitsInc(data, ind, 8) * 1.0; + reg.lon2 = getbitsInc(data, ind, 9) * 1.0; + reg.triangular = getbitsInc(data, ind, 1) == 0 ? true : false; + reg.in_Factor = dUDRETable[inFact]; + reg.outFactor = dUDRETable[ouFact]; + reg.time = frameTime; + tracepdeex( + SBAS_DEBUG_TRACE_LEVEL, + trace, + "L1 dUdre region %d (%d): lat: %3.0f %3.0f; lon: %4.0f %4.0f; %1d; dUDRe: %.2f %.2f\n", + ireg, + prio, + reg.lat1, + reg.lat2, + reg.lon1, + reg.lon2, + reg.triangular ? 3 : 4, + reg.in_Factor, + reg.outFactor + ); + } +} + +void decodeL1UDRECovr(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data) +{ + int i = 14; + int iodp = getbituInc(data, i, 2); + if (l1SBASSatMasks.find(iodp) == l1SBASSatMasks.end()) + return; + int slot1 = getbituInc(data, i, 6); + if (l1SBASSatMasks[iodp].find(slot1) == l1SBASSatMasks[iodp].end()) + { + double scale = 2 ^ (getbituInc(data, i, 3) - 5); + MatrixXd E = MatrixXd::Zero(4, 4); + E(0, 0) = getbituInc(data, i, 9); + E(1, 1) = getbituInc(data, i, 9); + E(2, 2) = getbituInc(data, i, 9); + E(3, 3) = getbituInc(data, i, 9); + E(0, 1) = getbitsInc(data, i, 10); + E(0, 2) = getbitsInc(data, i, 10); + E(0, 3) = getbitsInc(data, i, 10); + E(1, 2) = getbitsInc(data, i, 10); + E(1, 3) = getbitsInc(data, i, 10); + E(2, 3) = getbitsInc(data, i, 10); + MatrixXd R = scale * E; + MatrixXd C = R.transpose() * R; + + SatSys sat = l1SBASSatMasks[iodp][slot1]; + sbasUdreCov[iodp][sat].REScale = scale; + sbasUdreCov[iodp][sat].covr = C; + sbasUdreCov[iodp][sat].toe = frameTime; + sbasUdreCov[iodp][sat].Ivalid = acsConfig.sbsInOpts.prec_aproach ? 240 : 360; + } + else + i += 99; + + int slot2 = getbituInc(data, i, 6); + if (l1SBASSatMasks[iodp].find(slot2) == l1SBASSatMasks[iodp].end()) + { + double scale = 2 ^ (getbituInc(data, i, 3) - 5); + MatrixXd E = MatrixXd::Zero(4, 4); + E(0, 0) = getbituInc(data, i, 9); + E(1, 1) = getbituInc(data, i, 9); + E(2, 2) = getbituInc(data, i, 9); + E(3, 3) = getbituInc(data, i, 9); + E(0, 1) = getbitsInc(data, i, 10); + E(0, 2) = getbitsInc(data, i, 10); + E(0, 3) = getbitsInc(data, i, 10); + E(1, 2) = getbitsInc(data, i, 10); + E(1, 3) = getbitsInc(data, i, 10); + E(2, 3) = getbitsInc(data, i, 10); + MatrixXd R = scale * E; + MatrixXd C = R.transpose() * R; + + SatSys sat = l1SBASSatMasks[iodp][slot1]; + sbasUdreCov[iodp][sat].REScale = scale; + sbasUdreCov[iodp][sat].covr = C; + sbasUdreCov[iodp][sat].toe = frameTime; + sbasUdreCov[iodp][sat].Ivalid = acsConfig.sbsInOpts.prec_aproach ? 240 : 360; + } +} + void decodeSBASMessage(Trace& trace, GTime time, SBASMessage& mess, Navigation& nav) { int type = mess.type; if (type == 0) type = acsConfig.sbsInOpts.mt0; + checkForType0(time, type); + tracepdeex(SBAS_DEBUG_TRACE_LEVEL, trace, "Decoding SBAS message type %2d\n", type); + switch (type) { - // case 1: decodeL1SBASMask(frameTime,nav,mess.data); break; // Satellite mask - // case 2: decodeL1FastCorr(frameTime,nav,mess.data,0); break; // Fast Corrections sat - // 1-13 case 3: decodeL1FastCorr(frameTime,nav,mess.data,13); break; // Fast - // Corrections sat 14-26 case 4: decodeL1FastCorr(frameTime,nav,mess.data,26); break; - // // Fast Corrections sat 27-39 case 5: decodeL1FastCorr(frameTime,nav,mess.data,39); - // break; // Fast Corrections sat 40-51 case 6: - // decodeL1UDREBase(frameTime,nav,mess.data); break; // UDRE case 7: - // decodeL1FastDegr( nav,mess.data); break; // Fast Correction degradation - // case 9: decodeL1GEO_Navg(frameTime,nav,mess.data,mess.prn); break; // GEO satellite - // position data (Ephemeris) case 10: decodeL1SlowDegr( nav,mess.data); - // break; // Slow Correction degradation case 12: - // decodeL1SBASTime(frameTime,nav,mess.data); break; // SBAS Network Time nav,message - // case 17: decodeL1GEO_Almn(frameTime,nav,mess.data); break; // GEO satellite - // position data (Almanac) *not supported case 18: decodeL1IonoGrid( nav,mess.data); - // break; // Ionosphere Grid points definition case 24: - // decodeL1MixdCorr(frameTime,nav,mess.data); break; // Fast & Slow Correction - // degradation *not supported case 25: decodeL1SlowCorr(frameTime,nav,mess.data); - // break; // Slow corrections case 26: decodeL1IonoGIVD(frameTime,nav,mess.data); - // break; // Ionosphere Correction at IGP case 27: - // decodeL1UDRERegn(frameTime,nav,mess.data); break; // UDRE Region definition case - // 28: decodeL1UDRECovr(frameTime,nav,mess.data); break; // UDRE covariance matrix - // case 62: + case 1: + decodeL1SBASMask(trace, mess.data); + break; // Satellite mask + case 2: + decodeL1FastCorr(trace, time, nav, mess.data, 0); + break; // Fast Corrections 1-13 + case 3: + decodeL1FastCorr(trace, time, nav, mess.data, 13); + break; // Fast Corrections 14-26 + case 4: + decodeL1FastCorr(trace, time, nav, mess.data, 26); + break; // Fast Corrections 27-39 + case 5: + decodeL1FastCorr(trace, time, nav, mess.data, 39); + break; // Fast Corrections 40-51 + case 6: + decodeL1UDREIall(trace, time, nav, mess.data); + break; // UDREI all satellites + case 7: + decodeL1FastDegr(trace, time, mess.data); + break; // Fast Correction degradation + case 9: + decodeL1GEO_Navg(trace, time, nav, mess.data, mess.prn); + break; // GEO satellite + case 10: + decodeL1SlowDegr(trace, time, mess.data); + break; // Slow Correction degradation + case 18: + decodeL1IonoGrid(trace, time, nav, mess.data); + break; // Ionosphere Grid points definition + case 24: + decodeL1MixdCorr(trace, time, nav, mess.data); + break; // Fast & Slow Correction + case 25: + decodeL1SlowCorr(trace, time, nav, mess.data); + break; // Slow corrections + case 26: + decodeL1IonoGIVD(trace, time, nav, mess.data); + break; // Ionosphere Correction at IGPs + case 27: + decodeL1UDRERegn(trace, time, mess.data); + break; // UDRE Region definition + case 28: + decodeL1UDRECovr(trace, time, nav, mess.data); + break; // UDRE covariance matrix + case 62: case 63: break; default: @@ -40,3 +800,213 @@ void decodeSBASMessage(Trace& trace, GTime time, SBASMessage& mess, Navigation& } return; } + +bool recvInRegion(SBASRegn& reg, double latDeg, double lonDeg) +{ + double intLat = reg.lat2 - reg.lat1; + double dLat = (latDeg - reg.lat1) / intLat; + if (0 < dLat || dLat > 1) + return false; + + double intLon = reg.lon1 - reg.lon2; + if (intLon > 180) + intLon -= 360; + if (intLon < -180) + intLon += 360; + + double dLon_ = lonDeg - reg.lon2; + if (dLon_ > 180) + dLon_ -= 360; + if (dLon_ < -180) + dLon_ += 360; + + double dLon = dLon_ / intLon; + + if (0 < dLon || dLon > 1) + return false; + + if (!reg.triangular) + return true; + + if ((1 - dLat - dLon) < 0) + return true; + + return false; +} + +double rangeErrFromReg(Trace& trace, GTime time, Vector3d& rRec) +{ + double dt = 86401; + SBASRegnMap completeMap; + for (auto& [iods, regMap] : regnMaps) + { + if (regMap.mess.size() < regMap.totmess) + continue; + double dtIods = (time - regMap.tUpdate).to_double(); + if (dtIods > dt) + { + dt = dtIods; + completeMap = regMap; + } + } + + if (dt > 86400) + return 1; + + VectorPos pos = ecef2pos(rRec); + double latDeg = pos.latDeg(); + double lonDeg = pos.lonDeg(); + + int highPrio = -1; + int highPrio_out = -1; + double mindUDRE = 100; + double mindUDRE_out = 100; + for (auto& [prio, regList] : completeMap.regions) + for (auto& [ireg, reg] : regList) + { + if ((time - reg.time).to_double() > 86400) + continue; + if (recvInRegion(reg, latDeg, lonDeg)) + { + if (prio > highPrio) + { + highPrio = prio; + mindUDRE = reg.in_Factor; + } + else if (prio == highPrio && mindUDRE > reg.in_Factor) + mindUDRE = reg.in_Factor; + } + else if (highPrio < 0) + { + if (prio > highPrio_out) + { + highPrio_out = prio; + mindUDRE_out = reg.outFactor; + } + else if (prio == highPrio_out && mindUDRE_out > reg.outFactor) + mindUDRE_out = reg.outFactor; + } + } + + if (highPrio >= 0) + return mindUDRE; + + if (highPrio_out >= 0) + return mindUDRE_out; + + return 1; +} + +double estimateSBASVar( + Trace& trace, + GTime time, + SatSys sat, + Vector3d& rRec, + Vector3d& rSat, + SBASFast& sbsFast, + SBASSlow& sbasSlow +) +{ + int iodp = sbsFast.iodp; + if (sbasSlow.iodp != iodp) + return -2; + + int UDREI = sbsFast.REint; + if (UDREI < 0) + return -2; + if (UDREI == 14) + return -1; + if (UDREI > 15) + return -2; + double sig2UDRE = sig2UDREI[UDREI]; + + double dUDRE = 1; + if (sbasUdreCov.find(iodp) != sbasUdreCov.end() && + sbasUdreCov[iodp].find(sat) != sbasUdreCov[iodp].end()) + dUDRE = rangeErrFromCov(trace, time, iodp, sat, rRec, rSat, degrL1Slow.Ccovar); + else if (!regnMaps.empty()) + dUDRE = rangeErrFromReg(trace, time, rRec); + + double var = SQR(dUDRE * sqrt(sig2UDRE) + 8); + + double maxDegrAge = 360; + if (acsConfig.sbsInOpts.prec_aproach) + maxDegrAge = 240; + + if ((!fstDegrUpdate) || (time - fstDegrUpdate).to_double() > maxDegrAge) + return var; + + if ((!degrL1Slow.tUpdate) || (time - degrL1Slow.tUpdate).to_double() > maxDegrAge) + return var; + + double dtFast = (time - sbsFast.tFast).to_double() + degrFCTlat; + double eFc = sbsFast.accDegr * dtFast * dtFast / 2; + + double eRrc = 0; + if (sbsFast.accDegr > 0) + switch (sbsFast.dIODF) + { + case 0: + case 2: + eRrc = (sbsFast.accDegr * sbsFast.IValid / 4 + degrL1Slow.Brrc / sbsFast.dt) * + (time - sbsFast.tFast).to_double(); + break; + case 3: + double ddt = fabs(sbsFast.dt - sbsFast.IValid / 2); + if (ddt > 0) + eRrc = (sbsFast.accDegr * ddt / 2 + degrL1Slow.Brrc / sbsFast.dt) * + (time - sbsFast.tFast).to_double(); + break; + } + + double eLtc = 0; + double dtLtc = (time - sbasSlow.toe).to_double(); + if (degrL1Slow.velCode == 1) + { + if (dtLtc < 0) + eLtc = degrL1Slow.Cltc_lsb - degrL1Slow.Cltc_v1 * dtLtc; + if (dtLtc > degrL1Slow.Iltc_v1) + eLtc = degrL1Slow.Cltc_lsb + degrL1Slow.Cltc_v1 * (dtLtc - degrL1Slow.Iltc_v1); + } + else + eLtc = degrL1Slow.Cltc_v0 * floor(dtLtc / degrL1Slow.Iltc_v0); + + double eEr = 0; + if (dtLtc > maxDegrAge || (time - sbsFast.tFast).to_double() > sbsFast.IValid) + { + if (acsConfig.sbsInOpts.prec_aproach) + return -2; + else + eEr = degrL1Slow.Cer; + } + + if (degrL1Slow.RSSudre) + var = sig2UDRE * SQR(dUDRE) + SQR(eFc) + SQR(eLtc) + SQR(eEr); + else + var = SQR(sqrt(sig2UDRE) * dUDRE + eFc + eRrc + eLtc + eEr); + return var; +} + +double estimateIonoVar(GTime time, GTime givdTime, double sigGIVE) +{ + if (sigGIVE < 0) + return -1; + + if (degrL1Slow.Iiono < 0) + return -1; + + if ((time - degrL1Slow.tUpdate) > SBAS_DEGR_OUTAGE) + return -1; + + double dt = (time - givdTime).to_double(); + double dGIVE = + degrL1Slow.Ciono_step * floor(dt / degrL1Slow.Iiono) + degrL1Slow.Ciono_ramp * dt; + + double var = -1; + if (degrL1Slow.RSSiono) + var = sigGIVE * sigGIVE + dGIVE * dGIVE; + else + var = (sigGIVE + dGIVE) * (sigGIVE + dGIVE); + + return var; +} \ No newline at end of file diff --git a/src/cpp/sbas/decodeL5.cpp b/src/cpp/sbas/decodeL5.cpp index f8299343c..bd322b8f5 100644 --- a/src/cpp/sbas/decodeL5.cpp +++ b/src/cpp/sbas/decodeL5.cpp @@ -15,17 +15,16 @@ struct sbsDFREsysDegr }; GTime mt37Time; -double IValidGNSS = -1; -double IValidGEO = -1; +double IValidGNSS = 30; +double IValidGEO = 30; double mt37CER = 31.5; double mt37Ccov = 12.7; int degrdType = 0; map sysDegr; map DFREtable; -map> - SBASSatMasks; // Satellite mask updated by MT31, SBASSatMasks[IODP][index] = SatSys; -int lastIODM = -1; +map> l5SBASSatMasks; +int lastIODM = -1; SatSys l5SatIndex(int sati) { @@ -70,8 +69,8 @@ void decodeL5SBASMask(Trace& trace, unsigned char* data) tracepdeex(DFMC_DEBUG_TRACE_LEVEL, trace, "L5 mask IODM: %1d", iodm); - if (SBASSatMasks.find(iodm) != SBASSatMasks.end()) - SBASSatMasks[iodm].clear(); + if (l5SBASSatMasks.find(iodm) != l5SBASSatMasks.end()) + l5SBASSatMasks[iodm].clear(); int i = 0; for (int ind = 1; ind <= 214; ind++) @@ -80,7 +79,7 @@ void decodeL5SBASMask(Trace& trace, unsigned char* data) SatSys sat = l5SatIndex(ind); if (!sat) continue; - SBASSatMasks[iodm][i++] = sat; + l5SBASSatMasks[iodm][i++] = sat; tracepdeex(DFMC_DEBUG_TRACE_LEVEL, trace, ", %s", sat.id().c_str()); } @@ -89,7 +88,8 @@ void decodeL5SBASMask(Trace& trace, unsigned char* data) void decodeL5DFMCCorr(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data) { - int iod = 0; + if (lastIODM < 0) + return; int i = 10; int ns = acsConfig.sbsInOpts.use_do259 ? 8 : 9; @@ -106,6 +106,7 @@ void decodeL5DFMCCorr(Trace& trace, GTime frameTime, Navigation& nav, unsigned c int iode = getbituInc(data, i, 10); sbs.iode = iode; + sbs.iodp = lastIODM; sbs.dPos[0] = getbitsInc(data, i, 11) * 0.0625; sbs.dPos[1] = getbitsInc(data, i, 11) * 0.0625; sbs.dPos[2] = getbitsInc(data, i, 11) * 0.0625; @@ -130,9 +131,9 @@ void decodeL5DFMCCorr(Trace& trace, GTime frameTime, Navigation& nav, unsigned c sbs.toe = teph; sbs.trec = frameTime; - auto& sbsMap = nav.satNavMap[sat].currentSBAS; - sbsMap.slowCorr[iode] = sbs; - sbsMap.corrUpdt[frameTime] = iode; + auto& sbsMap = nav.satNavMap[sat].currentSBAS; + sbsMap.slowCorr[iode] = sbs; + sbsMap.slowUpdt[lastIODM][frameTime] = iode; tracepdeex( DFMC_DEBUG_TRACE_LEVEL, @@ -151,68 +152,54 @@ void decodeL5DFMCCorr(Trace& trace, GTime frameTime, Navigation& nav, unsigned c sbsMap.slowCorr[iode].ddPos[3] ); - if (IValidGNSS > 0) - { - sbsMap.corrUpdt.erase( - sbsMap.corrUpdt.upper_bound(frameTime - IValidGNSS), - sbsMap.corrUpdt.end() - ); - for (auto it = sbsMap.slowCorr.begin(); it != sbsMap.slowCorr.end();) - { - auto trec = it->second.trec; - if ((frameTime - teph).to_double() > IValidGNSS) - it = sbsMap.slowCorr.erase(it); - else - it++; - } - } - //-------------------------------------------- - if (lastIODM >= 0) - { - sbsMap.Integrity[lastIODM].trec = frameTime; - - double exponent = getbituInc(data, i, 3) - 5.0; - double scale = pow(2, exponent); - sbsMap.Integrity[lastIODM].REScale = scale; - - MatrixXd E = MatrixXd::Zero(4, 4); - E(0, 0) = getbituInc(data, i, 9); - E(1, 1) = getbituInc(data, i, 9); - E(2, 2) = getbituInc(data, i, 9); - E(3, 3) = getbituInc(data, i, 9); - E(0, 1) = getbitsInc(data, i, 10); - E(0, 2) = getbitsInc(data, i, 10); - E(0, 3) = getbitsInc(data, i, 10); - E(1, 2) = getbitsInc(data, i, 10); - E(1, 3) = getbitsInc(data, i, 10); - E(2, 3) = getbitsInc(data, i, 10); - MatrixXd R = scale * E; - MatrixXd C = R.transpose() * R; - sbsMap.Integrity[lastIODM].covr = C; - - sbsMap.Integrity[lastIODM].REint = getbituInc(data, i, 4); - sbsMap.Integrity[lastIODM].REBoost = false; - - if (acsConfig.sbsInOpts.use_do259) - sbsMap.Integrity[lastIODM].dRcorr = (getbituInc(data, i, 4) + 1) / 15; - else - sbsMap.Integrity[lastIODM].dRcorr = (getbituInc(data, i, 3) + 1) / 8; - - tracepdeex( - DFMC_DEBUG_TRACE_LEVEL, - trace, - ", %2d, %.3f", - sbsMap.Integrity[lastIODM].REint, - sbsMap.Integrity[lastIODM].dRcorr - ); - } + double expnt = getbituInc(data, i, 3) - 5.0; + double scale = pow(2, expnt); + MatrixXd E = MatrixXd::Zero(4, 4); + E(0, 0) = getbituInc(data, i, 9); + E(1, 1) = getbituInc(data, i, 9); + E(2, 2) = getbituInc(data, i, 9); + E(3, 3) = getbituInc(data, i, 9); + E(0, 1) = getbitsInc(data, i, 10); + E(0, 2) = getbitsInc(data, i, 10); + E(0, 3) = getbitsInc(data, i, 10); + E(1, 2) = getbitsInc(data, i, 10); + E(1, 3) = getbitsInc(data, i, 10); + E(2, 3) = getbitsInc(data, i, 10); + MatrixXd R = scale * E; + MatrixXd C = R.transpose() * R; + int REint = getbituInc(data, i, 4); + double dRcorr; + if (acsConfig.sbsInOpts.use_do259) + dRcorr = (getbituInc(data, i, 4) + 1) / 15; + else + dRcorr = (getbituInc(data, i, 3) + 1) / 8; + + sbsMap.fastCorr[4].tIntg = frameTime; + sbsMap.fastCorr[4].REint = REint; + sbsMap.fastCorr[4].REBoost = false; + sbsMap.fastCorr[4].dRcorr = dRcorr; + sbsMap.fastUpdt[lastIODM][frameTime] = 4; + + auto& sbsCov = sbasUdreCov[lastIODM][sat]; + sbsCov.toe = frameTime; + sbsCov.Ivalid = IValidGNSS; + sbsCov.REScale = scale; + sbsCov.covr = C; + + tracepdeex( + DFMC_DEBUG_TRACE_LEVEL, + trace, + ", %2d, %.3f", + sbsMap.fastCorr[4].REint, + sbsMap.fastCorr[4].dRcorr + ); } void decodeL5DFMCInt1(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data) { int iodm = getbitu(data, 224, 2); - if (SBASSatMasks.find(iodm) == SBASSatMasks.end()) + if (l5SBASSatMasks.find(iodm) == l5SBASSatMasks.end()) { tracepdeex(DFMC_DEBUG_TRACE_LEVEL, trace, " corrections for unknown IODM: %1d", iodm); return; @@ -220,50 +207,53 @@ void decodeL5DFMCInt1(Trace& trace, GTime frameTime, Navigation& nav, unsigned c tracepdeex(DFMC_DEBUG_TRACE_LEVEL, trace, " L5 DFRECI IODM: %1d", iodm); - int i = 10; - int j = 0; - map changedDFRE; + int i = 10; + int j = 0; + map changedDFRE; + map buffer; for (int slot = 0; slot < 92; slot++) { - if (SBASSatMasks[iodm].find(slot) == SBASSatMasks[iodm].end()) + if (l5SBASSatMasks[iodm].find(slot) == l5SBASSatMasks[iodm].end()) continue; - SatSys sat = SBASSatMasks[iodm][slot]; - auto& sbs = nav.satNavMap[sat].currentSBAS; + SatSys sat = l5SBASSatMasks[iodm][slot]; + auto& sbs = nav.satNavMap[sat].currentSBAS; + buffer[sat] = sbs.fastCorr[4]; + buffer[sat].REBoost = false; int DFRECI = getbituInc(data, i, 2); switch (DFRECI) { - case 0: - sbs.Integrity[iodm].REBoost = false; - break; case 2: - sbs.Integrity[iodm].REBoost = true; + buffer[sat].REBoost = true; break; case 1: if (j < 7) changedDFRE[j++] = sat; case 3: - sbs.Integrity[iodm].REint = 15; + buffer[sat].REint = 15; break; } - sbs.Integrity[iodm].trec = frameTime; + buffer[sat].tIntg = frameTime; } for (int slot = 0; slot < j; slot++) { - SatSys sat = changedDFRE[slot]; - auto& sbs = nav.satNavMap[sat].currentSBAS; + SatSys sat = changedDFRE[slot]; + buffer[sat].REint = getbituInc(data, i, 4); + } - sbs.Integrity[iodm].REint = getbituInc(data, i, 4); - sbs.Integrity[iodm].REBoost = false; - sbs.Integrity[iodm].trec = frameTime; + for (auto [sat, fastData] : buffer) + { + auto& sbs = nav.satNavMap[sat].currentSBAS; + sbs.fastCorr[4] = buffer[sat]; + sbs.fastUpdt[iodm][frameTime] = 4; } } void decodeL5DFMCInt2(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data) { int iodm = getbitu(data, 224, 2); - if (SBASSatMasks.find(iodm) == SBASSatMasks.end()) + if (l5SBASSatMasks.find(iodm) == l5SBASSatMasks.end()) { tracepdeex(DFMC_DEBUG_TRACE_LEVEL, trace, " corrections for unknown IODM: %1d\n", iodm); return; @@ -274,20 +264,21 @@ void decodeL5DFMCInt2(Trace& trace, GTime frameTime, Navigation& nav, unsigned c int i = 10; for (int slot = 0; slot < 53; slot++) { - if (SBASSatMasks[iodm].find(slot) == SBASSatMasks[iodm].end()) + if (l5SBASSatMasks[iodm].find(slot) == l5SBASSatMasks[iodm].end()) continue; - SatSys sat = SBASSatMasks[iodm][slot]; - auto& sbs = nav.satNavMap[sat].currentSBAS; - sbs.Integrity[iodm].REint = getbituInc(data, i, 4); - sbs.Integrity[iodm].REBoost = false; - sbs.Integrity[iodm].trec = frameTime; + SatSys sat = l5SBASSatMasks[iodm][slot]; + auto& sbs = nav.satNavMap[sat].currentSBAS; + sbs.fastCorr[4].REint = getbituInc(data, i, 4); + sbs.fastCorr[4].REBoost = false; + sbs.fastCorr[4].tIntg = frameTime; + sbs.fastUpdt[iodm][frameTime] = 4; } } void decodeL5DFMCInt3(Trace& trace, GTime frameTime, Navigation& nav, unsigned char* data) { int iodm = getbitu(data, 224, 2); - if (SBASSatMasks.find(iodm) == SBASSatMasks.end()) + if (l5SBASSatMasks.find(iodm) == l5SBASSatMasks.end()) { tracepdeex(DFMC_DEBUG_TRACE_LEVEL, trace, " corrections for unknown IODM: %1d\n", iodm); return; @@ -298,13 +289,14 @@ void decodeL5DFMCInt3(Trace& trace, GTime frameTime, Navigation& nav, unsigned c int i = 10; for (int slot = 53; slot < 92; slot++) { - if (SBASSatMasks[iodm].find(slot) == SBASSatMasks[iodm].end()) + if (l5SBASSatMasks[iodm].find(slot) == l5SBASSatMasks[iodm].end()) continue; - SatSys sat = SBASSatMasks[iodm][slot]; - auto& sbs = nav.satNavMap[sat].currentSBAS; - sbs.Integrity[iodm].REint = getbituInc(data, i, 4); - sbs.Integrity[iodm].REBoost = false; - sbs.Integrity[iodm].trec = frameTime; + SatSys sat = l5SBASSatMasks[iodm][slot]; + auto& sbs = nav.satNavMap[sat].currentSBAS; + sbs.fastCorr[4].REint = getbituInc(data, i, 4); + sbs.fastCorr[4].REBoost = false; + sbs.fastCorr[4].tIntg = frameTime; + sbs.fastUpdt[iodm][frameTime] = 4; } } @@ -317,6 +309,12 @@ void decodeL5DFREDegr(Trace& trace, GTime frameTime, unsigned char* data) mt37Ccov = getbituInc(data, i, 7) * 0.1; mt37Time = frameTime; + if (!acsConfig.sbsInOpts.prec_aproach) + { + IValidGNSS *= 1.5; + IValidGEO *= 1.5; + } + sysDegr[E_Sys::GPS].Icorr = getbituInc(data, i, 5) * 6.0 + 30.0; sysDegr[E_Sys::GPS].Ccorr = getbituInc(data, i, 8) * 0.01; sysDegr[E_Sys::GPS].Rcorr = getbituInc(data, i, 8) * 0.2; @@ -375,6 +373,8 @@ void decodeDFMCMessage(Trace& trace, GTime time, SBASMessage& mess, Navigation& if (type == 0) type = acsConfig.sbsInOpts.mt0; + checkForType0(time, type); + if (type == 65) // Handling of SouthPAN L5 message type 0 type = 33 + getbitu(mess.data, 222, 2); @@ -421,86 +421,66 @@ void decodeDFMCMessage(Trace& trace, GTime time, SBASMessage& mess, Navigation& return; } -double estimateDFMCVar(Trace& trace, GTime time, SatPos& satPos, SBASIntg& sbsIntg) +double estimateDFMCVar( + Trace& trace, + GTime time, + SatSys sat, + Vector3d& rRec, + Vector3d& rSat, + SBASFast& sbsIntg +) { int DFREI = sbsIntg.REint; if (sbsIntg.REBoost) DFREI++; - if (DFREI < 0 || DFREI > 14) + if (DFREI < 0) + return -2; + if (DFREI == 14) return -1; + if (DFREI == 15) + return -2; double dt = (time - mt37Time).to_double(); double maxDt = acsConfig.sbsInOpts.prec_aproach ? 240 : 360; if (dt > maxDt) - return -1; + return -2; - double sigDFRE = DFREtable[DFREI]; + double dDFRE = rangeErrFromCov(trace, time, sbsIntg.iodp, sat, rRec, rSat, mt37Ccov); + if (dDFRE < 0) + return -2; - SatStat& satStat = *satPos.satStat_ptr; - double x = 1e8; - if (satStat.e.norm() > 0) - { - VectorXd e = VectorXd::Zero(4); - for (int i = 0; i < 3; i++) - e[i] = satStat.e[i]; - e[3] = 1; - VectorXd Ce = sbsIntg.covr * e; - x = e.dot(Ce); - } - double dDFRE = sqrt(x) + mt37Ccov * sbsIntg.REScale; + double sigDFRE = DFREtable[DFREI]; - double rCorr = (dt > IValidGNSS) ? sbsIntg.dRcorr : 1; - auto sys = satPos.Sat.sys; - double eCorr = sysDegr[sys].Ccorr * floor(dt / sysDegr[sys].Icorr) + - sysDegr[sys].Rcorr * rCorr * dt / 1000; + double dRCorr = (dt > IValidGNSS) ? sbsIntg.dRcorr : 1; + auto sys = sat.sys; + double CCorr = sysDegr[sys].Ccorr; + double ICorr = sysDegr[sys].Icorr; + double RCorr = sysDegr[sys].Rcorr; + double eCorr = CCorr * floor(dt / ICorr) + dRCorr * RCorr * dt / 1000; double eer = (dt > IValidGNSS) ? mt37CER : 0; - double var = -1; - + double var; if (degrdType == 1) - var = SQR((SQR(sigDFRE) + eCorr + eer) * dDFRE); + var = SQR(dDFRE * (SQR(sigDFRE) + eCorr + eer)); else - var = SQR(sigDFRE * dDFRE) + SQR(eCorr) + SQR(eer); + var = SQR(dDFRE * sigDFRE) + SQR(eCorr) + SQR(eer); tracepdeex( 5, trace, - "\nSBASVAR %s %s, DFRE= %2d %.4f, dDFRE: %.5e %.5e, eCorr: %.3f %.3f %.3f, eer: %3f, " + "\nSBASVAR %s %s, DFRE= %2d %.4f, dDFRE: %.5e, eCorr: %.3f, eer: %3f, " "total: %.3f", time.to_string().c_str(), - satPos.Sat.id().c_str(), + sat.id().c_str(), sbsIntg.REint, sigDFRE, - sqrt(x), dDFRE, - dt, - rCorr, eCorr, eer, sqrt(var) ); - if (acsConfig.sbsInOpts.pvs_on_dfmc) - var = SQRT(0.005); - return var; } - -void estimateDFMCPL(Vector3d& staPos, Matrix3d& ecefP, double& horPL, double& verPL) -{ - VectorPos pos = ecef2pos(staPos); - Matrix3d E; - pos2enu(pos, E.data()); - Matrix3d EP = E * ecefP; - Matrix3d enuP = EP * E.transpose(); - - double scaleH = acsConfig.sbsInOpts.prec_aproach ? 6.00 : 6.18; - double scaleV = 5.33; - double aveEN = (enuP(0, 0) + enuP(1, 1)) / 2; - double difEN = (enuP(0, 0) - enuP(1, 1)) / 2; - double covEN = enuP(0, 1); - horPL = scaleH * sqrt(aveEN + sqrt(difEN * difEN + covEN * covEN)); - verPL = scaleV * sqrt(enuP(2, 2)); -} \ No newline at end of file diff --git a/src/cpp/sbas/sbas.cpp b/src/cpp/sbas/sbas.cpp index eddddca90..ec20d8c79 100644 --- a/src/cpp/sbas/sbas.cpp +++ b/src/cpp/sbas/sbas.cpp @@ -14,16 +14,11 @@ using std::lock_guard; GTime lastEMSLoaded; GTime lastEMSWritten; string lastEMSFile; +GTime lastMessType0; +bool sbasAlertNoSoL = false; -struct SBASSmoothControl -{ - GTime lastUpdate; - int numMea = 0; - double ambEst = 0; - double ambVar = -1; -}; - -map> smoothedMeasMap; +map> sbasUdreCov; +map usedSBASIODMap; mutex sbasMessagesMutex; map sbasMessages; @@ -194,14 +189,42 @@ void loadSBASdata(Trace& trace, GTime time, Navigation& nav) for (auto& [sat, satDat] : nav.satNavMap) { auto& sbs = satDat.currentSBAS; - for (auto it = sbs.corrUpdt.begin(); it != sbs.corrUpdt.end();) + for (auto it = sbs.slowUpdt.begin(); it != sbs.slowUpdt.end();) { - auto teph = it->first; - if ((time - teph).to_double() > MAX_SBAS_CORR_AGE) - it = sbs.corrUpdt.erase(it); + auto slowUpdt = it->second; + for (auto it2 = slowUpdt.begin(); it2 != slowUpdt.end();) + { + auto teph = it2->first; + if ((time - teph).to_double() > MAX_SBAS_CORR_AGE) + it2 = slowUpdt.erase(it2); + else + it2++; + } + + if (slowUpdt.empty()) + it = sbs.slowUpdt.erase(it); else it++; } + + for (auto it = sbs.fastUpdt.begin(); it != sbs.fastUpdt.end();) + { + auto fastUpdt = it->second; + for (auto it2 = fastUpdt.begin(); it2 != fastUpdt.end();) + { + auto teph = it2->first; + if ((time - teph).to_double() > MAX_SBAS_CORR_AGE) + it2 = fastUpdt.erase(it2); + else + it2++; + } + + if (fastUpdt.empty()) + it = sbs.fastUpdt.erase(it); + else + it++; + } + for (auto it = sbs.slowCorr.begin(); it != sbs.slowCorr.end();) { auto teph = it->second.trec; @@ -239,72 +262,33 @@ void loadSBASdata(Trace& trace, GTime time, Navigation& nav) void estimateSBASProtLvl(Vector3d& staPos, Matrix3d& ecefP, double& horPL, double& verPL) { - horPL = -1; - verPL = -1; - - switch (acsConfig.sbsInOpts.freq) - { - case 5: - estimateDFMCPL(staPos, ecefP, horPL, verPL); - return; - default: - return; - } -} + horPL = 8000; + verPL = 100; -double sbasSmoothedPsudo( - Trace& trace, - GTime time, - SatSys Sat, - string staId, - double measP, - double measL, - double variP, - double variL, - double& varSmooth, - bool update -) -{ - varSmooth = -1; - if (variP < 0 || variL < 0) - return measP; - - auto& smCtrl = smoothedMeasMap[Sat][staId]; - double fact = 1.0 / smCtrl.numMea; - if (update) - { - bool slip = false; - if ((time - smCtrl.lastUpdate).to_double() > acsConfig.sbsInOpts.smth_out) - slip = true; - if (smCtrl.ambVar < 0) - slip = true; - - double ambMea = measP - measL; - if (fabs(ambMea - smCtrl.ambEst) > 4 * sqrt(smCtrl.ambVar + variP + variL)) - slip = true; // Try replacing this with a outputs from preprocessor - - smCtrl.lastUpdate = time; - if (slip) - { - smCtrl.numMea = 0; - smCtrl.ambEst = 0; - smCtrl.ambVar = 0; - } - - smCtrl.numMea++; - if (smCtrl.numMea > acsConfig.sbsInOpts.smth_win) - smCtrl.numMea = acsConfig.sbsInOpts.smth_win; - fact = 1.0 / smCtrl.numMea; + if (sbasAlertNoSoL) + return; - smCtrl.ambEst += fact * (ambMea - smCtrl.ambEst); - smCtrl.ambVar = SQR(fact) * (variP + variL) + SQR(1 - fact) * smCtrl.ambVar; - } - varSmooth = (1 - 2 * fact) * variL + smCtrl.ambVar; - return smCtrl.ambEst + measL; + VectorPos pos = ecef2pos(staPos); + Matrix3d E; + pos2enu(pos, E.data()); + Matrix3d EP = E * ecefP; + Matrix3d enuP = EP * E.transpose(); + + double scaleH = acsConfig.sbsInOpts.prec_aproach ? 6.00 : 6.18; + double scaleV = 5.33; + double aveEN = (enuP(0, 0) + enuP(1, 1)) / 2; + double difEN = (enuP(0, 0) - enuP(1, 1)) / 2; + double covEN = enuP(0, 1); + horPL = scaleH * sqrt(aveEN + sqrt(difEN * difEN + covEN * covEN)); + verPL = scaleV * sqrt(enuP(2, 2)); } void writeSPP(string filename, Receiver& rec) { + auto& sppPos = rec.sol.sppPos; + if (sppPos.norm() < 1000) + return; + std::ofstream output(filename, std::fstream::out | std::fstream::app); if (!output.is_open()) { @@ -314,28 +298,65 @@ void writeSPP(string filename, Receiver& rec) output.seekp(0, output.end); // seek to end of file + auto& apriori = rec.aprioriPos; + auto& sppTime = rec.sol.sppTime; + VectorPos pos = ecef2pos(sppPos); + + string tstr = sppTime.to_ISOstring(3); + double tyear = sppTime.to_decYear(); + + if (apriori.norm() > 1000) + { + if (output.tellp() == 0) + tracepdeex( + 0, + output, + "\n*YYYY-MM-DDTHH:MM:SS.SSS YYYY.YYYYYYYYY X Y Z " + " HDOP VDOP GDOP - - - N ref. E ref. " + "H ref. dN dE dU dH HPL VPL " + "- - -" + ); + + VectorEnu dpos = ecef2enu(pos, sppPos - apriori); + tracepdeex( + 0, + output, + "\n %s %14.9f %14.5f %14.5f %14.5f %11.4f %11.4f %11.4f - - - %15.10f %15.10f %11.5f " + "%11.5f %11.5f %11.5f %11.5f %11.5f %11.5f - - -", + tstr, + tyear, + sppPos[0], + sppPos[1], + sppPos[2], + rec.sol.dops.hdop, + rec.sol.dops.vdop, + rec.sol.dops.gdop, + pos[0] * R2D, + pos[1] * R2D, + pos[2], + dpos[0], + dpos[1], + dpos[2], + sqrt(dpos[0] * dpos[0] + dpos[1] * dpos[1]), + rec.sol.horzPL, + rec.sol.vertPL + ); + return; + } if (output.tellp() == 0) tracepdeex( - 2, + 0, output, "\n*YYYY-MM-DDTHH:MM:SS.SSS YYYY.YYYYYYYYY X Y Z " - "HDOP VDOP GDOP - - - NLat Elong Height dN " - " dE dU dH HPL VPL - - - -" + " HDOP VDOP GDOP - - - N recv E recv H recv " + "- - - - HPL VPL - - -" ); - auto& apriori = rec.aprioriPos; - auto& sppPos = rec.sol.sppPos; - auto& sppTime = rec.sol.sppTime; - VectorPos pos = ecef2pos(apriori); - VectorEnu dpos = ecef2enu(pos, sppPos - apriori); - - string tstr = sppTime.to_ISOstring(3); - double tyear = sppTime.to_decYear(); tracepdeex( - 2, + 0, output, - "\n %s %14.9f %11.5f %11.5f %11.5f %11.4f %11.4f %11.4f - - - %15.10f %15.10f %11.5f " - "%11.5f %11.5f %11.5f %11.5f %11.5f %11.5f - - -", + "\n %s %14.9f %14.5f %14.5f %14.5f %11.4f %11.4f %11.4f - - - %15.10f %15.10f %11.5f " + "- - - - %11.5f %11.5f - - -", tstr, tyear, sppPos[0], @@ -347,11 +368,105 @@ void writeSPP(string filename, Receiver& rec) pos[0] * R2D, pos[1] * R2D, pos[2], - dpos[0], - dpos[1], - dpos[2], - sqrt(dpos[0] * dpos[0] + dpos[1] * dpos[1]), rec.sol.horzPL, rec.sol.vertPL ); } + +double rangeErrFromCov( + Trace& trace, + GTime time, + int iodp, + SatSys sat, + Vector3d& rRec, + Vector3d& rSat, + double eCov +) +{ + if (sbasUdreCov.find(iodp) == sbasUdreCov.end()) + return -1; + + if (sbasUdreCov[iodp].find(sat) == sbasUdreCov[iodp].end()) + return -1; + + if (sbasUdreCov[iodp][sat].REScale < 0) + return -1; + + if ((time - sbasUdreCov[iodp][sat].toe).to_double() > sbasUdreCov[iodp][sat].Ivalid) + return -1; + + if (rSat.norm() <= RE_WGS84 * 0.9) + return -1; + Vector3d ePos = rSat - rRec; + ePos.normalize(); + + VectorXd ePosClk = VectorXd::Zero(4); + for (int i = 0; i < 3; i++) + ePosClk[i] = ePos[i]; + ePosClk[3] = 1; + VectorXd Ce = sbasUdreCov[iodp][sat].covr * ePosClk; + double x = ePosClk.dot(Ce); + + return sqrt(x) + eCov * sbasUdreCov[iodp][sat].REScale; +} + +double checkSBASVar( + Trace& trace, + GTime time, + SatSys sat, + Vector3d& rRec, + Vector3d& rSat, + SBASMaps& sbsMaps +) +{ + if (usedSBASIODMap.find(sat) == usedSBASIODMap.end()) + return -1; + + if (fabs((time - usedSBASIODMap[sat].tUsed).to_double()) >= 0.5) + return -1; + + int iodp = usedSBASIODMap[sat].iodp; + int iodf = usedSBASIODMap[sat].iodf; + int iode = usedSBASIODMap[sat].iode; + + if (sbsMaps.fastUpdt.find(iodp) == sbsMaps.fastUpdt.end()) + return -2; + + if (sbsMaps.fastCorr.find(iodf) == sbsMaps.fastCorr.end()) + return -2; + + auto& sbsFast = sbsMaps.fastCorr[iodf]; + if (sbsFast.iodp != iodp) + return -2; + + switch (acsConfig.sbsInOpts.freq) + { + case 1: + if (sbsMaps.slowCorr.find(iode) == sbsMaps.slowCorr.end()) + return -2; + return estimateSBASVar(trace, time, sat, rRec, rSat, sbsFast, sbsMaps.slowCorr[iode]); + case 5: + return estimateDFMCVar(trace, time, sat, rRec, rSat, sbsFast); + } + + return -2; +} + +void checkForType0(GTime time, int type) +{ + if (type < 0) + { + lastMessType0 = time; + sbasAlertNoSoL = true; + for (auto& [sat, satDat] : nav.satNavMap) + { + auto& sbs = satDat.currentSBAS; + sbs.fastUpdt.clear(); + sbs.slowUpdt.clear(); + sbs.fastCorr.clear(); + sbs.slowCorr.clear(); + } + } + else if ((time - lastMessType0).to_double() > 10) + sbasAlertNoSoL = false; +} \ No newline at end of file diff --git a/src/cpp/sbas/sbas.hpp b/src/cpp/sbas/sbas.hpp index ec10e248f..36a44ab89 100644 --- a/src/cpp/sbas/sbas.hpp +++ b/src/cpp/sbas/sbas.hpp @@ -13,6 +13,8 @@ struct Receiver; struct SatSys; struct SatPos; +#define MAX_SBAS_MESS_AGE 600 + struct SBASMessage { int prn = -1; @@ -24,14 +26,25 @@ struct SBASMessage struct SBASFast { - GTime toe; + GTime tFast; + int iodp; double dClk; - double var; + double ddClk = 0; + int dIODF = -1; + double dt = 0; + double IValid = -1; + double accDegr = 10; + + GTime tIntg; + int REint; + bool REBoost = false; + double dRcorr = 1.0; }; struct SBASSlow { GTime toe; + int iodp; int iode; Vector4d dPos; Vector4d ddPos; @@ -40,75 +53,22 @@ struct SBASSlow bool pvsEnabled = false; }; -struct SBASRegn -{ - int priority; - double Lat1; - double Lat2; - double Lon1; - double Lon2; - bool triangular = false; - double in_Factor; - double outFactor; -}; - -struct SBASDegrL1 -{ - double Brrc; - double Cltc_lsb; - double Cltc_v1; - double Iltc_v1; - double Cltc_v0; - double Iltc_v0; - double Cgeo_lsb; - double Cgeo_v; - double Igeo_v; - double Cer; - double Ciono_step; - double Ciono_ramp; - double Iiono; - bool RSSudre; - bool RSSiono; - double Ccovar; -}; - -struct SBASDegrSys +struct SBASCova { - double Icorr; - double Ccorr; - double Rcorr; -}; -struct SBASDegrL5 -{ - double IValidGNSS = -1; - double IValidGEO; - double CER; - double Ccov; - map sysDegr; - map DFREtable; - int type = 0; -}; - -struct SBASIntg -{ - GTime trec; - int REint; - bool REBoost = false; - - double REScale; + GTime toe; + double Ivalid = 240; + double REScale = -1; MatrixXd covr; - double dRcorr; }; +extern map> sbasUdreCov; struct SBASMaps { - map> corrUpdt; + map>> fastUpdt; // fastUpdt[IODP][time] = IODF + map>> slowUpdt; // slowUpdt[IODP][time] = IODE - map slowCorr; - map Integrity; - - map fastCorr; - map UDRERegn; + map fastCorr; // fastCorr[IODF] = SBASFast + map slowCorr; // slowCorr[IODE] = SBASSlow }; struct SBASIono @@ -121,7 +81,15 @@ struct SBASIono extern mutex sbasMessagesMutex; extern map sbasMessages; -extern Vector3d sbasRoughStaPos; + +struct usedSBASIODs +{ + int iodp = -1; + int iodf = -1; + int iode = -1; + GTime tUsed; +}; +extern map usedSBASIODMap; void writeEMSdata(Trace& trace, string emsfile); @@ -135,23 +103,48 @@ void decodeDFMCMessage(Trace& trace, GTime time, SBASMessage& mess, Navigation& GTime adjustDay(double tod1, GTime nearTime); -double estimateDFMCVar(Trace& trace, GTime time, SatPos& satPos, SBASIntg& sbsIntg); - void estimateSBASProtLvl(Vector3d& staPos, Matrix3d& ecefP, double& horPL, double& verPL); -void estimateDFMCPL(Vector3d& staPos, Matrix3d& ecefP, double& horPL, double& verPL); - -double sbasSmoothedPsudo( - Trace& trace, - GTime time, - SatSys Sat, - string staId, - double measP, - double measL, - double variP, - double variL, - double& varSmooth, - bool update +void writeSPP(string filename, Receiver& rec); + +double rangeErrFromCov( + Trace& trace, + GTime time, + int iodp, + SatSys sat, + Vector3d& rRec, + Vector3d& rSat, + double eCov +); + +double estimateIonoVar(GTime time, GTime givdTime, double sigGIVE); + +double estimateDFMCVar( + Trace& trace, + GTime time, + SatSys sat, + Vector3d& rRec, + Vector3d& rSat, + SBASFast& sbsFast +); + +double estimateSBASVar( + Trace& trace, + GTime time, + SatSys sat, + Vector3d& rRec, + Vector3d& rSat, + SBASFast& sbsFast, + SBASSlow& sbasSlow +); + +double checkSBASVar( + Trace& trace, + GTime time, + SatSys sat, + Vector3d& Rrec, + Vector3d& Rsat, + SBASMaps& sbsMaps ); -void writeSPP(string filename, Receiver& rec); \ No newline at end of file +void checkForType0(GTime time, int type); \ No newline at end of file diff --git a/src/cpp/sbas/sisnet.cpp b/src/cpp/sbas/sisnet.cpp index 02abb1e19..18e85ce77 100644 --- a/src/cpp/sbas/sisnet.cpp +++ b/src/cpp/sbas/sisnet.cpp @@ -9,8 +9,6 @@ namespace bp = boost::asio::placeholders; using std::lock_guard; using std::mutex; -#define MAX_SBAS_MESS_AGE 600 - #define CLEAN_UP_AND_RETURN_ON_FAILURE \ \ if (inputStream.fail()) \