diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 101d69530..4170eb223 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -160,7 +160,6 @@ jobs: os: ubuntu-latest binary-artifact: ginan-linux-x64 gui-artifact: ginan-gui-linux-x64 - ui-sed-cmd: sed -i '24s/.*/from scripts.GinanUI.app.resources import ginan_logo_rc/' app/views/main_window_ui.py pyinstaller-args: --windowed extra-steps: "" @@ -168,7 +167,6 @@ jobs: os: macos-15 binary-artifact: ginan-macos-arm64 gui-artifact: ginan-gui-macos-arm64 - ui-sed-cmd: sed -i '' '24s/.*/from scripts.GinanUI.app.resources import ginan_logo_rc/' app/views/main_window_ui.py pyinstaller-args: --onedir --clean --target-arch arm64 --noconfirm extra-steps: macos @@ -176,7 +174,6 @@ jobs: os: macos-15-intel binary-artifact: ginan-macos-x64 gui-artifact: ginan-gui-macos-x64 - ui-sed-cmd: sed -i '' '24s/.*/from scripts.GinanUI.app.resources import ginan_logo_rc/' app/views/main_window_ui.py pyinstaller-args: --onedir --clean --noconfirm extra-steps: macos @@ -184,14 +181,6 @@ jobs: os: windows-latest binary-artifact: ginan-windows-x64 gui-artifact: ginan-gui-windows-x64 - ui-sed-cmd: | - (Get-Content app/views/main_window_ui.py) | ForEach-Object { - if ($_.ReadCount -eq 24) { - "from scripts.GinanUI.app.resources import ginan_logo_rc" - } else { - $_ - } - } | Set-Content app/views/main_window_ui.py pyinstaller-args: --windowed pyinstaller-separator: ";" extra-steps: "" @@ -221,20 +210,9 @@ jobs: pip install -r scripts/GinanUI/requirements.txt pip install pyinstaller - - name: Convert UI files to Python (Unix) - if: runner.os != 'Windows' + - name: Convert UI files to Python working-directory: scripts/GinanUI - run: | - pyside6-uic app/views/main_window.ui -o app/views/main_window_ui.py - ${{ matrix.ui-sed-cmd }} - - - name: Convert UI files to Python (Windows) - if: runner.os == 'Windows' - working-directory: scripts/GinanUI - shell: pwsh - run: | - pyside6-uic app/views/main_window.ui -o app/views/main_window_ui.py - ${{ matrix.ui-sed-cmd }} + run: python app/utils/ui_compilation.py - name: Make binaries executable (Unix) if: runner.os != 'Windows' @@ -292,14 +270,17 @@ jobs: run: | python -m PyInstaller --name GinanUI \ ${{ matrix.pyinstaller-args }} \ - --add-data "scripts/GinanUI/app:app" \ + --add-data "scripts/GinanUI/app:scripts/GinanUI/app" \ + --add-data "scripts/GinanUI/docs:scripts/GinanUI/docs" \ --add-data "scripts/plot_pos.py:scripts" \ + --add-data "scripts/plot_trace_res.py:scripts" \ --add-binary "bin/*:bin" \ --hidden-import PySide6 \ --hidden-import PySide6.QtWebEngineWidgets \ --hidden-import PySide6.QtWebEngineCore \ --hidden-import plotly \ --hidden-import scripts.plot_pos \ + --hidden-import scripts.plot_trace_res \ --hidden-import scripts.GinanUI.app \ --hidden-import scripts.GinanUI.app.models \ --hidden-import scripts.GinanUI.app.models.execution \ @@ -314,7 +295,6 @@ jobs: --hidden-import scripts.GinanUI.app.utils.gn_functions \ --hidden-import scripts.GinanUI.app.utils.yaml \ --hidden-import scripts.GinanUI.app.views.main_window_ui \ - --collect-all scripts.GinanUI \ scripts/GinanUI/main.py - name: Build GUI with PyInstaller (Windows) @@ -322,14 +302,17 @@ jobs: run: | pyinstaller --name GinanUI ` ${{ matrix.pyinstaller-args }} ` - --add-data "scripts/GinanUI/app;app" ` + --add-data "scripts/GinanUI/app;scripts/GinanUI/app" ` + --add-data "scripts/GinanUI/docs;scripts/GinanUI/docs" ` --add-data "scripts/plot_pos.py;scripts" ` + --add-data "scripts/plot_trace_res.py;scripts" ` --add-binary "bin/*;bin" ` --hidden-import PySide6 ` --hidden-import PySide6.QtWebEngineWidgets ` --hidden-import PySide6.QtWebEngineCore ` --hidden-import plotly ` --hidden-import scripts.plot_pos ` + --hidden-import scripts.plot_trace_res ` --hidden-import scripts.GinanUI.app ` --hidden-import scripts.GinanUI.app.models ` --hidden-import scripts.GinanUI.app.models.execution ` @@ -344,7 +327,6 @@ jobs: --hidden-import scripts.GinanUI.app.utils.gn_functions ` --hidden-import scripts.GinanUI.app.utils.yaml ` --hidden-import scripts.GinanUI.app.views.main_window_ui ` - --collect-all scripts.GinanUI ` scripts/GinanUI/main.py # Post-build cleanup diff --git a/Docs/announcements.md b/Docs/announcements.md index 24c1ad9cf..a27126ae3 100644 --- a/Docs/announcements.md +++ b/Docs/announcements.md @@ -1,4 +1,18 @@ +> **13 Feb 2026** - the Ginan team is pleased to release Ginan patch v4.1.1 +> +> **Highlights**: +> +> * Fix issue reading RINEX files in Windows binaries (CRLF-ended format) +> * Allow station / receivers names to start with a number in config (e.g. 4RMA00AUS) +> * Introduce reading of GLONASS satellites for RNX2 files +> * Various updates to the GUI: +> * Apriori position as config option in interface +> * Ability to run faster rate clocks (1 Hz -> 100 Hz) +> * SINEX downloading and validation +> * Verify downloads against CDDIS checksums +> * Use archived products if available + > **30 Jan 2026** - the Ginan team is pleased to release Ginan update v4.1.0 > > **Highlights**: diff --git a/README.md b/README.md index 67ed01328..2c3b49367 100755 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Ginan: GNSS Analysis Software Toolkit -[![Version](https://img.shields.io/badge/version-v4.1.0-blue.svg)](https://github.com/GeoscienceAustralia/ginan/releases) +[![Version](https://img.shields.io/badge/version-v4.1.1-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.1.0 bash +docker run -it -v $(pwd):/data gnssanalysis/ginan:v4.1.1 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.1.0 bash +docker run -it -v ${pwd}:/data gnssanalysis/ginan:v4.1.1 bash ``` This command: @@ -294,7 +294,7 @@ cd ../../exampleConfigs Expected output: ``` -PEA starting... (main ginan-v4.1.0 from ...) +PEA starting... (main ginan-v4.1.1 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.1.0** | **[GitHub Repository](https://github.com/GeoscienceAustralia/ginan)** +**Developed by [Geoscience Australia](https://www.ga.gov.au/)** | **Version 4.1.1** | **[GitHub Repository](https://github.com/GeoscienceAustralia/ginan)** diff --git a/scripts/GinanUI/app/controllers/input_controller.py b/scripts/GinanUI/app/controllers/input_controller.py index 662dc7041..62f3e6118 100644 --- a/scripts/GinanUI/app/controllers/input_controller.py +++ b/scripts/GinanUI/app/controllers/input_controller.py @@ -4,6 +4,7 @@ """ from __future__ import annotations +import math import os import re import subprocess @@ -31,6 +32,7 @@ QDialog, QFormLayout, QDoubleSpinBox, + QSpinBox, QHBoxLayout, QVBoxLayout, QDateTimeEdit, @@ -53,7 +55,7 @@ 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, BiasProductWorker +from scripts.GinanUI.app.utils.workers import DownloadWorker, BiasProductWorker, SinexValidationWorker from scripts.GinanUI.app.utils.toast import show_toast @@ -121,6 +123,10 @@ def __init__(self, ui, parent_window, execution: Execution): self.ui.antennaOffsetButton.setCursor(Qt.CursorShape.PointingHandCursor) self.ui.antennaOffsetValue.setText("0.0, 0.0, 0.0") + # Apriori position + self.ui.aprioriPositionButton.clicked.connect(self._open_apriori_position_dialog) + self.ui.aprioriPositionButton.setCursor(Qt.CursorShape.PointingHandCursor) + # Time window and data interval self.ui.timeWindowButton.clicked.connect(self._open_time_window_dialog) self.ui.timeWindowButton.setCursor(Qt.CursorShape.PointingHandCursor) @@ -156,6 +162,12 @@ def __init__(self, ui, parent_window, execution: Execution): self._bia_worker = None self._bia_thread = None + # SINEX validation worker tracking + self._sinex_worker = None + self._sinex_thread = None + self._sinex_path = None + self._sinex_filename = None # Stored until apply_ui_config() is called + # Connect tab change signal to trigger BIA fetch when switching to Constellations tab self.ui.configTabWidget.currentChanged.connect(self._on_config_tab_changed) @@ -298,6 +310,12 @@ def setup_tooltips(self): "Click to modify if needed" ) + self.ui.aprioriPositionButton.setToolTip( + "Approximate receiver position in ECEF coordinates (X, Y, Z) in metres\n" + "Typically extracted from RINEX header\n" + "Click to modify if needed" + ) + # Value display labels self.ui.receiverTypeValue.setToolTip("Receiver type from RINEX header") self.ui.antennaTypeValue.setToolTip("Antenna type from RINEX header") @@ -455,11 +473,19 @@ def load_rnx_file(self) -> ExtractedInputs | None: self.ui.timeWindowValue.setText(f"{result['start_epoch']} to {result['end_epoch']}") self.ui.timeWindowButton.setText(f"{result['start_epoch']} to {result['end_epoch']}") self.ui.dataIntervalButton.setText(f"{result['epoch_interval']} s") + self.rinex_epoch_interval = float(result['epoch_interval']) self.ui.receiverTypeValue.setText(result["receiver_type"]) self.ui.antennaTypeValue.setText(result["antenna_type"]) self.ui.antennaOffsetValue.setText(", ".join(map(str, result["antenna_offset"]))) self.ui.antennaOffsetButton.setText(", ".join(map(str, result["antenna_offset"]))) + # Populate apriori position if available + apriori = result.get("apriori_position") + if apriori and any(v != 0.0 for v in apriori): + self.ui.aprioriPositionButton.setText(", ".join(map(str, apriori))) + else: + self.ui.aprioriPositionButton.setText("0.0, 0.0, 0.0") + self.ui.receiverTypeCombo.clear() self.ui.receiverTypeCombo.addItem(result["receiver_type"]) self.ui.receiverTypeCombo.setCurrentIndex(0) @@ -482,6 +508,16 @@ def load_rnx_file(self) -> ExtractedInputs | None: self.ui.outputButton.setEnabled(True) self.ui.showConfigButton.setEnabled(True) + # Start SINEX validation in background + self._start_sinex_validation( + target_date=start_epoch, + marker_name=result.get("marker_name", ""), + receiver_type=result.get("receiver_type", ""), + antenna_type=result.get("antenna_type", ""), + antenna_offset=result.get("antenna_offset", [0.0, 0.0, 0.0]), + apriori_position=result.get("apriori_position"), + ) + except Exception as e: Logger.terminal(f"Error extracting RNX metadata: {e}") return None @@ -1394,6 +1430,178 @@ def _show_bia_warning(self, show: bool): if hasattr(self, '_bia_warning_label'): self._bia_warning_label.setVisible(show) + #region SINEX Validation + + def _start_sinex_validation(self, target_date: datetime, marker_name: str, receiver_type: str, antenna_type: str, + antenna_offset: list, apriori_position: list = None): + """ + Start SINEX validation in a background thread. + + Arguments: + target_date (datetime): Date for which to download the SINEX file + marker_name (str): 4-character marker name from RINEX + receiver_type (str): Receiver type from RINEX + antenna_type (str): Antenna type from RINEX + antenna_offset (list): Antenna offset [E, N, U] from RINEX + apriori_position (list): Optional apriori position [X, Y, Z] from RINEX + """ + if not marker_name or len(marker_name) < 4: + Logger.terminal("âš ī¸ Invalid marker name - SINEX validation skipped") + return + + # Stop any existing SINEX worker + self._stop_sinex_worker() + + Logger.terminal(f"📋 Starting SINEX validation for marker '{marker_name[:4]}'...") + + # Create worker and thread + self._sinex_worker = SinexValidationWorker( + target_date=target_date, + marker_name=marker_name[:4], # Use first 4 characters + receiver_type=receiver_type, + antenna_type=antenna_type, + antenna_offset=antenna_offset, + apriori_position=apriori_position, + ) + self._sinex_thread = QThread() + self._sinex_worker.moveToThread(self._sinex_thread) + + # Connect signals + self._sinex_worker.finished.connect(self._on_sinex_validation_finished) + self._sinex_worker.error.connect(self._on_sinex_validation_error) + self._sinex_worker.progress.connect(self._on_sinex_validation_progress) + + self._sinex_thread.started.connect(self._sinex_worker.run) + self._sinex_worker.finished.connect(self._sinex_thread.quit) + self._sinex_worker.error.connect(self._sinex_thread.quit) + self._sinex_thread.finished.connect(self._on_sinex_thread_finished) + + self._sinex_thread.start() + + def _stop_sinex_worker(self): + """ + Stop any running SINEX validation worker and clean up thread resources. + """ + if self._sinex_worker is not None: + # Disconnect signals to prevent callbacks after cleanup + try: + self._sinex_worker.finished.disconnect() + self._sinex_worker.error.disconnect() + self._sinex_worker.progress.disconnect() + except (RuntimeError, TypeError): + pass + # Signal the worker to stop + self._sinex_worker.stop() + + if self._sinex_thread is not None: + # Disconnect thread signals + try: + self._sinex_thread.started.disconnect() + self._sinex_thread.finished.disconnect() + except (RuntimeError, TypeError): + pass + + if self._sinex_thread.isRunning(): + self._sinex_thread.quit() + if not self._sinex_thread.wait(2000): + Logger.console("âš ī¸ SINEX thread did not stop gracefully, forcing termination") + self._sinex_thread.terminate() + self._sinex_thread.wait(1000) + + # Clean up references + self._sinex_worker = None + self._sinex_thread = None + + def _on_sinex_validation_progress(self, description: str, percent: int): + """ + UI handler: update progress bar during SINEX download. + + Arguments: + description (str): Progress description (filename) + percent (int): Progress percentage (0-100) + """ + # Update the progress bar in the UI (same as product downloads) + if hasattr(self.ui, 'progressBar'): + self.ui.progressBar.setValue(percent) + self.ui.progressBar.setFormat(f"đŸ“Ĩ {description}: {percent}%") + + def _on_sinex_validation_finished(self, sinex_path, validation_results: dict): + """ + UI handler: SINEX validation completed. + + Arguments: + sinex_path (Path): Path to the downloaded SINEX file + validation_results (dict): Validation results dictionary + """ + self._sinex_path = sinex_path + + # Store the SINEX filename for later use in apply_ui_config() + # (config writing only happens when apply_ui_config is called) + if sinex_path is not None: + self._sinex_filename = sinex_path.name + + # Reset progress bar + if hasattr(self.ui, 'progressBar'): + self.ui.progressBar.setValue(0) + self.ui.progressBar.setFormat("") + + # Check validation results and show appropriate toast + if 'error' in validation_results: + show_toast(self.parent, f"âš ī¸ SINEX validation error: {validation_results['error']}", duration=5000) + return + + if not validation_results.get('marker_found', False): + show_toast(self.parent, f"â„šī¸ Marker not found in SINEX file", duration=3000) + return + + # Apply SINEX apriori_position to UI if available (SINEX is more accurate than RINEX) + apriori_result = validation_results.get('apriori_position', {}) + sinex_position = apriori_result.get('sinex_value') + if sinex_position is not None and len(sinex_position) == 3: + # Update the UI with SINEX coordinates + position_str = ", ".join(str(v) for v in sinex_position) + self.ui.aprioriPositionButton.setText(position_str) + + # Check if all validations passed + all_valid = True + has_validations = False + for field in ['receiver_type', 'antenna_type', 'antenna_offset', 'apriori_position']: + field_result = validation_results.get(field, {}) + if field_result.get('valid') is True: + has_validations = True + elif field_result.get('valid') is False: + all_valid = False + has_validations = True + + if has_validations: + if all_valid: + show_toast(self.parent, "✅ SINEX validation passed", duration=3000) + else: + show_toast(self.parent, "âš ī¸ SINEX validation warnings - check terminal", duration=5000) + + def _on_sinex_validation_error(self, error_msg: str): + """ + UI handler: SINEX validation failed. + + Arguments: + error_msg (str): Error message describing the failure + """ + Logger.terminal(f"âš ī¸ SINEX validation error: {error_msg}") + + # Don't show toast for cancelled operations + if "cancelled" not in error_msg.lower(): + show_toast(self.parent, f"âš ī¸ SINEX validation failed: {error_msg}", duration=5000) + + def _on_sinex_thread_finished(self): + """ + Slot called when the SINEX thread has fully finished. + Safe to clean up references here. + """ + self._sinex_worker = None + self._sinex_thread = None + + # endregion + def _on_analysis_thread_finished(self): """ Slot called when the analysis thread has fully finished. @@ -1714,54 +1922,90 @@ def _ask_antenna_type(): # ========================================================== def _open_antenna_offset_dialog(self): """ - UI handler: open antenna offset dialog (E, N, U) with high-precision spin boxes. + UI handler: open antenna offset dialog (E, N, U) with text input fields. """ dlg = QDialog(self.ui.antennaOffsetButton) dlg.setWindowTitle("Antenna Offset") # Parse existing "E, N, U" try: - e0, n0, u0 = [float(x.strip()) for x in self.ui.antennaOffsetValue.text().split(",")] + e0, n0, u0 = [x.strip() for x in self.ui.antennaOffsetValue.text().split(",")] except Exception: - e0 = n0 = u0 = 0.0 + e0 = n0 = u0 = "0.0" form = QFormLayout(dlg) - class DecimalSpinBox(QDoubleSpinBox): - def __init__(self, parent=None, top=10000, bottom=-10000, precision=15, step_size=0.1): - super().__init__(parent) - self.setRange(bottom, top) + edit_e = QLineEdit(str(e0), dlg) + edit_n = QLineEdit(str(n0), dlg) + edit_u = QLineEdit(str(u0), dlg) - self.setDecimals(precision) # fallback precision - # up down arrow Step size - # note there is some float point inaccuracy when useing steps - self.setSingleStep(step_size) + form.addRow("E:", edit_e) + form.addRow("N:", edit_n) + form.addRow("U:", edit_u) - def textFromValue(self, value: float) -> str: - """Format value dynamically with Decimal for more precision""" - # Convert through Decimal to avoid scientific notation - d = Decimal(str(value)) - return str(d.normalize()) # trims trailing zeros + btn_row = QHBoxLayout() + ok_btn = QPushButton("OK", dlg) + cancel_btn = QPushButton("Cancel", dlg) + btn_row.addWidget(ok_btn) + btn_row.addWidget(cancel_btn) + form.addRow(btn_row) - def valueFromText(self, text: str) -> float: - """Parse text back into a float""" - try: - return float(Decimal(text)) - except InvalidOperation: - raise ValueError(f"Failed to convert Antenna offset to float: {text}") + ok_btn.clicked.connect(lambda: self._set_antenna_offset(edit_e, edit_n, edit_u, dlg)) + cancel_btn.clicked.connect(dlg.reject) + + dlg.setMinimumWidth(300) + dlg.setFixedHeight(dlg.sizeHint().height()) + + dlg.exec() + + def _set_antenna_offset(self, edit_e, edit_n, edit_u, dlg: QDialog): + """ + UI handler: apply antenna offset values back to UI. + + Arguments: + edit_e (QLineEdit): East (E) input field. + edit_n (QLineEdit): North (N) input field. + edit_u (QLineEdit): Up (U) input field. + dlg (QDialog): Dialog to accept/close. + """ + try: + e = float(edit_e.text().strip()) + n = float(edit_n.text().strip()) + u = float(edit_u.text().strip()) + except ValueError: + QMessageBox.warning(dlg, "Invalid input", "Please enter valid numeric values.") + return + + text = f"{e}, {n}, {u}" + self.ui.antennaOffsetButton.setText(text) + self.ui.antennaOffsetValue.setText(text) + dlg.accept() - sb_e = DecimalSpinBox(dlg) - sb_e.setValue(e0) + # ========================================================== + # Apriori position popup + # ========================================================== + def _open_apriori_position_dialog(self): + """ + UI handler: open apriori position dialog (X, Y, Z) with text input fields. + """ + dlg = QDialog(self.ui.aprioriPositionButton) + dlg.setWindowTitle("Apriori Position (ECEF)") + + # Parse existing "X, Y, Z" + try: + x0, y0, z0 = [x.strip() for x in self.ui.aprioriPositionButton.text().split(",")] + except Exception: + x0 = y0 = z0 = "0.0" - sb_n = DecimalSpinBox(dlg) - sb_n.setValue(n0) + form = QFormLayout(dlg) - sb_u = DecimalSpinBox(dlg) - sb_u.setValue(u0) + edit_x = QLineEdit(str(x0), dlg) + edit_y = QLineEdit(str(y0), dlg) + edit_z = QLineEdit(str(z0), dlg) - form.addRow("E:", sb_e) - form.addRow("N:", sb_n) - form.addRow("U:", sb_u) + form.addRow("X:", edit_x) + form.addRow("Y:", edit_y) + form.addRow("Z:", edit_z) btn_row = QHBoxLayout() ok_btn = QPushButton("OK", dlg) @@ -1770,25 +2014,34 @@ def valueFromText(self, text: str) -> float: btn_row.addWidget(cancel_btn) form.addRow(btn_row) - ok_btn.clicked.connect(lambda: self._set_antenna_offset(sb_e, sb_n, sb_u, dlg)) + ok_btn.clicked.connect(lambda: self._set_apriori_position(edit_x, edit_y, edit_z, dlg)) cancel_btn.clicked.connect(dlg.reject) + dlg.setMinimumWidth(300) + dlg.setFixedHeight(dlg.sizeHint().height()) + dlg.exec() - def _set_antenna_offset(self, sb_e, sb_n, sb_u, dlg: QDialog): + def _set_apriori_position(self, edit_x, edit_y, edit_z, dlg: QDialog): """ - UI handler: apply antenna offset values back to UI. + UI handler: apply apriori position values back to UI. Arguments: - sb_e (QDoubleSpinBox): East (E) spin box. - sb_n (QDoubleSpinBox): North (N) spin box. - sb_u (QDoubleSpinBox): Up (U) spin box. + edit_x (QLineEdit): X coordinate input field. + edit_y (QLineEdit): Y coordinate input field. + edit_z (QLineEdit): Z coordinate input field. dlg (QDialog): Dialog to accept/close. """ - e, n, u = sb_e.value(), sb_n.value(), sb_u.value() - text = f"{e}, {n}, {u}" - self.ui.antennaOffsetButton.setText(text) - self.ui.antennaOffsetValue.setText(text) + try: + x = float(edit_x.text().strip()) + y = float(edit_y.text().strip()) + z = float(edit_z.text().strip()) + except ValueError: + QMessageBox.warning(dlg, "Invalid input", "Please enter valid numeric values.") + return + + text = f"{x}, {y}, {z}" + self.ui.aprioriPositionButton.setText(text) dlg.accept() # ========================================================== @@ -1798,8 +2051,8 @@ def _open_time_window_dialog(self): """ UI handler: open dialog to adjust observation start/end times. """ - dlg = QDialog(self.ui.timeWindowValue) - dlg.setWindowTitle("Select start / end time") + dlg = QDialog(self.ui.timeWindowButton) + dlg.setWindowTitle("Time Window") # Parse existing "yyyy-MM-dd_HH:mm:ss to yyyy-MM-dd_HH:mm:ss" current_text = self.ui.timeWindowButton.text() @@ -1814,7 +2067,8 @@ def _open_time_window_dialog(self): except Exception: s_dt = e_dt = QDateTime.currentDateTime() - vbox = QVBoxLayout(dlg) + form = QFormLayout(dlg) + start_edit = QDateTimeEdit(s_dt, dlg) end_edit = QDateTimeEdit(e_dt, dlg) @@ -1822,20 +2076,25 @@ def _open_time_window_dialog(self): end_edit.setCalendarPopup(True) start_edit.setDisplayFormat("yyyy-MM-dd_HH:mm:ss") end_edit.setDisplayFormat("yyyy-MM-dd_HH:mm:ss") + start_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + end_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - vbox.addWidget(start_edit) - vbox.addWidget(end_edit) + form.addRow("Start:", start_edit) + form.addRow("End:", end_edit) btn_row = QHBoxLayout() ok_btn = QPushButton("OK", dlg) cancel_btn = QPushButton("Cancel", dlg) btn_row.addWidget(ok_btn) btn_row.addWidget(cancel_btn) - vbox.addLayout(btn_row) + form.addRow(btn_row) ok_btn.clicked.connect(lambda: self._set_time_window(start_edit, end_edit, dlg)) cancel_btn.clicked.connect(dlg.reject) + dlg.setMinimumWidth(300) + dlg.setFixedHeight(dlg.sizeHint().height()) + dlg.exec() def _set_time_window(self, start_edit, end_edit, dlg: QDialog): @@ -1863,27 +2122,57 @@ def _set_time_window(self, start_edit, end_edit, dlg: QDialog): # ========================================================== def _open_data_interval_dialog(self): """ - UI handler: prompt for data interval (seconds) and update UI. + UI handler: open dialog to adjust data interval (seconds). """ - # Extract current value from button text ("30 s" → 30) + dlg = QDialog(self.ui.dataIntervalButton) + dlg.setWindowTitle("Data Interval") + + # Extract current value from button text ("30 s" → 30, "0.50 s" → 0.5) current_text = self.ui.dataIntervalButton.text().replace(" s", "").strip() try: - current_val = int(current_text) + current_val = float(current_text) except ValueError: - current_val = 1 # fallback if parsing fails - - val, ok = QInputDialog.getInt( - self.ui.dataIntervalButton, - "Data interval", - "Input interval (seconds):", - current_val, # prefill with current value - 1, - 999_999, - ) - if ok: - text = f"{val} s" - self.ui.dataIntervalButton.setText(text) - self.ui.dataIntervalValue.setText(text) + current_val = 1.0 # fallback if parsing fails + + form = QFormLayout(dlg) + + interval_spin = QDoubleSpinBox(dlg) + interval_spin.setRange(0.01, 999999.99) + interval_spin.setDecimals(2) + interval_spin.setValue(current_val) + interval_spin.setSuffix(" s") + interval_spin.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + form.addRow("Interval:", interval_spin) + + btn_row = QHBoxLayout() + ok_btn = QPushButton("OK", dlg) + cancel_btn = QPushButton("Cancel", dlg) + btn_row.addWidget(ok_btn) + btn_row.addWidget(cancel_btn) + form.addRow(btn_row) + + ok_btn.clicked.connect(lambda: self._set_data_interval(interval_spin, dlg)) + cancel_btn.clicked.connect(dlg.reject) + + dlg.setMinimumWidth(300) + dlg.setFixedHeight(dlg.sizeHint().height()) + + dlg.exec() + + def _set_data_interval(self, interval_spin, dlg: QDialog): + """ + UI handler: apply data interval value back to UI. + + Arguments: + interval_spin (QDoubleSpinBox): Interval spin box. + dlg (QDialog): Dialog to accept/close. + """ + val = interval_spin.value() + text = f"{int(val)} s" if val == int(val) else f"{val:.2f} s" + self.ui.dataIntervalButton.setText(text) + self.ui.dataIntervalValue.setText(text) + dlg.accept() # endregion @@ -1919,6 +2208,7 @@ 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 + apriori_position_raw = self.ui.aprioriPositionButton.text() # Get from button, not value label 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 "" @@ -1929,7 +2219,8 @@ def extract_ui_values(self, rnx_path): # Parsed values start_epoch, end_epoch = self.parse_time_window(time_window_raw) antenna_offset = self.parse_antenna_offset(antenna_offset_raw) - epoch_interval = int(epoch_interval_raw.replace("s", "").strip()) + apriori_position = self.parse_apriori_position(apriori_position_raw) + epoch_interval = float(epoch_interval_raw.replace("s", "").strip()) marker_name = self.extract_marker_name(rnx_path) mode = self.determine_mode_value(mode_raw) @@ -1944,7 +2235,9 @@ def extract_ui_values(self, rnx_path): start_epoch=start_epoch, end_epoch=end_epoch, epoch_interval=epoch_interval, + rinex_epoch_interval=getattr(self, 'rinex_epoch_interval', epoch_interval), antenna_offset=antenna_offset, + apriori_position=apriori_position, mode=mode, constellations_raw=constellations_raw, receiver_type=receiver_type, @@ -1962,6 +2255,7 @@ def extract_ui_values(self, rnx_path): gpx_output=gpx_output, pos_output=pos_output, trace_output_network=trace_output_network, + sinex_filename=self._sinex_filename, ) def _extract_observation_codes(self) -> dict: @@ -2272,6 +2566,9 @@ def reset_ui_to_defaults(self): self.ui.antennaOffsetButton.setText("0.0, 0.0, 0.0") self.ui.antennaOffsetValue.setText("0.0, 0.0, 0.0") + # Apriori position - reset to default + self.ui.aprioriPositionButton.setText("0.0, 0.0, 0.0") + # PPP Provider - reset to placeholder self.ui.pppProviderCombo.clear() self.ui.pppProviderCombo.addItem("Select one") @@ -2433,7 +2730,7 @@ def extract_marker_name(rnx_path: str) -> str: if not rnx_path: return "TEST" stem = Path(rnx_path).stem # drops .gz/.rnx - m = re.match(r"([A-Za-z]{4})", stem) + m = re.match(r"([A-Za-z0-9]{4})", stem) return m.group(1).upper() if m else "TEST" @staticmethod @@ -2482,6 +2779,27 @@ def parse_antenna_offset(antenna_offset_raw: str): except ValueError: raise ValueError("Invalid antenna offset format. Expected: 'e, n, u'") + @staticmethod + def parse_apriori_position(apriori_position_raw: str): + """ + Convert 'x, y, z' string into [x, y, z] floats. + + Arguments: + apriori_position_raw (str): e.g., '2765120.6553, -4449249.8563, -3626405.2770'. + + Returns: + list[float]: [x, y, z] in metres (ECEF coordinates). + + Example: + >>> parse_apriori_position("2765120.6553, -4449249.8563, -3626405.2770") + [2765120.6553, -4449249.8563, -3626405.2770] + """ + try: + x, y, z = map(str.strip, apriori_position_raw.split(",")) + return [float(x), float(y), float(z)] + except ValueError: + raise ValueError("Invalid apriori position format. Expected: 'x, y, z'") + @dataclass class ExtractedInputs: """ @@ -2491,8 +2809,10 @@ class ExtractedInputs: marker_name: str start_epoch: str end_epoch: str - epoch_interval: int + epoch_interval: float + rinex_epoch_interval: float antenna_offset: list[float] + apriori_position: list[float] mode: int # Raw strings / controls that are needed downstream @@ -2519,6 +2839,8 @@ class ExtractedInputs: pos_output: bool = True trace_output_network: bool = False + sinex_filename: str = None + # endregion # region Statics @@ -2670,6 +2992,9 @@ def stop_all(self): # 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() + # Stop SINEX validation worker if running + if hasattr(self, "_stop_sinex_worker"): + self._stop_sinex_worker() # Restore cursor when stopping if hasattr(self, "parent"): self.parent.setCursor(Qt.CursorShape.ArrowCursor) diff --git a/scripts/GinanUI/app/main_window.py b/scripts/GinanUI/app/main_window.py index f331c9225..303b4b3af 100644 --- a/scripts/GinanUI/app/main_window.py +++ b/scripts/GinanUI/app/main_window.py @@ -167,6 +167,7 @@ def _set_processing_state(self, processing: bool): self.ui.receiverTypeCombo.setEnabled(enabled) self.ui.antennaTypeCombo.setEnabled(enabled) self.ui.antennaOffsetButton.setEnabled(enabled) + self.ui.aprioriPositionButton.setEnabled(enabled) self.ui.timeWindowButton.setEnabled(enabled) self.ui.dataIntervalButton.setEnabled(enabled) diff --git a/scripts/GinanUI/app/models/archive_manager.py b/scripts/GinanUI/app/models/archive_manager.py index 1361a2662..c457b1d6f 100644 --- a/scripts/GinanUI/app/models/archive_manager.py +++ b/scripts/GinanUI/app/models/archive_manager.py @@ -96,15 +96,36 @@ def archive_products(products_dir: Path = INPUT_PRODUCTS_PATH, reason: str = "ma return None 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 + "*.SP3.gz", # precise orbit + "*.CLK.gz", # clock files + "*.BIA.gz", # biases + "*.ION.gz", # ionosphere products + "*.TRO.gz", # troposphere products + "BRDC*.rnx.gz", # broadcast ephemeris + "*.sp3.Z", # precise orbit (old format) + "*.clk.Z", # clock files (old format) + "*.bia.Z", # biases (old format) ] + # Uncompressed counterparts to clean up after archiving the compressed versions + uncompressed_cleanup_patterns = [ + "*.SP3", + "*.CLK", + "*.BIA", + "*.ION", + "*.TRO", + "BRDC*.rnx", + "*.sp3", + "*.clk", + "*.bia", + ] + + # Only archive SNX files when RINEX changes (SNX is station-specific, not provider-specific) + if reason == "rinex_change" or reason == "startup_archival": + product_patterns.append("IGS*SNX_*_CRD.SNX.gz") + product_patterns.append("SHA512SUMS_*") + uncompressed_cleanup_patterns.append("IGS*SNX_*_CRD.SNX") + if startup_archival: startup_patterns = [ "finals.data.iau2000.txt", @@ -164,6 +185,15 @@ def archive_products(products_dir: Path = INPUT_PRODUCTS_PATH, reason: str = "ma except Exception as e: Logger.console(f"Failed to archive {file.name}: {e}") + # Clean up uncompressed counterparts left behind after archiving the compressed versions + if archived_files: + for pattern in uncompressed_cleanup_patterns: + for uncompressed_file in products_dir.glob(pattern): + try: + uncompressed_file.unlink() + except Exception as e: + Logger.console(f"Failed to clean up uncompressed file {uncompressed_file.name}: {e}") + if archived_files: Logger.console(f"Archived {', '.join(archived_files)} → {archive_dir}") return archive_dir @@ -177,6 +207,67 @@ def archive_products(products_dir: Path = INPUT_PRODUCTS_PATH, reason: str = "ma return None +# File extensions eligible for archive restoration (dynamic PPP products only) +RESTORABLE_EXTENSIONS = {".SP3", ".BIA", ".CLK", ".SNX", ".RNX"} # ".RNX" is for BRDC files +RESTORABLE_CHECKSUM_PREFIX = "SHA512SUMS" + +def restore_from_archive(filename: str, products_dir: Path = INPUT_PRODUCTS_PATH) -> Optional[Path]: + """ + Search the archive directory for a previously archived product file and restore it + by copying it back into the products directory. This avoids re-downloading from CDDIS + when the same product is needed again (e.g. after closing and reopening Ginan-UI). + + Only considers BIA, CLK, SP3, BRDC, SNX, and SHA512SUMS files. Archived files are stored + in their compressed form (.gz), so if the requested filename is already compressed it is + searched for directly; if uncompressed, the .gz variant is searched for as well. + + The archived copy is left intact - a duplicate is placed into products_dir. + + :param filename: The filename to search for (e.g. "COD0MGXFIN_20191950000_01D_01D_OSB.BIA.gz" + or "COD0MGXFIN_20191950000_01D_01D_OSB.BIA" or "SHA512SUMS_2062") + :param products_dir: The products directory to restore into + :return: Path to the restored file if found, else None + """ + archive_root = products_dir / "archive" + if not archive_root.exists() or not archive_root.is_dir(): + return None + + # Determine if this file is eligible for restoration + is_checksum = filename.startswith(RESTORABLE_CHECKSUM_PREFIX) + if not is_checksum: + # Check if the uncompressed extension is one we care about + bare_name = filename.removesuffix(".gz").removesuffix(".Z").removesuffix(".gzip") + ext = Path(bare_name).suffix.upper() + if ext not in RESTORABLE_EXTENSIONS: + return None + + # Build the list of candidate names to search for in the archive. + # Archived products are stored compressed, so search for the .gz name first, + # then the exact filename as given. + candidates = [filename] + is_compressed = any(filename.endswith(s) for s in (".gz", ".Z", ".gzip")) + if not is_compressed: + # The caller asked for an uncompressed name - also look for .gz in the archive + candidates.insert(0, filename + ".gz") + + # Glob through all archive sub-folders: archive/{reason}_{timestamp}/ + for candidate in candidates: + matches = list(archive_root.glob(f"*/{candidate}")) + if matches: + # Pick the most recent archive (folder names are {reason}_{YYYYMMDD_HHMMSS}) + matches.sort(key=lambda p: p.parent.name, reverse=True) + source = matches[0] + dest = products_dir / candidate + try: + shutil.copy2(str(source), str(dest)) + Logger.console(f"📂 Restored '{candidate}' from archive ({source.parent.name})") + return dest + except Exception as e: + Logger.console(f"Failed to restore '{candidate}' from archive: {e}") + return None + + return None + def archive_products_if_rinex_changed(current_rinex: Path, last_rinex: Optional[Path], products_dir: Path) -> Optional[Path]: diff --git a/scripts/GinanUI/app/models/dl_products.py b/scripts/GinanUI/app/models/dl_products.py index 8644e4ba0..77f79292d 100644 --- a/scripts/GinanUI/app/models/dl_products.py +++ b/scripts/GinanUI/app/models/dl_products.py @@ -1,15 +1,16 @@ -import gzip, os, shutil, unlzw3, requests +import gzip, hashlib, os, shutil, unlzw3, requests import pandas as pd import numpy as np from bs4 import BeautifulSoup, SoupStrainer from datetime import datetime, timedelta from pathlib import Path -from typing import Optional, Callable, Generator, List +from typing import Optional, Callable, Dict, Generator, List from scripts.GinanUI.app.utils.cddis_email import get_netrc_auth from scripts.GinanUI.app.utils.common_dirs import INPUT_PRODUCTS_PATH from scripts.GinanUI.app.utils.gn_functions import GPSDate from scripts.GinanUI.app.utils.logger import Logger +from scripts.GinanUI.app.models.archive_manager import restore_from_archive BASE_URL = "https://cddis.nasa.gov/archive" GPS_ORIGIN = np.datetime64("1980-01-06 00:00:00") # Magic date from gn_functions @@ -48,6 +49,11 @@ ] +CHECKSUM_FILENAME = "SHA512SUMS" +# File types that should be validated against SHA512SUMS +CHECKSUM_VALIDATED_FORMATS = {"SP3", "BIA", "CLK", "SNX"} + + def date_to_gpswk(date: datetime) -> int: return int(GPSDate(np.datetime64(date)).gpswk) @@ -600,12 +606,14 @@ def _try_repro3_fallback(start_time: datetime, end_time: datetime, target_files: return main_products return repro3_products -def extract_file(filepath: Path) -> Path: +def extract_file(filepath: Path, keep_compressed: bool = True) -> Path: """ Extracts [".gz", ".gzip", ".Z"] files with gzip and unlzw3 respectively. - Deletes compressed file after extraction. + By default, the compressed file is retained alongside the extracted version + so that it can be archived and later validated against SHA-512 checksums. :param filepath: compressed file path + :param keep_compressed: if True, retain the compressed file after extraction :return: path to extracted file """ finalpath = ".".join(str(filepath).split(".")[:-1]) @@ -616,13 +624,210 @@ def extract_file(filepath: Path) -> Path: decompressed_data = unlzw3.unlzw(filepath) with open(finalpath, "wb") as f_out: f_out.write(decompressed_data) - filepath.unlink() + if not keep_compressed: + filepath.unlink() return Path(finalpath) +# region SHA-512 Checksum Validation + +def get_checksum_url(gps_week: int, use_repro3: bool = False) -> str: + """ + Generate the URL for the SHA512SUMS file for a given GPS week. + + :param gps_week: GPS week number + :param use_repro3: If True, generate URL for the repro3 subdirectory + :returns: URL to the SHA512SUMS file + """ + if use_repro3: + return f"{BASE_URL}/gnss/products/{gps_week}/repro3/{CHECKSUM_FILENAME}" + return f"{BASE_URL}/gnss/products/{gps_week}/{CHECKSUM_FILENAME}" + +def download_checksum_file(gps_week: int, session: requests.Session, download_dir: Path = INPUT_PRODUCTS_PATH, + use_repro3: bool = False, progress_callback: Optional[Callable] = None, + stop_requested: Optional[Callable] = None) -> Optional[Path]: + """ + Download the SHA512SUMS file for a given GPS week if it hasn't been downloaded already. + The file is saved as SHA512SUMS_{gps_week} (or SHA512SUMS_{gps_week}_repro3) to avoid + collisions between different weeks. + + :param gps_week: GPS week number + :param session: Authenticated requests session for CDDIS access + :param download_dir: Directory to save the checksum file + :param use_repro3: If True, download from the repro3 subdirectory + :param progress_callback: Optional callback for progress updates (filename, percent) + :param stop_requested: Optional callback to check if operation should stop + :returns: Path to the downloaded checksum file, or None if download failed + """ + suffix = "_repro3" if use_repro3 else "" + local_filename = f"{CHECKSUM_FILENAME}_{gps_week}{suffix}" + local_path = download_dir / local_filename + + # Return cached file if it already exists + if local_path.exists(): + return local_path + + # Check if file can be restored from the archive + restored = restore_from_archive(local_filename, download_dir) + if restored is not None and restored.exists(): + return restored + + url = get_checksum_url(gps_week, use_repro3) + Logger.terminal(f"đŸ“Ĩ Downloading checksum file {CHECKSUM_FILENAME} for GPS week {gps_week}{' (repro3)' if use_repro3 else ''}...") + + for attempt in range(MAX_RETRIES): + if stop_requested and stop_requested(): + return None + + try: + resp = session.get(url, stream=True, timeout=30) + resp.raise_for_status() + + total_size = int(resp.headers.get("content-length", 0)) + downloaded = 0 + + os.makedirs(download_dir, exist_ok=True) + partial_path = local_path.with_suffix(".part") + + with open(partial_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=CHUNK_SIZE): + if stop_requested and stop_requested(): + partial_path.unlink(missing_ok=True) + return None + if chunk: + f.write(chunk) + downloaded += len(chunk) + if progress_callback and total_size > 0: + percent = int(downloaded / total_size * 100) + progress_callback(local_filename, percent) + + os.rename(partial_path, local_path) + Logger.terminal(f"✅ Downloaded checksum file {local_filename}") + return local_path + + except requests.RequestException as e: + if attempt < MAX_RETRIES - 1: + Logger.terminal(f"âš ī¸ Checksum download attempt {attempt + 1}/{MAX_RETRIES} failed: {e}") + else: + Logger.terminal(f"âš ī¸ Failed to download {CHECKSUM_FILENAME} for GPS week {gps_week} after {MAX_RETRIES} attempts: {e}") + + return None + +def parse_checksum_file(checksum_path: Path) -> dict: + """ + Parse a SHA512SUMS file into a dictionary mapping filenames to their expected SHA-512 hashes. + + The file format is: {128-char hex hash} {filename} (with one or two spaces as separator). + + :param checksum_path: Path to the SHA512SUMS file + :returns: Dictionary mapping filename -> expected SHA-512 hex digest + """ + checksums = {} + try: + with open(checksum_path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if not line: + continue + # SHA-512 hex digest is 128 characters, followed by space(s) and the filename + parts = line.split(None, 1) + if len(parts) == 2 and len(parts[0]) == 128: + hex_hash = parts[0].lower() + # Validate that the hash is actually valid hexadecimal + try: + int(hex_hash, 16) + except ValueError: + Logger.terminal(f"âš ī¸ Invalid hex hash in SHA512SUMS for {parts[1].strip()}, skipping entry") + continue + checksums[parts[1].strip()] = hex_hash + except Exception as e: + Logger.terminal(f"âš ī¸ Failed to parse checksum file {checksum_path}: {e}") + if not checksums: + Logger.terminal(f"âš ī¸ No valid checksum entries found in {checksum_path.name}") + return checksums + +def compute_sha512(filepath: Path) -> str: + """ + Compute the SHA-512 hash of a file. + + :param filepath: Path to the file to hash + :returns: Hex digest string of the SHA-512 hash + """ + sha512 = hashlib.sha512() + with open(filepath, "rb") as f: + for chunk in iter(lambda: f.read(CHUNK_SIZE), b""): + sha512.update(chunk) + return sha512.hexdigest() + +def validate_checksum(filepath: Path, checksums: dict) -> Optional[bool]: + """ + Validate a downloaded file against its expected SHA-512 checksum. + + :param filepath: Path to the compressed file to validate + :param checksums: Dictionary from parse_checksum_file() mapping filename -> expected hash + :returns: True if checksum matches, False if mismatch, None if filename not found in checksums + """ + filename = filepath.name + expected_hash = checksums.get(filename) + + if expected_hash is None: + Logger.terminal(f"âš ī¸ No checksum entry found for {filename} in SHA512SUMS (file may be corrupted or incomplete)") + return None + + # Verify the expected hash is valid hex before comparing + try: + int(expected_hash, 16) + except ValueError: + Logger.terminal(f"âš ī¸ Invalid checksum hash in SHA512SUMS for {filename}, skipping validation") + return None + + actual_hash = compute_sha512(filepath) + + if actual_hash == expected_hash: + Logger.console(f"✅ Checksum ok: {filename}") + return True + else: + Logger.console(f"❌ Checksum mismatch: {filename} | Expected: {expected_hash[:16]}... Got: {actual_hash[:16]}...") + return False + +# Cache of downloaded checksum files: (gps_week, use_repro3) -> parsed checksums dict +_checksum_cache: Dict[tuple, dict] = {} + +def get_checksums_for_week(gps_week: int, session: requests.Session, download_dir: Path = INPUT_PRODUCTS_PATH, + use_repro3: bool = False, progress_callback: Optional[Callable] = None, + stop_requested: Optional[Callable] = None) -> Optional[dict]: + """ + Get the parsed checksum dictionary for a GPS week, downloading the SHA512SUMS file if needed. + Uses an in-memory cache so the file is only downloaded and parsed once per session. + + :param gps_week: GPS week number + :param session: Authenticated requests session for CDDIS access + :param download_dir: Directory to save/find the checksum file + :param use_repro3: If True, use the repro3 subdirectory + :param progress_callback: Optional callback for progress updates (filename, percent) + :param stop_requested: Optional callback to check if operation should stop + :returns: Dictionary mapping filename -> expected SHA-512 hex digest, or None if unavailable + """ + cache_key = (gps_week, use_repro3) + + if cache_key in _checksum_cache: + return _checksum_cache[cache_key] + + checksum_path = download_checksum_file(gps_week, session, download_dir, use_repro3, + progress_callback, stop_requested) + if checksum_path is None: + return None + + checksums = parse_checksum_file(checksum_path) + _checksum_cache[cache_key] = checksums + return checksums + +# endregion + def download_file(url: str, session: requests.Session, download_dir: Path = INPUT_PRODUCTS_PATH, progress_callback: Optional[Callable] = None, - stop_requested: Callable = None) -> Path: + stop_requested: Callable = None, checksums: Optional[dict] = None, + keep_compressed: bool = True) -> Path: """ Checks if file already exists (additionally in compressed or .part forms). Uses provided session for CDDIS files (session made during startup). @@ -635,6 +840,8 @@ def download_file(url: str, session: requests.Session, download_dir: Path = INPU :param download_dir: dir to download to :param progress_callback: reports, on every chunk, an int percentage of total download :param stop_requested: bool callback. Raises a RuntimeError if occurred during download + :param checksums: Optional dict of filename -> expected SHA-512 hex digest for validation + :param keep_compressed: if True, retain compressed file alongside extracted version for archival :raises RuntimeError: Stop requested during download :raises Exception: Max retries reached :return: @@ -643,18 +850,65 @@ def download_file(url: str, session: requests.Session, download_dir: Path = INPU filepath = Path(download_dir / url.split("/")[-1]) # Download dir + filename # 1. When file already exists, extract if possible, then return if filepath.exists(): - if filepath.suffix in COMPRESSED_FILETYPE: - return extract_file(filepath) + # Validate checksum on the existing compressed file before extracting + if checksums is not None: + result = validate_checksum(filepath, checksums) + if result is False: + Logger.terminal(f"âš ī¸ Existing file {filepath.name} failed checksum, re-downloading...") + filepath.unlink(missing_ok=True) + # Fall through to download below + else: + if filepath.suffix in COMPRESSED_FILETYPE: + return extract_file(filepath, keep_compressed=keep_compressed) + else: + return filepath else: - return filepath + if filepath.suffix in COMPRESSED_FILETYPE: + return extract_file(filepath, keep_compressed=keep_compressed) + else: + return filepath # 2. Check if an extracted version of this file already exists if filepath.suffix in COMPRESSED_FILETYPE: potential_decompressed = filepath.with_suffix('') # Remove one suffix if potential_decompressed.exists(): - return potential_decompressed + # If the compressed file is still alongside it, validate checksum against it + if checksums is not None and filepath.name in checksums: + if filepath.exists(): + result = validate_checksum(filepath, checksums) + if result is False: + Logger.terminal(f"âš ī¸ Compressed file {filepath.name} failed checksum, re-downloading...") + filepath.unlink(missing_ok=True) + potential_decompressed.unlink(missing_ok=True) + # Fall through to download below + else: + return potential_decompressed + else: + Logger.terminal(f"âš ī¸ Cannot verify checksum for {filepath.name} (compressed file missing), re-downloading to validate...") + potential_decompressed.unlink(missing_ok=True) + # Fall through to download below + else: + return potential_decompressed + + # 3. Check if the file can be restored from the archive (avoids re-downloading from CDDIS) + restored = restore_from_archive(filepath.name, download_dir) + if restored is not None and restored.exists(): + # Restored file is compressed - validate checksum then extract + if checksums is not None and filepath.name in checksums: + result = validate_checksum(restored, checksums) + if result is False: + Logger.terminal(f"âš ī¸ Archived file {restored.name} failed checksum validation, re-downloading...") + restored.unlink(missing_ok=True) + # Fall through to download below + else: + return extract_file(restored, keep_compressed=keep_compressed) + else: + if restored.suffix in COMPRESSED_FILETYPE: + return extract_file(restored, keep_compressed=keep_compressed) + else: + return restored - # 3. Download the file in chunks (.part) + # 4. Download the file in chunks (.part) for i in range(MAX_RETRIES): _partial = filepath.with_suffix(filepath.suffix + ".part") @@ -705,8 +959,16 @@ def download_file(url: str, session: requests.Session, download_dir: Path = INPU os.rename(_partial, filepath) + # Validate checksum on the compressed file before extraction + if checksums is not None: + result = validate_checksum(filepath, checksums) + if result is False: + Logger.terminal(f"âš ī¸ Deleting corrupted file {filepath.name} and retrying...") + filepath.unlink(missing_ok=True) + continue + if filepath.suffix in COMPRESSED_FILETYPE: - return extract_file(filepath) + return extract_file(filepath, keep_compressed=keep_compressed) else: return filepath except requests.RequestException as e: @@ -771,8 +1033,11 @@ def download_products(products: pd.DataFrame, download_dir: Path = INPUT_PRODUCT _sesh = requests.Session() _sesh.auth = get_netrc_auth() - # 1. Generate filenames from the DataFrame + # 1. Generate filenames from the DataFrame, tracking which URLs need checksum validation downloads = [] + # Maps URL -> (gps_week, is_repro3) for CDDIS product files that need checksum validation + url_checksum_info = {} + for _, row in products.iterrows(): gps_week = date_to_gpswk(row.date) # Check if this is a repro3 product (R03 project) FIRST @@ -804,6 +1069,10 @@ def download_products(products: pd.DataFrame, download_dir: Path = INPUT_PRODUCT downloads.append(url) + # Track checksum info for validated file types + if row.format.upper() in CHECKSUM_VALIDATED_FORMATS: + url_checksum_info[url] = (gps_week, is_repro3) + if dl_urls: downloads.extend(dl_urls) @@ -816,7 +1085,17 @@ def download_products(products: pd.DataFrame, download_dir: Path = INPUT_PRODUCT fin_dir = download_dir else: fin_dir = download_dir / "tables" if _x[-2] == "tables" else download_dir - yield download_file(url, _sesh, fin_dir, progress_callback, stop_requested) + + # Fetch checksums for this URL if it needs validation + checksums = None + if url in url_checksum_info: + gps_week, is_repro3 = url_checksum_info[url] + checksums = get_checksums_for_week(gps_week, _sesh, download_dir, is_repro3, + progress_callback, stop_requested) + + # Don't keep compressed files for tables/metadata - only for CDDIS product files + is_tables = (fin_dir != download_dir) + yield download_file(url, _sesh, fin_dir, progress_callback, stop_requested, checksums, keep_compressed=not is_tables) def _get_repro3_filename_and_url(row: pd.Series, gps_week: int, session: requests.Session = None) -> tuple: """ @@ -1522,6 +1801,9 @@ def get_bia_code_priorities_for_selection(products_df: pd.DataFrame, Download and parse BIA file for a specific provider/series/project combination to extract available code priorities per constellation. + If the BIA file already exists locally (from a previous download) or can be restored + from the archive, it is read directly without contacting CDDIS. + :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') @@ -1553,6 +1835,18 @@ def get_bia_code_priorities_for_selection(products_df: pd.DataFrame, Logger.console(f"Could not generate BIA URL for {provider}/{series}/{project}") return None + # Check if the BIA file already exists locally (uncompressed from a previous download) + compressed_filename = url.split("/")[-1] + uncompressed_filename = compressed_filename.removesuffix(".gz").removesuffix(".Z").removesuffix(".gzip") + local_uncompressed = INPUT_PRODUCTS_PATH / uncompressed_filename + local_compressed = INPUT_PRODUCTS_PATH / compressed_filename + + bia_content = _try_read_local_bia(local_uncompressed, local_compressed, compressed_filename, provider, series, project) + if bia_content is not None: + code_priorities = parse_bia_code_priorities(bia_content) + _log_bia_code_priorities(code_priorities, provider, series, project) + return code_priorities + Logger.terminal(f"đŸ“Ĩ Validating constellation signal frequencies against BIA file for {provider}/{series}/{project}...") # Download satellite bias section @@ -1564,17 +1858,530 @@ def get_bia_code_priorities_for_selection(products_df: pd.DataFrame, # Parse code priorities code_priorities = parse_bia_code_priorities(bia_content) + _log_bia_code_priorities(code_priorities, provider, series, project) + return code_priorities + + +def _try_read_local_bia(local_uncompressed: Path, local_compressed: Path, + compressed_filename: str, provider: str, series: str, project: str) -> Optional[str]: + """ + Try to read BIA content from a local file or by restoring from the archive. + Returns the satellite bias section content, or None if not available locally. + + :param local_uncompressed: Path to the expected uncompressed BIA file + :param local_compressed: Path to the expected compressed BIA file + :param compressed_filename: The compressed filename for archive restoration + :param provider: Analysis centre for logging + :param series: Solution type for logging + :param project: Project code for logging + :return: BIA satellite section content string, or None + """ + # 1. Check if uncompressed BIA file exists locally + if local_uncompressed.exists(): + try: + Logger.console(f"📂 Using local BIA file for {provider}/{series}/{project}: {local_uncompressed.name}") + with open(local_uncompressed, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + _, satellite_section = _check_bia_termination(content, force_return=True) + if satellite_section: + return satellite_section + except Exception as e: + Logger.console(f"Failed to read local BIA file {local_uncompressed.name}: {e}") + + # 2. Check if compressed BIA file exists locally (extract and read) + if local_compressed.exists(): + try: + Logger.console(f"📂 Using local compressed BIA file for {provider}/{series}/{project}: {local_compressed.name}") + content = _read_compressed_bia(local_compressed) + if content: + _, satellite_section = _check_bia_termination(content, force_return=True) + if satellite_section: + return satellite_section + except Exception as e: + Logger.console(f"Failed to read compressed BIA file {local_compressed.name}: {e}") + + # 3. Try restoring from archive + restored = restore_from_archive(compressed_filename, INPUT_PRODUCTS_PATH) + if restored is not None and restored.exists(): + try: + Logger.console(f"📂 Using archived BIA file for {provider}/{series}/{project}: {restored.name}") + content = _read_compressed_bia(restored) if restored.suffix in COMPRESSED_FILETYPE else None + if content is None: + with open(restored, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + if content: + _, satellite_section = _check_bia_termination(content, force_return=True) + if satellite_section: + return satellite_section + except Exception as e: + Logger.console(f"Failed to read archived BIA file {restored.name}: {e}") - # Log results + return None + + +def _read_compressed_bia(filepath: Path) -> Optional[str]: + """ + Read and decompress a .gz or .Z compressed BIA file. + + :param filepath: Path to the compressed file + :return: Decompressed content as string, or None on failure + """ + try: + if str(filepath).endswith((".gz", ".gzip")): + with gzip.open(filepath, "rb") as f: + return f.read().decode('utf-8', errors='replace') + elif str(filepath).endswith(".Z"): + data = unlzw3.unlzw(filepath) + return data.decode('utf-8', errors='replace') + except Exception as e: + Logger.console(f"Failed to decompress {filepath.name}: {e}") + return None + + +def _log_bia_code_priorities(code_priorities: dict, provider: str, series: str, project: str): + """Log extracted BIA code priorities.""" 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 +#region SINEX Product Validation + +def get_sinex_url(target_date: datetime, use_repro3: bool = False) -> str: + """ + Generate the URL for the IGS CRD SINEX file for the given date. + Downloads the 1-day (daily) IGS CRD file + + Main directory format: IGS0OPSSNX_YYYYDDD0000_01D_01D_CRD.SNX.gz + Repro3 format: IGS0R03SNX_YYYYDDD0000_01D_01D_CRD.SNX.gz (in /repro3/ subdirectory) + + :param target_date: The date for which to download the SINEX file + :param use_repro3: If True, generate URL for repro3 subdirectory + :returns: URL to the IGS CRD SNX file + """ + gps_week = date_to_gpswk(target_date) + year = target_date.year + doy = target_date.timetuple().tm_yday + + if use_repro3: + filename = f"IGS0R03SNX_{year}{doy:03d}0000_01D_01D_CRD.SNX.gz" + url = f"{BASE_URL}/gnss/products/{gps_week}/repro3/{filename}" + else: + filename = f"IGS0OPSSNX_{year}{doy:03d}0000_01D_01D_CRD.SNX.gz" + url = f"{BASE_URL}/gnss/products/{gps_week}/{filename}" + return url + +def download_sinex_file(target_date: datetime, download_dir: Path = INPUT_PRODUCTS_PATH, progress_callback: Optional[Callable] = None, + stop_requested: Optional[Callable] = None, max_retries: int = 3) -> Optional[Path]: + """ + Download the IGS CRD SINEX file for the given date with retry logic. + Determines whether to use repro3 or main directory based on GPS week range + (same logic as BIA, CLK, SP3 products). + + - GPS week 730-2138: Use repro3 directory + - Outside that range: Use main directory + + :param target_date: The date for which to download the SINEX file + :param download_dir: Directory to save the downloaded file + :param progress_callback: Optional callback for progress updates (filename, percent) + :param stop_requested: Optional callback to check if operation should stop + :param max_retries: Number of retry attempts (default 3) + :returns: Path to the downloaded (and extracted) SINEX file, or None if failed + """ + session = requests.Session() + session.auth = get_netrc_auth() + + # Check if we're in the repro3 priority range (same logic as other products) + gps_week = date_to_gpswk(target_date) + use_repro3 = REPRO3_PRIORITY_GPS_WEEK_START <= gps_week <= REPRO3_PRIORITY_GPS_WEEK_END + + url = get_sinex_url(target_date, use_repro3=use_repro3) + + # Fetch checksums for the SINEX file's GPS week + checksums = get_checksums_for_week(gps_week, session, download_dir, use_repro3, progress_callback, stop_requested) + + for attempt in range(max_retries): + if stop_requested and stop_requested(): + Logger.terminal("🛑 SINEX download cancelled") + return None + + try: + filepath = download_file(url, session, download_dir, progress_callback, stop_requested, checksums) + Logger.terminal(f"✅ SINEX file downloaded: {filepath.name}") + return filepath + except Exception as e: + if attempt < max_retries - 1: + Logger.terminal(f"âš ī¸ SINEX download attempt {attempt + 1}/{max_retries} failed: {e}") + else: + Logger.terminal(f"❌ Failed to download SINEX file after {max_retries} attempts: {e}") + + return None + +def parse_sinex_section(content: str, section_name: str) -> List[str]: + """ + Extract lines from a specific SINEX section. + + :param content: Full SINEX file content + :param section_name: Name of the section (e.g., "SITE/RECEIVER", "SITE/ANTENNA") + :returns: List of data lines from the section (excluding header/comment lines) + """ + lines = [] + in_section = False + start_marker = f"+{section_name}" + end_marker = f"-{section_name}" + + for line in content.split('\n'): + if line.startswith(start_marker): + in_section = True + continue + elif line.startswith(end_marker): + break + elif in_section and not line.startswith('*') and line.strip(): + lines.append(line) + + return lines + +def parse_sinex_receiver(sinex_content: str, marker_name: str) -> Optional[str]: + """ + Extract the receiver type for a given marker from SINEX SITE/RECEIVER section. + + SINEX format (columns are fixed-width): + *CODE PT SOLN T _DATA START_ __DATA_END__ ___RECEIVER_TYPE____ _S/N_ _FIRMWARE__ + AB51 A ---- P 17:156:72000 00:000:00000 TRIMBLE NETRS 45032 1.3-2 + + Receiver type is 20 characters starting at column 42. + + :param sinex_content: Full SINEX file content + :param marker_name: 4-character marker name to look up + :returns: Receiver type string (20 chars) or None if not found + """ + lines = parse_sinex_section(sinex_content, "SITE/RECEIVER") + + for line in lines: + if len(line) < 62: + continue + code = line[1:5].strip() + if code.upper() == marker_name.upper(): + # Receiver type is columns 42-61 (20 characters) + receiver_type = line[42:62] + return receiver_type + + return None + +def parse_sinex_antenna(sinex_content: str, marker_name: str) -> Optional[str]: + """ + Extract the antenna type for a given marker from SINEX SITE/ANTENNA section. + + SINEX format (columns are fixed-width): + *CODE PT SOLN T _DATA START_ __DATA_END__ ____ANTENNA_TYPE____ _S/N_ _DAZ + AB51 A ---- P 05:273:00000 00:000:00000 TRM29659.00 SCIT 02203 0 + + Antenna type is 20 characters starting at column 42. + + :param sinex_content: Full SINEX file content + :param marker_name: 4-character marker name to look up + :returns: Antenna type string (20 chars) or None if not found + """ + lines = parse_sinex_section(sinex_content, "SITE/ANTENNA") + + for line in lines: + if len(line) < 62: + continue + code = line[1:5].strip() + if code.upper() == marker_name.upper(): + # Antenna type is columns 42-61 (20 characters) + antenna_type = line[42:62] + return antenna_type + + return None + +def parse_sinex_eccentricity(sinex_content: str, marker_name: str) -> Optional[List[float]]: + """ + Extract the antenna eccentricity (offset) for a given marker from SINEX SITE/ECCENTRICITY section. + + SINEX format (columns are fixed-width): + *CODE PT SOLN T _DATA START_ __DATA_END__ REF __DX_U__ __DX_N__ __DX_E__ + AB51 A ---- P 05:273:00000 00:000:00000 UNE 0.0083 0.0000 0.0000 + + DX_U (Up) starts at column 46, DX_N (North) at 55, DX_E (East) at 64. + + :param sinex_content: Full SINEX file content + :param marker_name: 4-character marker name to look up + :returns: List of [East, North, Up] offsets in metres, or None if not found + """ + lines = parse_sinex_section(sinex_content, "SITE/ECCENTRICITY") + + for line in lines: + if len(line) < 72: + continue + code = line[1:5].strip() + if code.upper() == marker_name.upper(): + try: + # Extract UNE values - note SINEX stores as U, N, E but we return as E, N, U + dx_u = float(line[46:55].strip()) + dx_n = float(line[55:64].strip()) + dx_e = float(line[64:].strip()) # Read to end of line for last field + return [dx_e, dx_n, dx_u] + except ValueError: + return None + + return None + +def parse_sinex_apriori_position(sinex_content: str, marker_name: str) -> Optional[List[float]]: + """ + Extract the apriori position (X, Y, Z) for a given marker from SINEX SOLUTION/APRIORI section. + + SINEX format (columns are fixed-width): + *INDEX _TYPE_ CODE PT SOLN _REF_EPOCH__ UNIT S ____APRIORI_VALUE____ __STD_DEV__ + 1 STAX AB51 A 3 23:260:43200 m 2 -2.38374988824415e+06 0.00000e+00 + + We need to find STAX, STAY, STAZ entries for the marker. + + :param sinex_content: Full SINEX file content + :param marker_name: 4-character marker name to look up + :returns: List of [X, Y, Z] coordinates in metres, or None if not found + """ + lines = parse_sinex_section(sinex_content, "SOLUTION/APRIORI") + + position = {'STAX': None, 'STAY': None, 'STAZ': None} + + for line in lines: + if len(line) < 68: + continue + + # Parse the line - fields are space-separated but with fixed positions + parts = line.split() + if len(parts) < 9: + continue + + try: + # _TYPE_ is at position 1, CODE at position 2, value at position 8 + sta_type = parts[1] + code = parts[2] + + if code.upper() != marker_name.upper(): + continue + + if sta_type in position: + # Value is in scientific notation + value = float(parts[8]) + position[sta_type] = value + except (IndexError, ValueError): + continue + + # Check if we found all three coordinates + if all(v is not None for v in position.values()): + return [position['STAX'], position['STAY'], position['STAZ']] + + return None + +def validate_sinex_values(sinex_content: str, marker_name: str, receiver_type: str, antenna_type: str, antenna_offset: List[float], + apriori_position: Optional[List[float]] = None, tolerance: float = 0.001) -> dict: + """ + Validate extracted RINEX values against SINEX file data. + + :param sinex_content: Full SINEX file content + :param marker_name: 4-character marker name + :param receiver_type: Receiver type from RINEX (20 chars, space-padded) + :param antenna_type: Antenna type from RINEX (20 chars, space-padded) + :param antenna_offset: Antenna offset [E, N, U] from RINEX + :param apriori_position: Optional apriori position [X, Y, Z] from RINEX + :param tolerance: Tolerance for floating point comparisons (default 1mm) + :returns: Dictionary with validation results for each field + """ + results = { + 'marker_found': False, + 'receiver_type': {'valid': None, 'sinex_value': None, 'rinex_value': receiver_type, 'message': ''}, + 'antenna_type': {'valid': None, 'sinex_value': None, 'rinex_value': antenna_type, 'message': ''}, + 'antenna_offset': {'valid': None, 'sinex_value': None, 'rinex_value': antenna_offset, 'message': ''}, + 'apriori_position': {'valid': None, 'sinex_value': None, 'rinex_value': apriori_position, 'message': ''}, + } + + # Validate receiver type + sinex_receiver = parse_sinex_receiver(sinex_content, marker_name) + if sinex_receiver is not None: + results['marker_found'] = True + results['receiver_type']['sinex_value'] = sinex_receiver + + sinex_recv_norm = sinex_receiver.rstrip() + rinex_recv_norm = receiver_type.rstrip() if receiver_type else '' + + if sinex_recv_norm == rinex_recv_norm: + results['receiver_type']['valid'] = True + results['receiver_type']['message'] = 'Receiver type matches SINEX' + else: + results['receiver_type']['valid'] = False + results['receiver_type']['message'] = f'Receiver type mismatch: RINEX="{rinex_recv_norm}", SINEX="{sinex_recv_norm}"' + + # Validate antenna type + sinex_antenna = parse_sinex_antenna(sinex_content, marker_name) + if sinex_antenna is not None: + results['marker_found'] = True + results['antenna_type']['sinex_value'] = sinex_antenna + + sinex_ant_norm = sinex_antenna.rstrip() + rinex_ant_norm = antenna_type.rstrip() if antenna_type else '' + + if sinex_ant_norm == rinex_ant_norm: + results['antenna_type']['valid'] = True + results['antenna_type']['message'] = 'Antenna type matches SINEX' + else: + results['antenna_type']['valid'] = False + results['antenna_type']['message'] = f'Antenna type mismatch: RINEX="{rinex_ant_norm}", SINEX="{sinex_ant_norm}"' + + # Validate antenna offset (eccentricity) + sinex_offset = parse_sinex_eccentricity(sinex_content, marker_name) + if sinex_offset is not None: + results['marker_found'] = True + results['antenna_offset']['sinex_value'] = sinex_offset + + if antenna_offset is not None and len(antenna_offset) == 3: + # Compare E, N, U values with tolerance + matches = all(abs(sinex_offset[i] - antenna_offset[i]) <= tolerance for i in range(3)) + if matches: + results['antenna_offset']['valid'] = True + results['antenna_offset']['message'] = f'Antenna offset matches SINEX [E={sinex_offset[0]:.4f}, N={sinex_offset[1]:.4f}, U={sinex_offset[2]:.4f}]' + else: + results['antenna_offset']['valid'] = False + results['antenna_offset']['message'] = ( + f'Antenna offset mismatch: RINEX=[E={antenna_offset[0]:.4f}, N={antenna_offset[1]:.4f}, U={antenna_offset[2]:.4f}], ' + f'SINEX=[E={sinex_offset[0]:.4f}, N={sinex_offset[1]:.4f}, U={sinex_offset[2]:.4f}]' + ) + else: + # RINEX didn't provide offset, just log what SINEX has + results['antenna_offset']['valid'] = None + results['antenna_offset']['message'] = f'SINEX offset: [E={sinex_offset[0]:.4f}, N={sinex_offset[1]:.4f}, U={sinex_offset[2]:.4f}] (no RINEX value to compare)' + else: + # Marker not found in SITE/ECCENTRICITY section + if antenna_offset is not None and len(antenna_offset) == 3: + results['antenna_offset']['message'] = f'Marker not found in SINEX SITE/ECCENTRICITY section (RINEX: [E={antenna_offset[0]:.4f}, N={antenna_offset[1]:.4f}, U={antenna_offset[2]:.4f}])' + + # Validate apriori position + sinex_position = parse_sinex_apriori_position(sinex_content, marker_name) + if sinex_position is not None: + results['marker_found'] = True + results['apriori_position']['sinex_value'] = sinex_position + + if apriori_position is not None and len(apriori_position) == 3: + # Calculate 3D distance between RINEX and SINEX positions + import math + distance = math.sqrt(sum((sinex_position[i] - apriori_position[i]) ** 2 for i in range(3))) + + # Always use SINEX, but warn if large discrepancy + results['apriori_position']['valid'] = True + + if distance <= 1.0: + results['apriori_position']['message'] = ( + f'Using SINEX coordinates (matches RINEX within {distance:.2f}m): ' + f'[{sinex_position[0]}, {sinex_position[1]}, {sinex_position[2]}]' + ) + elif distance <= 10.0: + # Small discrepancy - just informational + results['apriori_position']['message'] = ( + f'Using SINEX coordinates (RINEX differs by {distance:.2f}m): ' + f'[{sinex_position[0]}, {sinex_position[1]}, {sinex_position[2]}]' + ) + else: + # Large discrepancy - warn user but still use SINEX + results['apriori_position']['valid'] = False # Mark as warning + results['apriori_position']['message'] = ( + f'âš ī¸ Large position discrepancy ({distance:.2f}m) - verify correct station. ' + f'Using SINEX: [{sinex_position[0]}, {sinex_position[1]}, {sinex_position[2]}], ' + f'RINEX: [{apriori_position[0]}, {apriori_position[1]}, {apriori_position[2]}]' + ) + else: + # RINEX didn't provide position, use SINEX + results['apriori_position']['valid'] = True + results['apriori_position']['message'] = f'Using SINEX coordinates: [{sinex_position[0]}, {sinex_position[1]}, {sinex_position[2]}]' + + return results + +def download_and_validate_sinex(target_date: datetime, marker_name: str, receiver_type: str, antenna_type: str, antenna_offset: List[float], + apriori_position: Optional[List[float]] = None, download_dir: Path = INPUT_PRODUCTS_PATH, + progress_callback: Optional[Callable] = None, stop_requested: Optional[Callable] = None) -> tuple[Optional[Path], dict]: + """ + Download SINEX file and validate RINEX-extracted values against it. + + :param target_date: The date for which to download the SINEX file + :param marker_name: 4-character marker name + :param receiver_type: Receiver type from RINEX + :param antenna_type: Antenna type from RINEX + :param antenna_offset: Antenna offset [E, N, U] from RINEX + :param apriori_position: Optional apriori position [X, Y, Z] from RINEX + :param download_dir: Directory to save the downloaded file + :param progress_callback: Optional callback for progress updates + :param stop_requested: Optional callback to check if operation should stop + :returns: Tuple of (Path to SINEX file or None, validation results dict) + """ + # Download the SINEX file + sinex_path = download_sinex_file(target_date, download_dir, progress_callback, stop_requested) + + if sinex_path is None: + return None, {'error': 'Failed to download SINEX file'} + + # Read and validate + try: + with open(sinex_path, 'r', encoding='utf-8', errors='replace') as f: + sinex_content = f.read() + + results = validate_sinex_values( + sinex_content, marker_name, + receiver_type, antenna_type, + antenna_offset, apriori_position + ) + + return sinex_path, results + + except Exception as e: + Logger.terminal(f"❌ Error reading SINEX file: {e}") + return sinex_path, {'error': f'Failed to read SINEX file: {e}'} + +def log_sinex_validation_results(results: dict, marker_name: str): + """ + Log SINEX validation results to the terminal. + + :param results: Validation results dictionary from validate_sinex_values() + :param marker_name: Marker name for logging context + """ + if 'error' in results: + Logger.terminal(f"❌ SINEX validation error: {results['error']}") + return + + if not results['marker_found']: + Logger.terminal(f"âš ī¸ Marker '{marker_name}' not found in SINEX file - validation skipped") + return + + all_valid = True + has_validations = False + Logger.terminal(f"📋 SINEX validation results for marker '{marker_name}':") + + for field in ['receiver_type', 'antenna_type', 'antenna_offset', 'apriori_position']: + field_result = results.get(field, {}) + message = field_result.get('message', '') + + if field_result.get('valid') is True: + Logger.terminal(f" ✅ {field.replace('_', ' ').title()}: {message}") + has_validations = True + elif field_result.get('valid') is False: + Logger.terminal(f" âš ī¸ {field.replace('_', ' ').title()}: {message}") + all_valid = False + has_validations = True + elif message: + # valid is None but there's a message (info only, no comparison made) + Logger.terminal(f" â„šī¸ {field.replace('_', ' ').title()}: {message}") + + if has_validations: + if all_valid: + Logger.terminal(f"✅ All SINEX validations passed for marker '{marker_name}'") + else: + Logger.terminal(f"âš ī¸ Some SINEX validations failed for marker '{marker_name}' - please review the above warnings") + else: + Logger.terminal(f"â„šī¸ SINEX data found for marker '{marker_name}' but no comparisons were made (RINEX values may be missing)") + +# endregion + if __name__ == "__main__": # Test whole file download sesh = requests.Session() diff --git a/scripts/GinanUI/app/models/execution.py b/scripts/GinanUI/app/models/execution.py index 1a4bc936c..40eebd5d8 100644 --- a/scripts/GinanUI/app/models/execution.py +++ b/scripts/GinanUI/app/models/execution.py @@ -289,9 +289,24 @@ def apply_ui_config(self, inputs): # 3. Include UI-extracted values self.edit_config("processing_options.epoch_control.start_epoch", PlainScalarString(inputs.start_epoch), False) self.edit_config("processing_options.epoch_control.end_epoch", PlainScalarString(inputs.end_epoch), False) - self.edit_config("processing_options.epoch_control.epoch_interval", inputs.epoch_interval, False) + epoch_interval = inputs.epoch_interval + epoch_tolerance = min(0.5, inputs.rinex_epoch_interval / 2) + self.edit_config("processing_options.epoch_control.epoch_interval", int(epoch_interval) if epoch_interval == int(epoch_interval) else float(epoch_interval), False) + self.edit_config("processing_options.epoch_control.epoch_tolerance", int(epoch_tolerance) if epoch_tolerance == int(epoch_tolerance) else float(epoch_tolerance), True) self.edit_config(f"receiver_options.{inputs.marker_name}.receiver_type", inputs.receiver_type, True) self.edit_config(f"receiver_options.{inputs.marker_name}.antenna_type", inputs.antenna_type, True) + + # Handle apriori_position: remove if all zeros, add if non-zero + receiver_node = self.config.get("receiver_options", {}).get(inputs.marker_name, {}) + + if all(v == 0.0 for v in inputs.apriori_position): + # Remove apriori_position if it exists and is all zeros + if "apriori_position" in receiver_node: + del receiver_node["apriori_position"] + else: + # Add / update apriori_position if non-zero + self.edit_config(f"receiver_options.{inputs.marker_name}.apriori_position", inputs.apriori_position, True) + self.edit_config(f"receiver_options.{inputs.marker_name}.models.eccentricity.offset", inputs.antenna_offset, True) @@ -327,7 +342,73 @@ def apply_ui_config(self, inputs): 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) + empty_seq = CommentedSeq([]) + empty_seq.fa.set_flow_style() + self.edit_config(f"processing_options.gnss_general.sys_options.{const}.code_priorities", empty_seq,False) + + # 6. Add SINEX file to config if available + sinex_filename = getattr(inputs, 'sinex_filename', None) + if sinex_filename: + self._add_sinex_to_config(sinex_filename) + + def _add_sinex_to_config(self, sinex_filename: str): + """ + Append the SINEX filename to the config's inputs.snx_files list. + + Does NOT overwrite existing entries - only appends if not already present. + Removes any old IGS CRD SINEX files (IGS*_CRD.SNX pattern) before adding new one. + + :param sinex_filename: Name of the SINEX file (e.g., "IGS0OPSSNX_20250310000_01D_01D_CRD.SNX") + """ + import re + + try: + # Ensure inputs section exists + if "inputs" not in self.config: + self.config["inputs"] = CommentedMap() + + # Get or create snx_files list + existing = self.config["inputs"].get("snx_files") + + if existing is None: + # Create new list with the SINEX file + new_seq = CommentedSeq([normalise_yaml_value(sinex_filename)]) + new_seq.fa.set_block_style() + self.config["inputs"]["snx_files"] = new_seq + elif isinstance(existing, CommentedSeq): + # Remove any old IGS CRD SINEX files (pattern: IGS*SNX_*_CRD.SNX) + # Keep other entries like igs_satellite_metadata.snx or tables/*.snx + igs_crd_pattern = re.compile(r'^IGS.*SNX_.*_CRD\.SNX$', re.IGNORECASE) + + # Filter out old IGS CRD files + filtered = [item for item in existing if not igs_crd_pattern.match(str(item))] + + # Check if new file is already present + if sinex_filename not in filtered: + filtered.append(normalise_yaml_value(sinex_filename)) + + # Update the list + existing.clear() + for item in filtered: + existing.append(normalise_yaml_value(item) if not isinstance(item, PlainScalarString) else item) + existing.fa.set_block_style() + else: + # Convert to list if it's a single value + old_value = str(existing) + new_seq = CommentedSeq() + + # Keep old value if it's not an IGS CRD file + igs_crd_pattern = re.compile(r'^IGS.*SNX_.*_CRD\.SNX$', re.IGNORECASE) + if not igs_crd_pattern.match(old_value): + new_seq.append(normalise_yaml_value(old_value)) + + # Add new SINEX file + new_seq.append(normalise_yaml_value(sinex_filename)) + new_seq.fa.set_block_style() + self.config["inputs"]["snx_files"] = new_seq + + except Exception as e: + Logger.terminal(f"âš ī¸ Failed to write SINEX to config: {e}") def write_cached_changes(self): write_yaml(self.config_path, self.config) diff --git a/scripts/GinanUI/app/models/rinex_extractor.py b/scripts/GinanUI/app/models/rinex_extractor.py index 5cfb6aeb9..d1534e4b3 100644 --- a/scripts/GinanUI/app/models/rinex_extractor.py +++ b/scripts/GinanUI/app/models/rinex_extractor.py @@ -123,6 +123,7 @@ def extract_obs_types_v3(line: str, obs_data: str): receiver_type = None antenna_type = None antenna_offset = None + apriori_position = None with open(rinex_path, "r", errors="replace") as f: lines = f.readlines() @@ -177,7 +178,8 @@ def extract_obs_types_v3(line: str, obs_data: str): end_epoch = format_time(y, m, d, hh, mm, ss) elif label == "INTERVAL": try: - epoch_interval = int(float(line[0:10].strip())) + raw_interval = float(line[0:10].strip()) + epoch_interval = int(raw_interval) if raw_interval == int(raw_interval) else round(raw_interval, 2) except Exception: pass elif label == "MARKER NAME": @@ -199,6 +201,14 @@ def extract_obs_types_v3(line: str, obs_data: str): antenna_offset = [e, nnn, h] except Exception: pass + elif label == "APPROX POSITION XYZ": + try: + x = float(line[0:14].strip()) + y = float(line[14:28].strip()) + z = float(line[28:42].strip()) + apriori_position = [x, y, z] + except Exception: + pass elif label == "END OF HEADER": in_header = False break @@ -250,7 +260,8 @@ def extract_obs_types_v3(line: str, obs_data: str): pass elif label == "INTERVAL": try: - epoch_interval = int(float(line[0:10])) + raw_interval = float(line[0:10]) + epoch_interval = int(raw_interval) if raw_interval == int(raw_interval) else round(raw_interval, 2) except Exception: pass elif label == "MARKER NAME": @@ -270,6 +281,14 @@ def extract_obs_types_v3(line: str, obs_data: str): antenna_offset = [e, nnn, h] except Exception: pass + elif label == "APPROX POSITION XYZ": + try: + x = float(line[0:14].strip()) + y = float(line[14:28].strip()) + z = float(line[28:42].strip()) + apriori_position = [x, y, z] + except Exception: + pass elif label == "END OF HEADER": in_header = False break @@ -313,16 +332,17 @@ def extract_obs_types_v3(line: str, obs_data: str): # Epoch interval from first two epochs if previous_observation_dt and epoch_interval is None: - t1 = datetime(*previous_observation_dt) - t2 = datetime(year, mo, dd, hh, mmn, int(ssf)) - epoch_interval = int((t2 - t1).total_seconds()) + t1 = datetime(*previous_observation_dt[:5], int(previous_observation_dt[5]), int((previous_observation_dt[5] % 1) * 1000000)) + t2 = datetime(year, mo, dd, hh, mmn, int(ssf), int((ssf % 1) * 1000000)) + raw_interval = (t2 - t1).total_seconds() + epoch_interval = int(raw_interval) if raw_interval == int(raw_interval) else round(raw_interval, 2) end_epoch = format_time(year, mo, dd, hh, mmn, ssf) if start_epoch is None: # Header didn't contain TIME OF FIRST OBS start_epoch = end_epoch - previous_observation_dt = (year, mo, dd, hh, mmn, int(ssf)) + previous_observation_dt = (year, mo, dd, hh, mmn, ssf) # Satellites from this line + continuation lines sats = chunk_sat_ids(rest) @@ -359,15 +379,16 @@ def extract_obs_types_v3(line: str, obs_data: str): ssf = float(parts[5]) if previous_observation_dt and epoch_interval is None: - t1 = datetime(*previous_observation_dt) - t2 = datetime(y, mo, dd, hh, mmn, int(ssf)) - epoch_interval = int((t2 - t1).total_seconds()) + t1 = datetime(*previous_observation_dt[:5], int(previous_observation_dt[5]), int((previous_observation_dt[5] % 1) * 1000000)) + t2 = datetime(y, mo, dd, hh, mmn, int(ssf), int((ssf % 1) * 1000000)) + raw_interval = (t2 - t1).total_seconds() + epoch_interval = int(raw_interval) if raw_interval == int(raw_interval) else round(raw_interval, 2) end_epoch = format_time(y, mo, dd, hh, mmn, ssf) if start_epoch is None: start_epoch = end_epoch - previous_observation_dt = (y, mo, dd, hh, mmn, int(ssf)) + previous_observation_dt = (y, mo, dd, hh, mmn, ssf) sats = [] if len(parts) > 8: @@ -600,6 +621,7 @@ def reorder_by_priority(rinex_codes, priority_order): "receiver_type": receiver_type, "antenna_type": antenna_type, "antenna_offset": antenna_offset, + "apriori_position": apriori_position, "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"], diff --git a/scripts/GinanUI/app/resources/Yaml/default_config.yaml b/scripts/GinanUI/app/resources/Yaml/default_config.yaml index b0350aeb0..1cbd0fb23 100644 --- a/scripts/GinanUI/app/resources/Yaml/default_config.yaml +++ b/scripts/GinanUI/app/resources/Yaml/default_config.yaml @@ -1,25 +1,25 @@ inputs: inputs_root: #USER_SET - atx_files: [igs20.atx] # Required - igrf_files: [tables/igrf14coeffs.txt] - erp_files: [finals.data.iau2000.txt] - planetary_ephemeris_files: [tables/DE436.1950.2050] + atx_files: [igs20.atx] #AUTO + igrf_files: [tables/igrf14coeffs.txt] #AUTO + erp_files: [finals.data.iau2000.txt] #AUTO + planetary_ephemeris_files: [tables/DE436.1950.2050] #AUTO troposphere: - gpt2grid_files: [tables/gpt_25.grd] + gpt2grid_files: [tables/gpt_25.grd] #AUTO tides: - ocean_tide_loading_blq_files: [tables/OLOAD_GO.BLQ] # Required if ocean loading is applied - atmos_tide_loading_blq_files: [tables/ALOAD_GO.BLQ] # Required if atmospheric tide loading is applied - ocean_pole_tide_loading_files: [tables/opoleloadcoefcmcor.txt] # Required if ocean pole tide loading is applied + ocean_tide_loading_blq_files: [tables/OLOAD_GO.BLQ] #AUTO # Required if ocean loading is applied + atmos_tide_loading_blq_files: [tables/ALOAD_GO.BLQ] #AUTO # Required if atmospheric tide loading is applied + ocean_pole_tide_loading_files: [tables/opoleloadcoefcmcor.txt] #AUTO # Required if ocean pole tide loading is applied snx_files: # Use a wild card (*) to include all files matching the description in the directory - - igs_satellite_metadata.snx - - tables/sat_yaw_bias_rate.snx - - tables/qzss_yaw_modes.snx - - tables/bds_yaw_modes.snx + - igs_satellite_metadata.snx #AUTO + - tables/sat_yaw_bias_rate.snx #AUTO + - tables/qzss_yaw_modes.snx #AUTO + - tables/bds_yaw_modes.snx #AUTO #- "*.SNX" satellite_data: @@ -49,15 +49,15 @@ outputs: outputs_root: #USER_SET gpx: - output: true + output: true #USER_SET filename: __.GPX pos: - output: true + output: true #USER_SET filename: __.POS trace: level: 2 output_receivers: false - output_network: true + output_network: true #USER_SET receiver_filename: __.TRACE network_filename: __.TRACE output_residuals: true @@ -126,6 +126,7 @@ receiver_options: TEST: #change to header name of RNX receiver_type: # #USER_SET (string) antenna_type: # #USER_SET (string) + apriori_position: [ 0.0000, 0.0000, 0.0000 ] # [floats] #USER_SET models: eccentricity: enable: true @@ -140,10 +141,11 @@ processing_options: slr: false # Process SLR observations epoch_control: - start_epoch: #RNX - end_epoch: #RNX + start_epoch: #USER_SET + end_epoch: #USER_SET #max_epochs: 2880 # Future user set. - epoch_interval: #USER SET + epoch_interval: #USER_SET + epoch_tolerance: #AUTO: minimum of "0.5" and "rinex_epoch_interval / 2" wait_next_epoch: 3600 #USER_SET seconds (make large for post-processing) gnss_general: @@ -152,35 +154,33 @@ processing_options: use_primary_signals: true sys_options: gps: - process: false + process: false #USER_SET reject_eclipse: false - code_priorities: [L1W, L1C, L1X, L2W, L2C, L2X, L2S, L2L, L5Q, L5X] + code_priorities: [L1W, L1C, L1X, L2W, L2C, L2X, L2S, L2L, L5Q, L5X] #USER_SET gal: - process: false + process: false #USER_SET reject_eclipse: false # clock_codes: [] - code_priorities: [L1C, L1X, L5Q, L5X, L6C, L6X, L7Q, L7X] + code_priorities: [L1C, L1X, L5Q, L5X, L6C, L6X, L7Q, L7X] #USER_SET bds: - process: false + process: false #USER_SET reject_eclipse: false # clock_codes: [] - code_priorities: [L2I, L7I, L6I, L5P] + code_priorities: [L2I, L7I, L6I, L5P] #USER_SET glo: - process: false + process: false #USER_SET reject_eclipse: false # clock_codes: [] - code_priorities: [L1P, L1C, L2P, L2C] + code_priorities: [L1P, L1C, L2P, L2C] #USER_SET qzs: - process: false + process: false #USER_SET reject_eclipse: false # clock_codes: [] - code_priorities: [L1C, L1X, L2L, L2X, L5Q, L5X] - - + code_priorities: [L1C, L1X, L2L, L2X, L5Q, L5X] #USER_SET preprocessor: # Configurations for the kalman filter and its sub processes cycle_slips: # Cycle slips may be detected by the preprocessor and measurements rejected or ambiguities reinitialised @@ -253,7 +253,6 @@ processing_options: scdia: true # SCDIA test estimation_parameters: - receivers: global: pos: diff --git a/scripts/GinanUI/app/resources/assets/icons.qrc b/scripts/GinanUI/app/resources/assets/icons.qrc index 69fe2545d..ed16b18e8 100644 --- a/scripts/GinanUI/app/resources/assets/icons.qrc +++ b/scripts/GinanUI/app/resources/assets/icons.qrc @@ -1,5 +1,8 @@ + help.png + help_hover.png + help_selected.png checkbox_selected.png checkbox_selected_disabled.png checkbox_selected_hover.png diff --git a/scripts/GinanUI/app/resources/assets/icons_rc.py b/scripts/GinanUI/app/resources/assets/icons_rc.py index 0952d112b..0fe27ea1e 100644 --- a/scripts/GinanUI/app/resources/assets/icons_rc.py +++ b/scripts/GinanUI/app/resources/assets/icons_rc.py @@ -6,6 +6,105 @@ from PySide6 import QtCore qt_resource_data = b"\ +\x00\x00\x02\xf0\ +\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\x02\xa5IDATx\x01\xec\x981\x8b\x13A\ +\x14\xc7\xdf\x1bM!\x08v\x8ax\x5c\xb2\x07\xda\x88\x8d\ +\xd7(\xd8\x08\xe2\xc6\xc3\xd62\x9a\x04\xed\xb5\x14A\xef\ +\xd4\xc2Nk%\xb7Q\xfc\x04\x16\xeeF8\x1bA\xb0\ +Q\x10\x1b\x8b\xcbn\xd0B\x11-EQ\xf69c2\ +a=2\x1b\x92}\xb3\xeb\xc1.\xfb\xee\xbd\x99\xddy\ +\xef\xff\x9b\x97\xecr\x11\xb0\xcd\x8f\x12\xa0\xe8\x06\x96\x1d\ +(;\x90q\x07R?B5\xb7y\xd7\xa9\xb7\xa8H\ +S\x1a \xe50\x02,\xb9\xad5D\xbc\x9c\xb26\x97\ +KJ\x83\xd2b*f\x04 \x84\xeb\xa6Ey\xcf\xa7\ +i1\x02\xe4-r\xdez%\xc0\xbc;\xc7\xb5\xceJ\ +\x07\x10\xc1#A\xfb\xc3\xc0\xc30\xa8\xee\x88\x09\x96\xe5\ +\xe7\xd8\xe7\x12\x9d\xcc\xc3\x0b\x80tK\x89\xee\xfb^;\ +z\xda\xfd4,\xb4\x1a\x0fz\xde\xeb\xc8\xf7V\xd45\ +97\x90\xc6v\xb2\x01\xc4(\x8e\x86~w\xea\x93K\ +B\xd4\x88\xe0+\x17\x01\x17\xc0\xe3\x81\xdfy\xa3EU\ +O7\x1c\xa7\xde\xfc\xec\xe8\x97\xa0\xdb\x1aucx\xc7\ +o\x10\x87\x86Q\xf6\xbf,\x00rW\x1bZ\x8as\xa6\ +}I\x88\x9d}\x00\xdc\x0b\xfa@\xd8\xa7`\xf4\xf0c\ +\xaf\xf3M\xc7Y=\x0b\x80\x12'\xed\xc1\xc2\xf1s\xbb\ +\x80\xe8\xbeI\xd4\xa2{\xe1\xb0\xe9\xda\xbc\xf3,\x00\xa3\ +\xe2\x17+{v\x7f\x1f\xc5\x13\x1d\x09\xf81\xf1B\x86\ +IN\x80\xa92>\xf8\x0f7\xa7\xde4\xe3\x0d\xb9\x01\ +\x10\xd0\x9a\xd6\xb6\xb4\xd2<\xa2\xe3\xac~\x06\x80,\xa5\ +p#\x0a\xba\xab:\x03\xc5\xf8V\xc7Y\xbd}\x00\x82\ +Wa\xb0~J\x0bu\xea\xed\xf7:\xe6\xf0\xb6\x01\x06\ +a\xcf;\xa6\x85\xca'\xd5\x0b\x00b{\x07\xa8\xbcV\ +\x01\xc2\xc0\xab\xa9\x22\xca\x1c\xb7u[\xfa\x13\xd2XO\ +\x9b\x00O\xb4\xd2\x85\xb3\xe7\x0f\x00\xc25=\xe6\xf4\xd6\ +\x00D,\xc6\x82+\xbf\xc4KN\xd1\xc9\x5c\xd6\x006\ +\x9fu\xde\x8d\x0b!.\x8ec\xe6\xc0\x1a\x80\xfc\xc2\x86\ +\xda\x985\xff\x93\xce\x1a\x80\xac\xa2\xbe\xc0\xda\xe4\xd0\xce\ +i\x13\xc0\x8e\xe2-YK\x80-\x1b2\x1e\x12\xe0U\ +\xf9\x1e\x90\xff\x13{('\xbfH\xb3rZ\xeb@\x14\ +\xac\xdf\xd1\x8a\x7f\xc6\xb0\xaccno\x0d )\xb4\x02\ +p09\xe6\x8c\xad\x01\xc8G(U\xdd\xf6I\xc7m\ +6\x84\x80\x0dN\xd1\xc9\x5c\xd6\x00T\x11\x81\xf4\x1c\x10\ +\x1f\xa9\xd8\x96Y\x05\xb0%:\x99\xb7\x04H\xeeF\x11\ +\xb1\xb1\x03Dt\xaf\x08A\x93j\xa6i1\x02D\xbd\ +\xee\x15$\xb89)a\x9esJ\x83\xd2b\xaai\x04\ +P\x0b\xfa=\xef\x86~\x9b\xce\xe4\xff\xfe*\xad~\x99\ +\xcenJ\x83\xd2b\xb2T\x00\xd3\xa2\xffi\xbe\x04(\ +\xba\x1be\x07\x8a\xee\xc0\x1f\x00\x00\x00\xff\xff!,]\ +\xe4\x00\x00\x00\x06IDAT\x03\x00\xd3\xfa$p\x9b\ +=\x1b\x85\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x02\xf3\ +\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\x02\xa8IDATx\x01\xec\x99\xbf\x8b\xd4@\ +\x14\xc7\xdf\x8b\xab \x88v\x8a\x8d\xbb\x1b\x0f-l\xe4\ +\xecU\xe40\x11;\xc1BTn\xa3`'\xa8\xb5x\ +\xfe\xf8\x03\xce\xc2\xce\x22\xf1\x0e\xfc\x0b\xc4\xcd\x0a6\xda\ +z\x85\x85\x16\xeamV\x14\x0b\xfd\x07,\xd6<\xdf\x08\ +\x93\x1b\x8f\x9d\x90\xbb\xcc${\x5c\x96\x0c\xef\xcd\x9b_\ +\xdfO^\x98\x9d\xec:\xb0\xc5?\x0d@\xdd\x09l2\ +\xd0d\xa0\xe4\x1d\xc8}\x84:^o\xb1\xeb\x07Tg\ +\x11\x1a \xe7\xa3\x05p\xbd\xe0\x01\x22\xde\xca\x19[I\ +\x93\xd0 \xb4\xe8\x16\xd3\x02\x10\xc2=\xdd\xa0\xaa\xe3y\ +Z\xb4\x00\xaa\xc8$\x0e\xb1\x8e\xa2j\xd0\xf9\x85\x00t\ +\x83\xa7!\xde\x00L\xca\x02\xefZW\xba\xfe\xb5\x1fl\ +y\x07\xeb\xa5\x1d?Xi\x9f\x0d\xceL\xea[6f\ +4\x03\x1d\xde\xb9\x84h\x16\xb5\x0c@\x07\xd9\xf2\xc5\xfb\ +\x08\xc0\xac\xe3\xc0k\xd1v\xd8\xbb>\xc3Ac\x971\ +\x00\xbe\xcb\xef\xb0\xc0\xce\x95b\xfa\xd9\x9d\xbb\xb8\xcf\x14\ +\x81\x11\x00\xd7\x0f.!\xc0\x895Q\x948\xe9\xf8\x80\ +\xdc\xb9v \xec_k\xe3\xdc\xb4\xf6|U\xebe|\ +#\x00\x04\xf0<\x13\x81\xf44\x89#w\xf5\xd5\xf2O\ +\x19\xfb\xd2\x0f\x7f\x09\x18Yg;]\x19\xf8'\x8e\xe0\ +&\x0b\xfb\x9d\xf4\xa3\x1bl'^\x0c\xfaabC\x89\ +\xa0\x91\x0c\x88\xf5\x93A\xf8\x84Av\x0b_W\x10p\ +\xa7\xaem\xb3qc\x00\xc5\x04\xd0\x91b\xfd\x8a\xf7\xaa\ +\x0c\xa0\xe3\xf7\xee+\xb2\xde+~)w\x03\x00\x9b_\ +\xa7\xed\xcd\x9f\xe7\xc7gA\xce\xc0\x8f\xdaq\xe9\x97\xb5\ +\xd6\x01\xda^0\xeb\xa0\xf3B\x0aE\xe7\xcfQ\xe9\x9b\ +\xb0V\x01\xdc\xb9\xab\x87\x1c\x84\x95L(\xd1\xc9\xe1\xcb\ +\xa5OY\xdd\x80c\x15\x80Z\xad\xec\x0b\x0b\x01\xee$\ +\x83\xe8\xad\x01\xcd\xffMa\x0d\xa0\xed\xf7.(+}\ +\x1f\xc6\xe1\xa2R7\xe6Z\x03@\xc0\x87R\xe5\x18\xd3\ +\xd3\xd27m-\x02\xc01)\xf6[\xff\xd9\xaa\xf4M\ +[k\x00\x04\xd4\x95\xc5\xb4hu>k\x00\xa38\x1a\ +\xc9\xa2.h\xda\xb7\x06`Z\xa8n\xbe\x06@wg\ +\xf8\xf5\xf1\x11\x17~'\x0eh\xe6\xdc\xe5\xbd\xba~e\ +\xe363pW\x8a\x1b\xd3\xae\x8f\xd27mm\x02\xa8\ +Z\x8d\x1e\x1f\xd4\x89\xad\x01\xf0\x89\x13\x81\xe8\x0d\x10,\ +\x8d\xe2\xd0\xcaO*\x02\xc4\x1a\x80\x98\x9c\xcf>\xa7\xf8\ +Mm^\xf8\xb6\x8aU\x00[\xa2\xd5y\xb7\x07\x80\xdc\ +\x0e\xab\xb6\xea\x9d\xd6\xf9\xda\x0c\x10\xd1c\xdd\xa0\xaa\xe3\ +yZ\xb4\x00\xa3At\x1b\x09\xb2#q\xd5\xa2\xe5z\ +B\x83\xd0\x22\xeb\xeb\xad\x16@t\x1c\x0e\xc2\x05\xb1\x1d\ +n\xb8\x18\xfcCDh\x10Zt%\x17@7h\x9a\ +\xe2\x0d@\xdd\xd9h2Pw\x06\xfe\x02\x00\x00\xff\xff\ +\xf4\x9a\x09p\x00\x00\x00\x06IDAT\x03\x00\xb0\xee\ +\x9ep\xb7\xab\x0e\xf6\x00\x00\x00\x00IEND\xaeB\ +`\x82\ \x00\x00\x01w\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -32,6 +131,56 @@ Z\x1f\xf9\x92\x88\xae\xeb\x1ey\x9e_\xb9\x05\x00X\x14\ \xa5>:\xd9HN\x8c\x13l%\x00\x00\x00\x00IE\ ND\xaeB`\x82\ +\x00\x00\x02\xf5\ +\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\x02\xaaIDATx\x01\xec\x99\xbf\x8b\x13A\ +\x14\xc7\xdfK\xce\x03\x1b\xed\x14\x1bA\x11-l\xe4\xc2\ +\xe6\xb4Q\xb1\xb5\xb3\x13\x04\xbb+d7dW\xec\xc4\ +\xf3Gcc6\xdcD8\xb0S\xf0/\xb0\xb5Q\xb8\ +b\xe2\xa1\x16Z(\xa2\x82X\xe8? \xc6\xe49\xb3\ +\xcb\xb8s\x90Ys\xd9\x99l\x8e\xdbeg\xe7\xed\x9b\ +\x997\xdf\xcf\xbccn\xd8\xd4`\x87_\x15@\xd9\x09\ +\xac2Pe\xa0\xe0\x0a\xe4\xfe\x09yA\x1c7\x83\x98\ +\xca,RC\x1e\xa3\x11\xc0\x0b:w\x10\xa0\x0d%_\ +R\x83\xd4b\x92a\x04@\xc0[\xa6A\xb3\xf6\xe7i\ +1\x02\xe8\x229\x0b\xb1\x8c\xa2k0\xd9\x13\x01\x98\x06\ +\xcf\x83\xbf\x02\x18\x97\x05\xcf\xef^\xf1\x82\xf8{\xb2{\ +\xf9\xf1\xc8\xf3\xe3\xcd\xa6\x1f_\x18\xd7\xb7\xa8\xcfj\x06\ +<\xb1sI\xd1\x88\xf4\x04\x01\x0e%\xe2\x10\x10\x11\x96\ +\x00\xe1\xb9l;s\xads,\xf1[zX\x03h\x06\ +\xddWB\xeb\x7fw\xaea\x1d?6V\xee\xef\xb7\xa4\ +\x1f\xac\x00\x88\x95\xbd\x0c@\x8dL\x14}^\x18\xfc9\ +\xa8v\xae\x01\xd4\x0fdm\x00\xf5\xc5\xc5\xaf\xfa{\x11\ +\xdb\x0a\x80\x10\xf0T\x94\xe4&\xa0G\x9cEG7\xd6\ +o\xfcH\x1c\xe2\xf1\x9a\xb5~J\x18a\xa67\xe2|\ +e \x15G\x01\x10\xfd\xea\xb3h%U9\xe6I\xf0\ +n\x8c\xb7\x90\xcbV\x06@\xacz\x8f\xf7\xa2\xbd\xb9j\ +\x10\xf6\xe4\xb6O\xd1h\x0d`\xc2\xb9\x8fO\xd8o\xe2\ +n3\x03h\xfa\x0fnk\xaa\xdejv!s\x1b\x00\ +\xd3\xcf#v\xa9\x8b\x80\xb5U\x15\x81\xb3\xf0\x94\xb2\x8b\ +\xd6\xce\x01N\xb7\xe3%!\xf2\x99(\xe9=\x1a\x9dH\ +\x0d;O\xa7\x00\x8dV\xf7\xf0h\x08\x9b\x99T:\xcb\ +\x1f^\xff\x90\xbd\x17\xb7\x9c\x02\xd4\x89\xb2\x7fX\x84\x91\ +\xd8\xa9^\x16\x97\xbc5\x823\x00/\x88/eS\xe1\ +7\xdek\xc7\xd9\xbb=\xcb\x19\x00\x02\xdcU2\x17\xa0\ +v^\xd9\xb6\xeb\x9a\xed\x80Z\xbc\x93\xca\xde`\xadO\ +\xca\xb6];\x03\xf8=\x80#\xaa\xd8\x16\xad\xc7s\x06\ +\xf0f=\xfc\xa2\x8a>\xa1m\xdb\x19\x80m\xa1\xa6x\ +\x15\x80ie\x96\x83\xce=q\x84H\xbe\xea-\x07k\ +\xfbL\xfd\x8a\xfa\x9de\x80\x00o*qD\xc3\xf7\xca\ +\xb6];\x03\xd8\x22\x14\xd1\xea\xf1A\x8f\xed\x0c@\x9c\ +8Q|\x89x!&{\xccY\xdb\xc9'\x15\x11\x1b\ +\x9c\x01\xc8\xe0|-<\xc7YxU\xda\xae\x8aS\x00\ +W\xa2\xf5\xb8\xbb\x03@m\x87\xb3\xae\xf5\x956\xd9\xc6\ +\x0c\x10@\x17\xe6\xe4\xca\xd3b\x04\xe8\xb30$\xa0\x7f\ +G\xe2\xb2X\xa4\x06\xa9\xc54\xbf\x11@\x0e\xe8\xb3h\ +\x95O\xf3\xe3\x86\xc51}\xa1Aj1\x95\x5c\x00\xd3\ +\xa0y\xf2W\x00eg\xa3\xca@\xd9\x19\xf8\x0b\x00\x00\ +\xff\xff\xc8\x84k\xc6\x00\x00\x00\x06IDAT\x03\x00\ +\x93\x06\x9cp\x90\xeb\xed\xbc\x00\x00\x00\x00IEND\ +\xaeB`\x82\ \x00\x00\x01w\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -276,11 +425,24 @@ \x00\x06\xfa^\ \x00i\ \x00c\x00o\x00n\ +\x00\x11\ +\x06$\x0d\x87\ +\x00h\ +\x00e\x00l\x00p\x00_\x00s\x00e\x00l\x00e\x00c\x00t\x00e\x00d\x00.\x00p\x00n\x00g\ +\ +\x00\x0e\ +\x04\xa9\xf2'\ +\x00h\ +\x00e\x00l\x00p\x00_\x00h\x00o\x00v\x00e\x00r\x00.\x00p\x00n\x00g\ \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\x08\ +\x0c3Z\x87\ +\x00h\ +\x00e\x00l\x00p\x00.\x00p\x00n\x00g\ \x00\x1e\ \x01\xa4;\xa7\ \x00c\ @@ -326,25 +488,31 @@ 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\x02\x00\x00\x00\x0c\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\x00\x9e\x00\x00\x00\x00\x00\x01\x00\x00\x0a_\ \x00\x00\x01\x9b\x8b|\xd9\x1d\ -\x00\x00\x01\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x0ca\ +\x00\x00\x02\x12\x00\x00\x00\x00\x00\x01\x00\x00\x15E\ \x00\x00\x01\x9b\x8b\x7f\x84`\ -\x00\x00\x01\xf6\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xc2\ +\x00\x00\x02V\x00\x00\x00\x00\x00\x01\x00\x00\x16\xa6\ \x00\x00\x01\x9b\x8b|\xb8I\ -\x00\x00\x00\xc0\x00\x00\x00\x00\x00\x01\x00\x00\x053\ +\x00\x00\x006\x00\x00\x00\x00\x00\x01\x00\x00\x02\xf4\ +\x00\x00\x01\x9c/\xcb\xb2\x98\ +\x00\x00\x01 \x00\x00\x00\x00\x00\x01\x00\x00\x0e\x17\ \x00\x00\x01\x9b\x8b}Y\x86\ \x00\x00\x00\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01\x9c/\xcb\xa4\x8b\ +\x00\x00\x00X\x00\x00\x00\x00\x00\x01\x00\x00\x05\xeb\ \x00\x00\x01\x9b\x8b|\xd9\x1d\ -\x00\x00\x00\x80\x00\x00\x00\x00\x00\x01\x00\x00\x02\xf6\ +\x00\x00\x00\xe0\x00\x00\x00\x00\x00\x01\x00\x00\x0b\xda\ \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`\x00\x00\x00\x00\x00\x01\x00\x00\x0fv\ \x00\x00\x01\x9b\x8b|\x97\x0f\ -\x00\x00\x01l\x00\x00\x00\x00\x00\x01\x00\x00\x0a\xe1\ +\x00\x00\x01\xcc\x00\x00\x00\x00\x00\x01\x00\x00\x13\xc5\ \x00\x00\x01\x9b\xc3\xf0\x1b\xf5\ -\x00\x00\x010\x00\x00\x00\x00\x00\x01\x00\x00\x08\xd7\ +\x00\x00\x00\x88\x00\x00\x00\x00\x00\x01\x00\x00\x07f\ +\x00\x00\x01\x9c/\xcb\xbe\x9e\ +\x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x00\x11\xbb\ \x00\x00\x01\x9b\x8b}\x1f{\ " diff --git a/scripts/GinanUI/app/utils/common_dirs.py b/scripts/GinanUI/app/utils/common_dirs.py index cbd9c144b..b3601370d 100644 --- a/scripts/GinanUI/app/utils/common_dirs.py +++ b/scripts/GinanUI/app/utils/common_dirs.py @@ -5,8 +5,8 @@ def get_base_path(): """Get the base path for resources, handling both development and PyInstaller bundled modes.""" if getattr(sys, 'frozen', False): # Running in PyInstaller bundle - sys._MEIPASS is _internal/ - # and app folder is at _internal/app/ - return Path(sys._MEIPASS) / "app" + # and app folder is at _internal/scripts/GinanUI/app/ + return Path(sys._MEIPASS) / "scripts" / "GinanUI" / "app" else: # Running in development mode - __file__ is in app/utils/ return Path(__file__).parent.parent @@ -14,8 +14,8 @@ def get_base_path(): 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" + # Running in PyInstaller bundle - look in _internal/scripts/GinanUI/docs/ + return Path(sys._MEIPASS) / "scripts" / "GinanUI" / "docs" / "USER_MANUAL.md" else: # Running in development mode - __file__ is in app/utils/ return Path(__file__).parent.parent.parent / "docs" / "USER_MANUAL.md" diff --git a/scripts/GinanUI/app/utils/workers.py b/scripts/GinanUI/app/utils/workers.py index 1ebb54f3b..e6bef87c8 100644 --- a/scripts/GinanUI/app/utils/workers.py +++ b/scripts/GinanUI/app/utils/workers.py @@ -2,12 +2,21 @@ import traceback from datetime import datetime from pathlib import Path -from typing import Optional +from typing import Optional, List import pandas as pd from PySide6.QtCore import QObject, Signal, Slot -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.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, + download_and_validate_sinex, + log_sinex_validation_results +) from scripts.GinanUI.app.utils.common_dirs import INPUT_PRODUCTS_PATH from scripts.GinanUI.app.utils.logger import Logger @@ -246,4 +255,85 @@ def check_stop(): 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 + self.error.emit(f"Error fetching BIA: {e}") + +class SinexValidationWorker(QObject): + """ + Downloads the IGS CRD SINEX file and validates RINEX-extracted values against it. + + :param target_date: The date for which to download the SINEX file + :param marker_name: 4-character marker name from RINEX + :param receiver_type: Receiver type from RINEX + :param antenna_type: Antenna type from RINEX + :param antenna_offset: Antenna offset [E, N, U] from RINEX + :param apriori_position: Optional apriori position [X, Y, Z] from RINEX + :param download_dir: Directory to save the downloaded file + """ + finished = Signal(Path, dict) # Emits (sinex_path, validation_results) + error = Signal(str) # Emits error message string + progress = Signal(str, int) # Emits (description, percent) for progress updates + + def __init__(self, target_date: datetime, marker_name: str, receiver_type: str, antenna_type: str,antenna_offset: List[float], + apriori_position: Optional[List[float]] = None, download_dir: Path = INPUT_PRODUCTS_PATH): + super().__init__() + self.target_date = target_date + self.marker_name = marker_name + self.receiver_type = receiver_type + self.antenna_type = antenna_type + self.antenna_offset = antenna_offset + self.apriori_position = apriori_position + self.download_dir = download_dir + 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.terminal(f"đŸ“Ļ SINEX validation cancelled") + self.error.emit("SINEX validation cancelled") + return + + def check_stop(): + return self._stop + + # Download and validate SINEX file + sinex_path, results = download_and_validate_sinex( + self.target_date, + self.marker_name, + self.receiver_type, + self.antenna_type, + self.antenna_offset, + self.apriori_position, + self.download_dir, + progress_callback=self.progress.emit, + stop_requested=check_stop + ) + + # Check again after download + if self._stop: + Logger.terminal(f"đŸ“Ļ SINEX validation cancelled") + self.error.emit("SINEX validation cancelled") + return + + if sinex_path is None: + self.error.emit("Failed to download SINEX file") + return + + # Log the validation results + log_sinex_validation_results(results, self.marker_name) + + # Emit successful result + self.finished.emit(sinex_path, results) + + except Exception as e: + if self._stop: + self.error.emit("SINEX validation cancelled") + return + tb = traceback.format_exc() + Logger.terminal(f"âš ī¸ Error during SINEX validation:\n{tb}") + self.error.emit(f"Error during SINEX validation: {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 968bf6a57..bf176d930 100644 --- a/scripts/GinanUI/app/views/main_window.ui +++ b/scripts/GinanUI/app/views/main_window.ui @@ -88,7 +88,7 @@ text-align: right; - Ginan-UI v4.1.0 + Ginan-UI v4.1.1 Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -333,24 +333,34 @@ QPushButton:disabled { 10 - - - - - 0 - 0 - + + + + Receiver Type - - false + + + + + + + 0 + 0 + + + + + 16777215 + 16777215 + - PPP Provider + PPP Project - - + + 0 @@ -377,42 +387,26 @@ QComboBox:disabled { - Select one + Import text - - - - - 0 - 0 - + + + + false - - PointingHandCursor + + Static - - QComboBox { - background-color: #2c5d7c; - color: white; - padding: 0px 8px; - font: 13pt "Segoe UI"; - text-align: left; -} -QComboBox:hover { - background-color: #214861; -} -QComboBox:disabled { - background-color: rgb(120, 120, 120); -} + + + + + + Antenna Type - - - Select one - - @@ -431,25 +425,23 @@ QComboBox:disabled { - - + + + + PPP Series + + + + + - + 0 0 - - false - - - background:transparent;border:none; - - Antenna Offset - - - true + Mode @@ -463,8 +455,8 @@ QComboBox:disabled { - - + + 0 @@ -491,50 +483,11 @@ QComboBox:disabled { - Select one + Import text - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - PPP Project - - - - - - - PPP Provider - - - - - - - - 0 - 0 - - - - false - - - @@ -568,49 +521,31 @@ QComboBox:disabled { - - + + - PPP Series + Data Interval - - + + - + 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); -} + + false - Interval (Seconds) + PPP Series - - + + 0 @@ -637,13 +572,13 @@ QComboBox:disabled { - Select one or more + Select one - - + + 0 @@ -670,13 +605,13 @@ QComboBox:disabled { - Import text + Select one - - + + 0 @@ -705,30 +640,54 @@ QPushButton:disabled { } - Start / End + 0.0, 0.0, 0.0 - - + + - + 0 0 - - false + + PointingHandCursor + + QComboBox { + background-color: #2c5d7c; + color: white; + padding: 0px 8px; + font: 13pt "Segoe UI"; + text-align: left; +} +QComboBox:hover { + background-color: #214861; +} +QComboBox:disabled { + background-color: rgb(120, 120, 120); +} + + + + Select one or more + + + + + + - Receiver Type + Antenna Offset - - + + - + 0 0 @@ -736,8 +695,14 @@ QPushButton:disabled { false + + background:transparent;border:none; + - PPP Series + Antenna Offset + + + true @@ -748,15 +713,56 @@ QPushButton:disabled { - - + + - Antenna Offset + Time Window - - + + + + + 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); +} + + + Interval (Seconds) + + + + + + + PPP Provider + + + + + 0 @@ -783,13 +789,13 @@ QComboBox:disabled { - Import text + Select one - - + + 0 @@ -803,9 +809,9 @@ QComboBox:disabled { QPushButton { background-color: #2c5d7c; color: white; - padding: 0px 8px; + padding: 2px 8px; font: 13pt "Segoe UI"; - text-align: left; + text-align: center; } QPushButton:hover { background-color: #214861; @@ -818,35 +824,80 @@ QPushButton:disabled { } - 0.0, 0.0, 0.0 + Reset Config - - + + - + 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); +} + - Mode + Start / End - - - - false + + + + + 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); +} - Static + Show Config - - + + 0 @@ -857,74 +908,57 @@ QPushButton:disabled { false - Time Window + PPP Provider - - - - Antenna Type + + + + + 0 + 0 + - - - - - - Time Window + + false - - - - Receiver Type - - - - Data Interval + + + + + 0 + 0 + + + + false - - + + - + 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); -} + + false - Reset Config + Time Window - - + + 0 @@ -938,9 +972,9 @@ QPushButton:disabled { QPushButton { background-color: #2c5d7c; color: white; - padding: 2px 8px; + padding: 0px 8px; font: 13pt "Segoe UI"; - text-align: center; + text-align: left; } QPushButton:hover { background-color: #214861; @@ -953,7 +987,14 @@ QPushButton:disabled { } - Show Config + 0.0, 0.0, 0.0 + + + + + + + Apriori Position @@ -1688,12 +1729,27 @@ QPushButton:disabled { background-color: rgb(120,120,120); } false + + QPushButton { + width: 40px; + height: 40px; + border: none; + background: transparent; + image: url(:/icon/help.png); +} + +QPushButton:hover { + image: url(:/icon/help_hover.png); +} + +QPushButton:pressed { + image: url(:/icon/help_selected.png); +} + + - - - 32 diff --git a/scripts/GinanUI/docs/USER_MANUAL.md b/scripts/GinanUI/docs/USER_MANUAL.md index c8f24d263..3b048e817 100644 --- a/scripts/GinanUI/docs/USER_MANUAL.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 v4.1.0 -### Last Updated: 22nd January 2026 +### Version: Release v4.1.1 +### Last Updated: 13th February 2026 ## 1. Introduction @@ -521,7 +521,7 @@ Click the "Reset Config" button in the General tab. This will regenerate the con 2. On the next processing run, a clean configuration file will be generated from the template at `scripts/GinanUI/app/resources/Yaml/default_config.yaml` -**For executable releases of Ginan-UI**, the config is located at `_internal/app/resources/ppp_generated.yaml` +**For executable releases of Ginan-UI**, the config is located at `_internal/scripts/GinanUI/app/resources/ppp_generated.yaml` **Warning:** Invalid YAML syntax (like incorrect indentation, mismatched quotes, and malformed lists) will cause PEA to fail. Please verify your formatting if you encounter configuration-related errors in the logs. diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 52832393e..f60d80ea3 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -8,8 +8,8 @@ plotext>=4.2 requests_oauthlib>=1.3.1 werkzeug>=2.2.2 requests>=2.27.1 -numpy>=1.21.6 # don't care of specific numpy and pandas versions -pandas>=1.1.0 #MongoDash required version +numpy>=1.21.6 +pandas>=1.1.0,<3.0.0 # pip doesn't seem to notice that gnssanalysis currently pins 2.3.3, instead installing 3.0.0 matplotlib>=3.5.2 ruamel.yaml>=0.17.21 boto3 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0a180ee2e..354758528 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -79,6 +79,12 @@ else () option(ENABLE_OPTIMISATION "ENABLE_OPTIMISATION" ON) endif () +# Library linking options - allow packagers to override defaults +# Default to static linking for vcpkg/development, but allow dynamic for distributions +option(USE_STATIC_LIBS "Use static libraries (Boost, yaml-cpp)" ON) +option(USE_STATIC_BOOST "Use static Boost libraries" ${USE_STATIC_LIBS}) +option(USE_STATIC_YAML_CPP "Use static yaml-cpp library" ${USE_STATIC_LIBS}) + find_program(CCACHE_FOUND ccache) if(CCACHE_FOUND) message(STATUS "Setting ccache on") @@ -185,15 +191,33 @@ if (CMAKE_USE_PTHREADS_INIT) set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pthread") endif() -set(YAML_CPP_USE_STATIC_LIBS ON) +# Configure yaml-cpp linking +if(USE_STATIC_YAML_CPP) + set(YAML_CPP_USE_STATIC_LIBS ON) + message(STATUS "Using static yaml-cpp") +else() + set(YAML_CPP_USE_STATIC_LIBS OFF) + message(STATUS "Using dynamic yaml-cpp") +endif() find_package(YAML_CPP 0.6.2 REQUIRED) # OpenSSL - use MODULE mode for vcpkg compatibility (wrapper handles it) find_package(OpenSSL REQUIRED) +# Configure Boost linking +if(USE_STATIC_BOOST) + set(Boost_USE_STATIC_LIBS ON) + message(STATUS "Using static Boost libraries") +else() + set(Boost_USE_STATIC_LIBS OFF) + # Dynamic Boost requires additional compile definitions + add_compile_definitions(BOOST_LOG_DYN_LINK) + message(STATUS "Using dynamic Boost libraries") +endif() + #set(Boost_NO_SYSTEM_PATHS ON) -set(Boost_USE_STATIC_LIBS ON) -find_package(Boost 1.75.0 REQUIRED COMPONENTS log log_setup date_time system thread program_options serialization timer json) +# Note: Boost system is header-only since 1.67, no longer needed as a component +find_package(Boost 1.75.0 REQUIRED COMPONENTS log log_setup date_time thread program_options serialization timer json) # Try CONFIG mode first (for vcpkg), fall back to module mode (for brew/system) find_package(Eigen3 3.3.0 CONFIG QUIET) diff --git a/src/CMakePresets.json b/src/CMakePresets.json index fe3ebd9b9..6f2cf0540 100644 --- a/src/CMakePresets.json +++ b/src/CMakePresets.json @@ -11,7 +11,9 @@ "hidden": true, "cacheVariables": { "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", - "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "VCPKG_MANIFEST_MODE": "ON", + "VCPKG_MANIFEST_DIR": "${sourceDir}/.." } }, { @@ -25,7 +27,7 @@ }, "cacheVariables": { "VCPKG_TARGET_TRIPLET": "x64-linux", - "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed" + "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed/linux" } }, { @@ -38,7 +40,8 @@ "rhs": "Windows" }, "cacheVariables": { - "VCPKG_TARGET_TRIPLET": "x64-windows" + "VCPKG_TARGET_TRIPLET": "x64-windows", + "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed/windows" } }, { @@ -59,7 +62,7 @@ "VCPKG_TARGET_TRIPLET": "arm64-osx", "CMAKE_OSX_ARCHITECTURES": "arm64", "VCPKG_CHAINLOAD_TOOLCHAIN_FILE": "${sourceDir}/cmake/toolchain/clang_mac_arm64.cmake", - "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed" + "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed/macos-arm64" } }, { @@ -70,7 +73,7 @@ "VCPKG_TARGET_TRIPLET": "x64-osx", "CMAKE_OSX_ARCHITECTURES": "x86_64", "VCPKG_CHAINLOAD_TOOLCHAIN_FILE": "${sourceDir}/cmake/toolchain/clang_mac_x64.cmake", - "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed" + "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed/macos-x64" } }, { @@ -92,7 +95,7 @@ "CMAKE_FIND_ROOT_PATH_MODE_INCLUDE": "ONLY", "VCPKG_TARGET_TRIPLET": "x64-mingw-static", "VCPKG_CHAINLOAD_TOOLCHAIN_FILE": "${sourceDir}/cmake/toolchain/mingw64.cmake", - "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed", + "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed/mingw", "ENABLE_MONGODB": "OFF" } }, @@ -110,7 +113,7 @@ "CMAKE_CXX_COMPILER": "g++", "VCPKG_TARGET_TRIPLET": "x64-mingw-static", "VCPKG_CHAINLOAD_TOOLCHAIN_FILE": "${sourceDir}/cmake/toolchain/mingw64.cmake", - "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed", + "VCPKG_INSTALLED_DIR": "${sourceDir}/../vcpkg_installed/mingw", "ENABLE_MONGODB": "OFF" } }, diff --git a/src/cpp/common/acsConfig.cpp b/src/cpp/common/acsConfig.cpp index f5f9ac81e..bd3fb7d59 100644 --- a/src/cpp/common/acsConfig.cpp +++ b/src/cpp/common/acsConfig.cpp @@ -508,6 +508,9 @@ string stringify(vector vec) return output; } +/** Get a stack of clean valid tokens, such those without symbolic and numeric prefixes, and the + * prefix of the last config token + */ string nonNumericStack(const string& stack, string& cutstr, bool colon = true) { string token; @@ -517,15 +520,43 @@ string nonNumericStack(const string& stack, string& cutstr, bool colon = true) while (getline(ss, token, ':')) { - size_t found = token.find_first_not_of("0123456789!@#: "); - if (found != std::string::npos) + cutstr = ""; + + // A valid config token should not contain any of '!', '@' and '#', so first get the + // trailing substring without any of them + size_t lastSymbol = token.find_last_of("!@#"); + if (lastSymbol != std::string::npos) + { + cutstr += token.substr(0, lastSymbol + 1); + token = token.substr(lastSymbol + 1); + } + + // Trim leading and trailing whitespace, otherwise the first non-prefix could be ' ' + boost::trim(token); + + // The leading number (any digits) followed by whitespace is used for sorting configs (e.g. + // '0 output'), but if the leading character is not a number (e.g. 'A123'), or there is no + // whitespace between the leading number and the rest of the string (e.g. '1ABC'), it should + // be the config token. Pure numbers are also accepted as valid configs. + size_t firstNonPrefix = token.find_first_not_of("0123456789"); + if (firstNonPrefix != std::string::npos && token[firstNonPrefix] == ' ') + { + cutstr += token.substr(0, firstNonPrefix); + token = token.substr(firstNonPrefix + 1); + } + + // Trim leading whitespace again in case there're multiple ' ' following leading number + boost::trim(token); + + if (token.size() > 0) { - cutstr = token.substr(0, found); - token = token.substr(found); newStack += token; if (colon) newStack += ":"; } + + // Trim leading and trailing whitespace in prefix + boost::trim(cutstr); } return newStack; @@ -2586,7 +2617,7 @@ void tryGetKalmanFromYaml( /** Set common options from yaml */ void tryGetKalmanFromYaml( - CommonKalmans& comOpts, ///< Receiver options variable to output to + CommonKalmans& comOpts, ///< Common options variable to output to NodeStack yamlBase, ///< Yaml node to search within const vector& descriptorVec ///< List of strings of keys of yaml hierarchy ) @@ -2669,10 +2700,10 @@ void getKalmanFromYaml( tryGetKalmanFromYaml(recOpts.trop_maps, recNode, "1@ trop_maps", "Troposphere ZWD mapping"); } -/** Set common options from yaml +/** Set orbit options from yaml */ void getOptionsFromYaml( - OrbitOptions& orbOpts, ///< Satellite options variable to output to + OrbitOptions& orbOpts, ///< Orbit options variable to output to NodeStack yamlBase, ///< Yaml node to search within const vector& descriptorVec ///< List of strings of keys of yaml hierarchy ) @@ -2980,7 +3011,7 @@ void getOptionsFromYaml( /** Set common options from yaml */ void getOptionsFromYaml( - CommonOptions& comOpts, ///< Satellite options variable to output to + CommonOptions& comOpts, ///< Common options variable to output to NodeStack yamlBase, ///< Yaml node to search within const vector& descriptorVec ///< List of strings of keys of yaml hierarchy ) diff --git a/src/cpp/common/lapackWrapper.hpp b/src/cpp/common/lapackWrapper.hpp index 279ddc3c2..69167d935 100644 --- a/src/cpp/common/lapackWrapper.hpp +++ b/src/cpp/common/lapackWrapper.hpp @@ -145,6 +145,32 @@ extern "C" double* y, const int* incy ); + void dsymm_( + const char* side, + const char* uplo, + const int* m, + const int* n, + const double* alpha, + const double* a, + const int* lda, + const double* b, + const int* ldb, + const double* beta, + double* c, + const int* ldc + ); + void dsyrk_( + const char* uplo, + const char* trans, + const int* n, + const int* k, + const double* alpha, + const double* a, + const int* lda, + const double* beta, + double* c, + const int* ldc + ); } #endif // Note: When EIGEN_USE_BLAS/EIGEN_BLAS_H is defined, these are already declared by Eigen headers @@ -470,4 +496,78 @@ inline void daxpy(int n, double alpha, const double* x, int incx, double* y, int daxpy_(&n, &alpha, const_cast(x), &incx, y, &incy); } +// Symmetric matrix-matrix multiply: C = alpha*A*B + beta*C or C = alpha*B*A + beta*C +// where A is symmetric +inline void dsymm( + Layout layout, + char side, // 'L' for A*B, 'R' for B*A + char uplo, // 'U' or 'L' - which triangle of A is stored + int m, // Rows of C + int n, // Cols of C + double alpha, + const double* a, // Symmetric matrix + int lda, + const double* b, // General matrix + int ldb, + double beta, + double* c, + int ldc +) +{ + if (layout != Layout::ColMajor) + { + return; + } + + dsymm_( + &side, + &uplo, + &m, + &n, + &alpha, + const_cast(a), + &lda, + const_cast(b), + &ldb, + &beta, + c, + &ldc + ); +} + +// Symmetric rank-k update: C = alpha*A*A^T + beta*C or C = alpha*A^T*A + beta*C +// where C is symmetric +inline void dsyrk( + Layout layout, + char uplo, // 'U' or 'L' - which triangle of C to update + char trans, // 'N' for A*A^T, 'T' for A^T*A + int n, // Order of C + int k, // Inner dimension + double alpha, + const double* a, + int lda, + double beta, + double* c, + int ldc +) +{ + if (layout != Layout::ColMajor) + { + return; + } + + dsyrk_( + &uplo, + &trans, + &n, + &k, + &alpha, + const_cast(a), + &lda, + &beta, + c, + &ldc + ); +} + } // namespace LapackWrapper diff --git a/src/cpp/common/rinex.cpp b/src/cpp/common/rinex.cpp index 6007fad22..9acc380e2 100644 --- a/src/cpp/common/rinex.cpp +++ b/src/cpp/common/rinex.cpp @@ -77,6 +77,18 @@ void setstr(char* dst, const char* src, int n) *p-- = '\0'; } +/** Strip trailing carriage return (\r) from string if present + * This handles Windows-encoded files (\r\n line endings) where std::getline + * removes only the \n but leaves the \r in the string. + */ +inline void stripCarriageReturn(string& line) +{ + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } +} + // Decode RINEX observation file header void decodeObsH( std::istream& inputStream, @@ -88,6 +100,9 @@ void decodeObsH( RinexStation& rnxRec ) { + // Ensure line is clean (defensive, should already be stripped by caller) + stripCarriageReturn(line); + double del[3]; int prn; int fcn; @@ -172,6 +187,7 @@ void decodeObsH( if (!std::getline(inputStream, line)) break; + stripCarriageReturn(line); buff = &line[0]; k = 7; } @@ -258,6 +274,7 @@ void decodeObsH( if (!std::getline(inputStream, line)) break; + stripCarriageReturn(line); buff = (char*)line.c_str(); j = 10; } @@ -378,6 +395,7 @@ void decodeNavH( Navigation& nav ///< Navigation data ) { + stripCarriageReturn(line); char* buff = &line[0]; char* label = buff + 60; @@ -551,6 +569,7 @@ void decodeNavH( // Decode GLONASS navigation file header void decodeGnavH(string& line, Navigation& nav) { + stripCarriageReturn(line); char* buff = &line[0]; char* label = buff + 60; @@ -568,6 +587,7 @@ void decodeGnavH(string& line, Navigation& nav) // Decode SBAS/geostationary navigation file header void decodeHnavH(string& line, Navigation& nav) { + stripCarriageReturn(line); char* buff = &line[0]; char* label = buff + 60; @@ -613,6 +633,8 @@ int readRnxH( while (std::getline(inputStream, line)) { + stripCarriageReturn(line); + char* buff = &line[0]; char* label = buff + 60; @@ -800,7 +822,6 @@ int decodeObsEpoch( int n = 0; char* buff = &line[0]; - // BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << ": ver=" << ver; if (ver <= 2.99) { @@ -833,6 +854,7 @@ int decodeObsEpoch( if (!std::getline(inputStream, line)) break; + stripCarriageReturn(line); buff = &line[0]; j = 32; @@ -960,6 +982,7 @@ int decodeObsDataRinex2( { if (!std::getline(inputStream, line)) break; + stripCarriageReturn(line); if (line.size() < 80) line.append(80 - line.size(), ' '); // Ensure line is at least 80 characters @@ -1195,6 +1218,8 @@ int readRnxObsB( std::streampos pos; while (pos = inputStream.tellg(), std::getline(inputStream, line)) { + stripCarriageReturn(line); + // decode obs epoch if (i == 0) { @@ -1303,10 +1328,10 @@ int decodeEph(double ver, SatSys Sat, GTime toc, vector& data, Eph& eph) return 0; } - double deltaTime = (toc - toc.floorTime(7200)).to_double(); - if (sys == E_Sys::GPS && deltaTime > 60 && deltaTime < (7200 - 60)) + double deltaTime = (toc - toc.floorTime(3600)).to_double(); + if (sys == E_Sys::GPS && deltaTime > 60 && (deltaTime - 3600) < -60) { - // Skip decoding bad ephemeris (being off for more than 1 minute from 2-hour modulo epochs) + // Skip decoding bad ephemeris (being off for more than 1 minute from 1-hour modulo epochs) return 0; } @@ -1858,6 +1883,8 @@ int readRnxNavB( while (std::getline(inputStream, line)) { + stripCarriageReturn(line); + char* buff = &line[0]; if (data.empty()) @@ -2205,6 +2232,8 @@ int readRnxClk(std::istream& inputStream, double ver, Navigation& nav) GTime time0; while (std::getline(inputStream, line)) { + stripCarriageReturn(line); + char* buff = &line[0]; GTime time; diff --git a/src/cpp/common/rtsSmoothing.cpp b/src/cpp/common/rtsSmoothing.cpp index 37164c7ec..b0180b093 100644 --- a/src/cpp/common/rtsSmoothing.cpp +++ b/src/cpp/common/rtsSmoothing.cpp @@ -540,7 +540,17 @@ bool FilterData::performRtsComputation(KFState& kfState, const RtsConfiguration& smoothedKF.time = kalmanPlus.time; - smoothedKF.P = (smoothedKF.P + smoothedKF.P.transpose()).eval() / 2; + // Symmetrize in-place without creating transpose copy + int n = smoothedKF.P.rows(); + for (int i = 0; i < n; i++) + { + for (int j = i + 1; j < n; j++) + { + double avg = (smoothedKF.P(i, j) + smoothedKF.P(j, i)) * 0.5; + smoothedKF.P(i, j) = avg; + smoothedKF.P(j, i) = avg; + } + } // get process noise and dynamics auto& F = transitionMatrix; @@ -556,6 +566,10 @@ bool FilterData::performRtsComputation(KFState& kfState, const RtsConfiguration& VectorXd deltaX = VectorXd::Zero(kalmanPlus.x.rows()); MatrixXd deltaP = MatrixXd::Zero(kalmanPlus.P.rows(), kalmanPlus.P.cols()); + // Pre-allocate temporary matrices for reuse across chunks + MatrixXd temp; + int maxChunkSize = 0; + map filterChunks; for (auto& [id, fcP] : kalmanPlus.filterChunkMap) filterChunks[id] = true; @@ -584,83 +598,127 @@ bool FilterData::performRtsComputation(KFState& kfState, const RtsConfiguration& continue; } - MatrixXd Q = kalmanMinus.P.block(fcM.begX, fcM.begX, fcM.numX, fcM.numX); - MatrixXd FP_ = FP.block(fcM.begX, fcP.begX, fcM.numX, fcP.numX); - int n = fcM.numX; - int neqs = fcP.numX; - Q += MatrixXd::Identity(fcM.numX, fcM.numX) * config.regularisation; + int n = fcM.numX; + int neqs = fcP.numX; - solveSystem(fcM.numX, fcP.numX, Q.data(), FP_.data()); + // Copy Q block and add regularization (needed for in-place solving) + MatrixXd Q = kalmanMinus.P.block(fcM.begX, fcM.begX, n, n); + Q += MatrixXd::Identity(n, n) * config.regularisation; - auto deltaX_ = deltaX.segment(fcP.begX, fcP.numX); - auto smoothedX = smoothedKF.x.segment(fcM.begX, fcM.numX); - auto xMinus = kalmanMinus.x.segment(fcM.begX, fcM.numX); - auto deltaP_ = deltaP.block(fcP.begX, fcP.begX, fcP.numX, fcP.numX); - auto smoothedP = smoothedKF.P.block(fcM.begX, fcM.begX, fcM.numX, fcM.numX); - auto minuxP = kalmanMinus.P.block(fcM.begX, fcM.begX, fcM.numX, fcM.numX); + // Copy FP block for solving (will be overwritten by solution) + MatrixXd FP_solved = FP.block(fcM.begX, fcP.begX, n, neqs); + solveSystem(n, neqs, Q.data(), FP_solved.data()); - VectorXd xChanged = smoothedX - xMinus; + // Get pointers to blocks for direct LAPACK operations + double* pDeltaX = deltaX.data() + fcP.begX; + double* pSmoothedX = smoothedKF.x.data() + fcM.begX; + double* pXMinus = kalmanMinus.x.data() + fcM.begX; + double* pSmoothedP = smoothedKF.P.data() + fcM.begX * smoothedKF.P.rows() + fcM.begX; + double* pMinusP = kalmanMinus.P.data() + fcM.begX * kalmanMinus.P.rows() + fcM.begX; + double* pDeltaP = deltaP.data() + fcP.begX * deltaP.rows() + fcP.begX; + + int ldSmoothedP = smoothedKF.P.rows(); + int ldMinusP = kalmanMinus.P.rows(); + int ldDeltaP = deltaP.rows(); + + // Compute xChanged = smoothedX - xMinus + VectorXd xChanged(n); + for (int i = 0; i < n; ++i) + { + xChanged[i] = pSmoothedX[i] - pXMinus[i]; + } - // Use CBLAS for matrix-vector multiplication: deltaX_ = FP_^T * xChanged + // deltaX += FP_solved^T * xChanged + // Using dgemv: y = alpha*A^T*x + beta*y LapackWrapper::dgemv( LapackWrapper::COL_MAJOR, LapackWrapper::CblasTrans, - n, - neqs, - 1.0, - FP_.data(), - n, - xChanged.data(), - 1, - 0.0, - deltaX_.data(), - 1 + n, // rows of FP_solved + neqs, // cols of FP_solved + 1.0, // alpha + FP_solved.data(), // matrix A + n, // leading dimension of A + xChanged.data(), // vector x + 1, // stride of x + 0.0, // beta (overwrite, not accumulate) + pDeltaX, // vector y + 1 // stride of y ); - MatrixXd dP = smoothedP - minuxP; - MatrixXd temp = MatrixXd::Zero(neqs, n); + // Resize temporary matrix if needed (reuse across chunks) + if (temp.rows() != neqs || temp.cols() != n) + { + temp.resize(neqs, n); + } - // Use CBLAS for matrix-matrix multiplication: temp = FP_^T * dP + // Compute: temp = FP_solved^T * (smoothedP - minusP) + // Note: Could use dsymm since P matrices are symmetric, but we need the transpose + // operation FP_solved^T which dsymm doesn't directly support, so dgemm is clearer + + // Step 1: temp = FP_solved^T * smoothedP LapackWrapper::dgemm( LapackWrapper::COL_MAJOR, - LapackWrapper::CblasTrans, - LapackWrapper::CblasNoTrans, - neqs, - n, - n, - 1.0, - FP_.data(), - n, - dP.data(), - n, - 0.0, - temp.data(), - neqs + LapackWrapper::CblasTrans, // transpose FP_solved + LapackWrapper::CblasNoTrans, // don't transpose smoothedP + neqs, // rows of result + n, // cols of result + n, // inner dimension + 1.0, // alpha + FP_solved.data(), // A + n, // leading dim of A + pSmoothedP, // B (smoothed P block) + ldSmoothedP, // leading dim of B + 0.0, // beta + temp.data(), // C + neqs // leading dim of C + ); + + // Step 2: temp -= FP_solved^T * minusP + LapackWrapper::dgemm( + LapackWrapper::COL_MAJOR, + LapackWrapper::CblasTrans, // transpose FP_solved + LapackWrapper::CblasNoTrans, // don't transpose minusP + neqs, // rows of result + n, // cols of result + n, // inner dimension + -1.0, // alpha (subtract) + FP_solved.data(), // A + n, // leading dim of A + pMinusP, // B (minus P block) + ldMinusP, // leading dim of B + 1.0, // beta (accumulate) + temp.data(), // C + neqs // leading dim of C ); - // Use CBLAS for matrix-matrix multiplication: deltaP_ = temp * FP_ - // NOTE: deltaP_ is a block reference, so leading dimension is deltaP.rows() + // Final step: deltaP += temp * FP_solved LapackWrapper::dgemm( LapackWrapper::COL_MAJOR, - LapackWrapper::CblasNoTrans, - LapackWrapper::CblasNoTrans, - neqs, - neqs, - n, - 1.0, - temp.data(), - neqs, - FP_.data(), - n, - 0.0, - deltaP_.data(), - deltaP.rows() // Parent matrix row count, not block size + LapackWrapper::CblasNoTrans, // don't transpose temp + LapackWrapper::CblasNoTrans, // don't transpose FP_solved + neqs, // rows of result + neqs, // cols of result + n, // inner dimension + 1.0, // alpha + temp.data(), // A + neqs, // leading dim of A + FP_solved.data(), // B + n, // leading dim of B + 0.0, // beta (overwrite) + pDeltaP, // C (deltaP block) + ldDeltaP // leading dim of parent matrix ); } smoothedKF.dx = deltaX; - smoothedKF.x = deltaX + kalmanPlus.x; - smoothedKF.P = deltaP + kalmanPlus.P; + + // Use BLAS for vector/matrix additions for better performance + smoothedKF.x = kalmanPlus.x; + LapackWrapper::daxpy(deltaX.size(), 1.0, deltaX.data(), 1, smoothedKF.x.data(), 1); + + smoothedKF.P = kalmanPlus.P; + int totalSize = kalmanPlus.P.rows() * kalmanPlus.P.cols(); + LapackWrapper::daxpy(totalSize, 1.0, deltaP.data(), 1, smoothedKF.P.data(), 1); if (measurements.H.rows()) if (measurements.H.cols() == deltaX.rows()) diff --git a/src/cpp/common/sinex.cpp b/src/cpp/common/sinex.cpp index 855245cbc..613b2565e 100644 --- a/src/cpp/common/sinex.cpp +++ b/src/cpp/common/sinex.cpp @@ -2742,6 +2742,78 @@ void writESnxSatMass(ofstream& out) } } +/** Get GLONASS frequency channel from SINEX data + * Returns frequency channel number for a GLONASS satellite at a given time. + * Searches SINEX satellite frequency channel blocks to find the correct channel. + */ +int getGloFreqChannel( + const SatSys& sat, ///< Satellite to query + const GTime& time, ///< Time of observation + Navigation& nav ///< Navigation data to cache result +) +{ + if (sat.sys != E_Sys::GLO) + { + return 0; + } + + // Try to get SVN from nav.svnMap first (populated from SINEX SATELLITE/PRN block) + string svn; + auto it = nav.svnMap[sat].lower_bound(time); + if (it != nav.svnMap[sat].end()) + { + svn = it->second; + } + + // Fallback to satDataMap if svnMap lookup failed + if (svn.empty()) + { + svn = sat.svn(); + } + + BOOST_LOG_TRIVIAL(debug) << "SINEX: Querying frequency channel for " << sat.id() + << " at time " << time.to_string() << " (SVN=" << svn << ")"; + + if (svn.empty()) + { + BOOST_LOG_TRIVIAL(info) + << "SINEX: No SVN available for " << sat.id() + << " at time " << time.to_string(); + return 0; + } + + // Find frequency channel for this SVN at this time + for (auto& sfc : theSinex.listsatfreqchns) + { + if (sfc.svn != svn) + continue; + + GTime startTime = sfc.start; + GTime stopTime = sfc.stop; + + // Check if stop time is 0000:000:00000 (means ongoing/no end date) + bool isOngoing = (sfc.stop[0] == 0 && sfc.stop[1] == 0 && sfc.stop[2] == 0); + + // Time must be after start, and either before stop or stop is ongoing + if (time >= startTime && (isOngoing || time <= stopTime)) + { + nav.gloFreqMap[sat] = sfc.channel; + + BOOST_LOG_TRIVIAL(debug) + << "SINEX: Found frequency channel " << sfc.channel + << " for " << sat.id() << " (SVN=" << svn << ") at time " << time.to_string(); + + return sfc.channel; + } + } + + BOOST_LOG_TRIVIAL(debug) + << "SINEX: Could not find frequency channel for " << sat.id() + << " (SVN=" << svn << ") at time " << time.to_string(); + + return 0; +} + bool compareSatCom(SinexSatCom& left, SinexSatCom& right) { // start by comparing SVN... diff --git a/src/cpp/common/sinex.hpp b/src/cpp/common/sinex.hpp index c5dc55159..65b9c6145 100644 --- a/src/cpp/common/sinex.hpp +++ b/src/cpp/common/sinex.hpp @@ -15,6 +15,10 @@ using std::map; using std::string; using std::vector; +// Forward declarations +struct SatSys; +struct Navigation; + //=============================================================================== /* history structure (optional but recommended) * ------------------------------------------------------------------------------ @@ -742,6 +746,7 @@ union GetSnxResult GetSnxResult getRecSnx(string id, GTime time, SinexRecData& snx); GetSnxResult getSatSnx(string prn, GTime time, SinexSatSnx& snx); void getSlrRecBias(string id, string prn, GTime time, map& recBias); +int getGloFreqChannel(const SatSys& sat, const GTime& time, Navigation& nav); void sinexAddStatistic(const string& what, const int value); void sinexAddStatistic(const string& what, const double value); diff --git a/src/cpp/common/streamFile.hpp b/src/cpp/common/streamFile.hpp index 791ecc3f3..ac4585eb4 100644 --- a/src/cpp/common/streamFile.hpp +++ b/src/cpp/common/streamFile.hpp @@ -13,7 +13,7 @@ struct FileState : std::ifstream { long int& filePos; - FileState(string path, long int& filePos, std::ifstream::openmode mode = std::ifstream::in) + FileState(string path, long int& filePos, std::ifstream::openmode mode = std::ifstream::in | std::ios::binary) : filePos{filePos} { if (filePos < 0) diff --git a/src/cpp/pea/ppppp.cpp b/src/cpp/pea/ppppp.cpp index ac7a6b3a1..c5dcf7387 100644 --- a/src/cpp/pea/ppppp.cpp +++ b/src/cpp/pea/ppppp.cpp @@ -918,8 +918,8 @@ void updateAvgIonosphere( double ionosphereStec = diono / alpha; - // update the mu value but dont use the state thing - it will re-add it after its deleted - // kfState.addKFState(key, init); + // Update the mu value but dont use the state thing - it will re-add it after its deleted + // kfState.addKFState(key, init); kfState.gaussMarkovMuMap[key] = ionosphereStec; } } @@ -970,7 +970,7 @@ void updateAvgOrbits( init.mu = satPos.rSatEci0(i); - // update the mu value, + // Update the mu value, kfState.addKFState(key, init); } } @@ -1004,13 +1004,7 @@ void updateAvgClocks( satPosBrdc.Sat = Sat; satPosKf.Sat = Sat; - bool pass = satClkBroadcast(trace, time, time, satPosBrdc, nav); - if (pass == false) - { - continue; - } - - pass = satClkKalman(trace, time, satPosKf, &kfState); + bool pass = satClkKalman(trace, time, satPosKf, &kfState); if (pass == false) { continue; @@ -1021,6 +1015,18 @@ void updateAvgClocks( // Restore tau value from config InitialState init = initialStateFromConfig(satOpts.clk); + pass = satClkBroadcast(trace, time, time, satPosBrdc, nav); + if (pass == false) + { + init.tau = -1; // Disable FOGM so that this satellite clock won't be tied down to old + // mu value + + // Update the tau value + kfState.addKFState(key, init); + + continue; + } + double satClkBrdc = satPosBrdc.satClk * CLIGHT; double satClkKf = satPosKf.satClk * CLIGHT; double satClkDiff = satClkBrdc - satClkKf; @@ -1043,7 +1049,7 @@ void updateAvgClocks( init.mu = satClkBrdc; } - // update tau & mu values + // Update tau & mu values kfState.addKFState(key, init); } } diff --git a/src/cpp/rtklib/rtkcmn.cpp b/src/cpp/rtklib/rtkcmn.cpp index 7bfa65e02..1ba44060d 100644 --- a/src/cpp/rtklib/rtkcmn.cpp +++ b/src/cpp/rtklib/rtkcmn.cpp @@ -10,6 +10,7 @@ #include "common/eigenIncluder.hpp" #include "common/enums.h" #include "common/navigation.hpp" +#include "common/sinex.hpp" #include "orbprop/coordinates.hpp" void updateLamMap(const GTime& time, SatPos& satPos) @@ -50,6 +51,11 @@ void updateLamMap(const GTime& time, SatPos& satPos) freqNum = geph.frq; } + else + { + // Fallback to SINEX lookup for RINEX 2 or when ephemeris not available + freqNum = getGloFreqChannel(satPos.Sat, time, nav); + } } if (freqNum > 20)