├── .gitignore
├── DIRECTORY_STRUCTURE.md
├── LICENSE
├── README.md
├── docs
├── Makefile
├── detailed
│ └── io
│ │ ├── rinex2.md
│ │ └── rinex3.md
├── make.bat
└── source
│ ├── api.rst
│ ├── conf.py
│ ├── index.rst
│ ├── modules
│ ├── rinex2.rst
│ ├── rinex3.rst
│ └── rinexnav.rst
│ └── usage.rst
├── pygnsslab_logo.png
├── pyproject.toml
├── requirements.txt
├── setup.py
├── src
├── __init__.py
└── pygnsslab
│ ├── __init__.py
│ └── io
│ ├── rinex2
│ ├── __init__.py
│ ├── example_usage.py
│ ├── metadata.py
│ ├── reader.py
│ ├── utils.py
│ └── writer.py
│ ├── rinex3
│ ├── __init__.py
│ ├── example_usage.py
│ ├── metadata.py
│ ├── reader.py
│ ├── utils.py
│ └── writer.py
│ ├── rinexnav
│ ├── __init__.py
│ ├── example_usage.py
│ ├── metadata.py
│ ├── reader.py
│ └── utils.py
│ └── sp3
│ ├── __init__.py
│ ├── example_usage.py
│ ├── metadata.py
│ ├── reader.py
│ ├── utils.py
│ └── writer.py
└── tests
├── rinex2
├── test_output.ipynb
└── test_output.py
├── rinexnav
├── test_rinexnav.ipynb
└── test_rinexnav.py
└── sp3
└── test_sp3.ipynb
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # User defined
30 | data/
31 | update_directory_structure.py
32 |
33 | # PyInstaller
34 | # Usually these files are written by a python script from a template
35 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
36 | *.manifest
37 | *.spec
38 |
39 | # Installer logs
40 | pip-log.txt
41 | pip-delete-this-directory.txt
42 |
43 | # Unit test / coverage reports
44 | htmlcov/
45 | .tox/
46 | .nox/
47 | .coverage
48 | .coverage.*
49 | .cache
50 | nosetests.xml
51 | coverage.xml
52 | *.cover
53 | *.py,cover
54 | .hypothesis/
55 | .pytest_cache/
56 | cover/
57 |
58 | # Translations
59 | *.mo
60 | *.pot
61 |
62 | # Django stuff:
63 | *.log
64 | local_settings.py
65 | db.sqlite3
66 | db.sqlite3-journal
67 |
68 | # Flask stuff:
69 | instance/
70 | .webassets-cache
71 |
72 | # Scrapy stuff:
73 | .scrapy
74 |
75 | # Sphinx documentation
76 | docs/_build/
77 |
78 | # PyBuilder
79 | .pybuilder/
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | # For a library or package, you might want to ignore these files since the code is
91 | # intended to run in multiple environments; otherwise, check them in:
92 | # .python-version
93 |
94 | # pipenv
95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
98 | # install all needed dependencies.
99 | #Pipfile.lock
100 |
101 | # UV
102 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
103 | # This is especially recommended for binary packages to ensure reproducibility, and is more
104 | # commonly ignored for libraries.
105 | #uv.lock
106 |
107 | # poetry
108 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
109 | # This is especially recommended for binary packages to ensure reproducibility, and is more
110 | # commonly ignored for libraries.
111 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
112 | #poetry.lock
113 |
114 | # pdm
115 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
116 | #pdm.lock
117 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
118 | # in version control.
119 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
120 | .pdm.toml
121 | .pdm-python
122 | .pdm-build/
123 |
124 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
125 | __pypackages__/
126 |
127 | # Celery stuff
128 | celerybeat-schedule
129 | celerybeat.pid
130 |
131 | # SageMath parsed files
132 | *.sage.py
133 |
134 | # Environments
135 | .env
136 | .venv
137 | env/
138 | venv/
139 | ENV/
140 | env.bak/
141 | venv.bak/
142 |
143 | # Spyder project settings
144 | .spyderproject
145 | .spyproject
146 |
147 | # Rope project settings
148 | .ropeproject
149 |
150 | # mkdocs documentation
151 | /site
152 |
153 | # mypy
154 | .mypy_cache/
155 | .dmypy.json
156 | dmypy.json
157 |
158 | # Pyre type checker
159 | .pyre/
160 |
161 | # pytype static type analyzer
162 | .pytype/
163 |
164 | # Cython debug symbols
165 | cython_debug/
166 |
167 | # PyCharm
168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
170 | # and can be added to the global gitignore or merged into this file. For a more nuclear
171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
172 | #.idea/
173 |
174 | # Ruff stuff:
175 | .ruff_cache/
176 |
177 | # PyPI configuration file
178 | .pypirc
179 |
--------------------------------------------------------------------------------
/DIRECTORY_STRUCTURE.md:
--------------------------------------------------------------------------------
1 | # Project Directory Structure
2 |
3 | ```
4 | .
5 | │ └─── .gitignore
6 | │ └─── DIRECTORY_STRUCTURE.md
7 | │ └─── LICENSE
8 | │ └─── README.md
9 | │ └─── pygnsslab_logo.png
10 | │ └─── pyproject.toml
11 | │ └─── requirements.txt
12 | │ └─── setup.py
13 | │ └───src
14 | │ │ └─── __init__.py
15 | │ │ └───pygnsslab
16 | │ │ │ └─── __init__.py
17 | │ │ │ └───io
18 | │ │ │ │ └───rinex2
19 | │ │ │ │ │ └─── __init__.py
20 | │ │ │ │ │ └─── example_usage.py
21 | │ │ │ │ │ └─── metadata.py
22 | │ │ │ │ │ └─── reader.py
23 | │ │ │ │ │ └─── utils.py
24 | │ │ │ │ │ └─── writer.py
25 | │ │ │ │ └───rinex3
26 | │ │ │ │ │ └─── __init__.py
27 | │ │ │ │ │ └─── example_usage.py
28 | │ │ │ │ │ └─── metadata.py
29 | │ │ │ │ │ └─── reader.py
30 | │ │ │ │ │ └─── utils.py
31 | │ │ │ │ │ └─── writer.py
32 | │ │ │ │ └───rinexnav
33 | │ │ │ │ │ └─── __init__.py
34 | │ │ │ │ │ └─── example_usage.py
35 | │ │ │ │ │ └─── metadata.py
36 | │ │ │ │ │ └─── reader.py
37 | │ │ │ │ │ └─── utils.py
38 | │ │ │ │ └───sp3
39 | │ │ │ │ │ └─── __init__.py
40 | │ │ │ │ │ └─── example_usage.py
41 | │ │ │ │ │ └─── metadata.py
42 | │ │ │ │ │ └─── reader.py
43 | │ │ │ │ │ └─── utils.py
44 | │ │ │ │ │ └─── writer.py
45 | │ └───tests
46 | │ │ └───rinex2
47 | │ │ │ └─── test_output.ipynb
48 | │ │ │ └─── test_output.py
49 | │ │ └───rinexnav
50 | │ │ │ └─── test_rinexnav.ipynb
51 | │ │ │ └─── test_rinexnav.py
52 | │ │ └───sp3
53 | │ │ │ └─── test_sp3.ipynb
54 | ```
55 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 PyGnssLab
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # pygnsslab
4 | Open-source Python-based GNSS software.
5 |
6 | # PyGNSSLab
7 | **PyGNSSLab** is an open-source project focused on developing modular, Python-based GNSS tools and libraries. Our aim is to provide accurate, reliable, and extensible solutions for GNSS data processing — from basic RINEX handling to advanced PPP-AR techniques.
8 |
9 | ## 🔍 Key Features
10 |
11 | - 🛰️ RINEX reading & conversion tools
12 | - 📡 PPP and PPP-AR processing engines
13 | - 🧪 Real-time GNSS data stream support
14 | - 🧰 Modular structure for research & development
15 | - 📦 Ready-to-use APIs and command-line tools
16 |
17 | ## 💡 Who is it for?
18 |
19 | - Geodesists & Earth scientists
20 | - Researchers working on GNSS positioning
21 | - Developers creating custom GNSS pipelines
22 | - Students learning GNSS with Python
23 |
24 | ## 📁 Modules
25 |
26 | This organization hosts multiple standalone and integrated modules:
27 | - **RINEX2**: A comprehensive parser for RINEX 2.x observation files, supporting:
28 | - Fast reading of observation data
29 | - Metadata extraction
30 | - Export to Parquet format for efficient storage
31 | - JSON metadata output
32 |
33 | - [Example usage](src/pygnsslab/io/rinex2/example_usage.py)
34 | - [Test output](tests/rinex2/test_output.py)
35 |
36 | - **RINEX3**: Modern RINEX 3.x file handler with features for:
37 | - Multi-GNSS observation data parsing
38 | - Complete metadata extraction
39 | - Parquet file conversion
40 | - Standardized JSON metadata output
41 |
42 | - [Example usage](src/pygnsslab/io/rinex3/example_usage.py)
43 |
44 | - **Coming Soon**:
45 | - SPP Capabilities
46 | - PPP/PPP-AR Processing Engine
47 | - Real-time Data Stream Handler
48 | - Troposphere & Ionosphere Models
49 | - Orbit & Clock Products Interface
50 |
51 | ## 🚀 Getting Started
52 |
53 | ### Installation
54 |
55 | Follow these steps to get PyGNSSLab set up on your local machine:
56 |
57 | 1. **Clone the repository:**
58 | ```bash
59 | git clone https://github.com/PyGNSSLab/pygnsslab.git
60 | cd pygnsslab
61 | ```
62 |
63 | 2. **Create and activate a virtual environment (Recommended):**
64 | ```bash
65 | python -m venv venv
66 | # On Windows
67 | .\venv\Scripts\activate
68 | # On macOS/Linux
69 | source venv/bin/activate
70 | ```
71 |
72 | 3. **Install the required dependencies:**
73 | ```bash
74 | pip install -r requirements.txt
75 | ```
76 |
77 | Now you're ready to use the PyGNSSLab tools and libraries!
78 |
79 | ## 📜 License
80 |
81 | All repositories under PyGNSSLab are released under the **MIT License** unless otherwise specified.
82 |
83 | ---
84 |
85 | > Made with 🛰️ by the GNSS community, for the GNSS community.
86 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/detailed/io/rinex2.md:
--------------------------------------------------------------------------------
1 | # pygnsslab.io.rinex2 Module Documentation
2 |
3 | ## Overview
4 |
5 | The `pygnsslab.io.rinex2` module is a Python package designed for parsing, processing, and manipulating RINEX 2 observation files. RINEX (Receiver Independent Exchange Format) is a standardized format for GNSS (Global Navigation Satellite System) data. This module provides a comprehensive set of tools for extracting metadata and observation data from RINEX 2 files, converting between different time formats, and saving processed data to more efficient formats.
6 |
7 | The module is part of the larger `pygnsslab` package and is located at `./src/pygnsslab/io/rinex2`.
8 |
9 | ## Module Structure
10 |
11 | The `pygnsslab.io.rinex2` module consists of the following Python files:
12 |
13 | - `__init__.py`: Main module initialization that exports key functionality
14 | - `reader.py`: Contains the `Rinex2Reader` class for parsing RINEX 2 files
15 | - `writer.py`: Functions for saving observation data to parquet format
16 | - `metadata.py`: Functions for handling RINEX metadata
17 | - `utils.py`: Utility functions for time conversions and file operations
18 | - `example_usage.py`: Example script demonstrating how to use the module
19 |
20 | ## Key Components
21 |
22 | ### Rinex2Reader
23 |
24 | The `Rinex2Reader` class is the primary interface for reading and parsing RINEX 2 observation files.
25 |
26 | #### Usage
27 |
28 | ```python
29 | from pygnsslab.io.rinex2 import Rinex2Reader
30 |
31 | # Initialize the reader with a RINEX 2 file
32 | rinex_reader = Rinex2Reader("path/to/rinex_file.o")
33 |
34 | # Access metadata
35 | metadata = rinex_reader.metadata
36 |
37 | # Access observation data as a pandas DataFrame
38 | obs_data = rinex_reader.get_obs_data()
39 |
40 | # Save observation data directly to Parquet (convenience method)
41 | rinex_reader.to_parquet("output.parquet")
42 | ```
43 |
44 | #### Input
45 |
46 | - RINEX 2 observation files (typically with extensions `.obs`, `.o`, `.yyo`, `.yyO`, `.yyN`, or `.yyG`)
47 |
48 | #### Output
49 |
50 | - `metadata`: Dictionary containing header information from the RINEX file
51 | - `obs_data`: Pandas DataFrame containing observation data
52 |
53 | ### Metadata Handling
54 |
55 | The `metadata.py` module provides functions for extracting, formatting, and saving header metadata from RINEX 2 files.
56 |
57 | #### Key Functions
58 |
59 | - `extract_metadata(rinex_header)`: Extracts and formats metadata from RINEX header
60 | - `save_metadata(metadata, output_file)`: Saves metadata to a JSON file
61 | - `load_metadata(metadata_file)`: Loads metadata from a JSON file
62 | - `compare_metadata(metadata1, metadata2)`: Compares two metadata dictionaries and returns their differences
63 |
64 | #### Usage
65 |
66 | ```python
67 | from pygnsslab.io.rinex2.metadata import extract_metadata, save_metadata, load_metadata, compare_metadata
68 |
69 | # Extract metadata from RINEX header
70 | metadata = extract_metadata(rinex_header)
71 |
72 | # Save metadata to a JSON file
73 | save_metadata(metadata, "output_metadata.json")
74 |
75 | # Load metadata from a JSON file
76 | loaded_metadata = load_metadata("metadata.json")
77 |
78 | # Compare two metadata dictionaries
79 | differences = compare_metadata(metadata1, metadata2)
80 | ```
81 |
82 | ### Data Writing
83 |
84 | The `writer.py` module provides functions for saving RINEX observation data to parquet format and creating summary reports.
85 |
86 | #### Key Functions
87 |
88 | - `write_to_parquet(obs_data, output_file, metadata=None, metadata_file=None)`: Writes observation data to a parquet file and optionally saves metadata
89 | - `write_obs_summary(obs_data, output_file)`: Writes a summary of observation data to a text file
90 |
91 | #### Usage
92 |
93 | ```python
94 | from pygnsslab.io.rinex2.writer import write_to_parquet, write_obs_summary
95 |
96 | # Write observation data to parquet format
97 | parquet_file, metadata_file = write_to_parquet(obs_data, "output.parquet", metadata)
98 |
99 | # Write observation data summary
100 | summary_file = write_obs_summary(obs_data, "summary.txt")
101 | ```
102 |
103 | ### Utility Functions
104 |
105 | The `utils.py` module provides utility functions for time conversions and file operations.
106 |
107 | #### Time Conversion Functions
108 |
109 | - `datetime_to_mjd(dt)`: Converts a datetime object to Modified Julian Date (MJD)
110 | - `datetime_to_jd(dt)`: Converts a datetime object to Julian Date
111 | - `mjd_to_datetime(mjd)`: Converts Modified Julian Date to datetime object
112 | - `jd_to_datetime(jd)`: Converts Julian Date to datetime object
113 | - `time_to_seconds_of_day(time_obj)`: Converts a time object to seconds of day
114 |
115 | #### File Operations
116 |
117 | - `is_rinex2_file(filename)`: Checks if a file is a RINEX 2 observation file
118 | - `find_rinex2_files(directory)`: Finds all RINEX 2 observation files in a directory
119 |
120 | #### Usage
121 |
122 | ```python
123 | from pygnsslab.io.rinex2.utils import datetime_to_mjd, is_rinex2_file, find_rinex2_files
124 | import datetime
125 |
126 | # Convert datetime to MJD
127 | dt = datetime.datetime.now()
128 | mjd = datetime_to_mjd(dt)
129 |
130 | # Check if a file is a RINEX 2 file
131 | is_rinex2 = is_rinex2_file("path/to/file.o")
132 |
133 | # Find all RINEX 2 files in a directory
134 | rinex_files = find_rinex2_files("path/to/directory")
135 | ```
136 |
137 | ## Data Formats
138 |
139 | ### RINEX 2 Format
140 |
141 | The RINEX 2 format is an older standard format for GNSS data, with some notable differences from the newer RINEX 3 format. RINEX 2 observation files contain:
142 |
143 | 1. **Header Section**: Contains metadata about the observation session, including station information, receiver and antenna details, and observation types.
144 | 2. **Observation Section**: Contains the actual observation data from different satellites.
145 |
146 | Key differences from RINEX 3:
147 | - Observation types are defined globally rather than per constellation
148 | - Two-digit year format is commonly used
149 | - Limited support for multiple GNSS constellations
150 | - Different formatting for epoch headers and observation data
151 |
152 | ### Metadata Structure
153 |
154 | The extracted metadata from RINEX 2 files is structured as a Python dictionary with the following key fields:
155 |
156 | - `rinexVersion`: RINEX version number
157 | - `fileType`: Type of RINEX file (usually 'O' for observation)
158 | - `stationName`: Name of the station
159 | - `observer`: Name of the observer
160 | - `agency`: Agency or institution
161 | - `approxPos`: Approximate position of the station (X, Y, Z coordinates)
162 | - `receiverNumber`: Receiver number
163 | - `receiverType`: Receiver type
164 | - `receiverVersion`: Receiver firmware version
165 | - `antennaNumber`: Antenna number
166 | - `antennaType`: Antenna type
167 | - `observationTypes`: Dictionary of observation types by constellation
168 | - In RINEX 2, observation types are stored globally but converted to a constellation-based format for compatibility with RINEX 3 processing code
169 | - Original global list is preserved under the 'ALL' key
170 | - `firstObsTime`: Time of first observation
171 | - `lastObsTime`: Time of last observation
172 | - `interval`: Observation interval in seconds
173 | - `leapSeconds`: Number of leap seconds
174 | - `antennaDelta`: Antenna delta (height, east, north)
175 | - `wavelengthFactors`: Wavelength factors information
176 |
177 | ### Observation Data Structure
178 |
179 | The observation data is structured as a pandas DataFrame with the following columns:
180 |
181 | - `date`: Observation date
182 | - `epoch_sec`: Seconds of day
183 | - `mjd`: Modified Julian Date
184 | - `constellation`: GNSS constellation (G=GPS, R=GLONASS, E=Galileo, C=BeiDou, J=QZSS, S=SBAS)
185 | - `sat`: Satellite identifier
186 | - `channel`: Signal channel/frequency
187 | - `range`: Pseudorange measurement
188 | - `phase`: Carrier phase measurement
189 | - `doppler`: Doppler measurement
190 | - `snr`: Signal-to-noise ratio
191 |
192 | This structure is designed to be compatible with the RINEX 3 data format for easier integration with existing processing pipelines.
193 |
194 | ## Example Usage
195 |
196 | ### Basic Example
197 |
198 | ```python
199 | from pygnsslab.io.rinex2 import Rinex2Reader
200 | from pygnsslab.io.rinex2.writer import write_to_parquet
201 | import os
202 |
203 | # Path to RINEX 2 file
204 | rinex_file = "path/to/rinex_file.o"
205 |
206 | # Read RINEX 2 file
207 | rinex_reader = Rinex2Reader(rinex_file)
208 |
209 | # Get metadata and observations
210 | metadata = rinex_reader.metadata
211 | obs_data = rinex_reader.get_obs_data()
212 |
213 | # Save to parquet format
214 | output_dir = "output"
215 | os.makedirs(output_dir, exist_ok=True)
216 | base_name = os.path.splitext(os.path.basename(rinex_file))[0]
217 | parquet_file = os.path.join(output_dir, f"{base_name}.parquet")
218 | metadata_file = os.path.join(output_dir, f"{base_name}_metadata.json")
219 |
220 | # Write to parquet with metadata
221 | write_to_parquet(obs_data, parquet_file, metadata, metadata_file)
222 | ```
223 |
224 | ### Processing Multiple Files
225 |
226 | ```python
227 | from pygnsslab.io.rinex2 import Rinex2Reader
228 | from pygnsslab.io.rinex2.writer import write_to_parquet
229 | from pygnsslab.io.rinex2.utils import find_rinex2_files
230 | import os
231 |
232 | # Find all RINEX 2 files in a directory
233 | rinex_dir = "path/to/rinex_directory"
234 | rinex_files = find_rinex2_files(rinex_dir)
235 |
236 | # Process each file
237 | output_dir = "output"
238 | os.makedirs(output_dir, exist_ok=True)
239 |
240 | for rinex_file in rinex_files:
241 | # Extract base name
242 | base_name = os.path.splitext(os.path.basename(rinex_file))[0]
243 |
244 | # Read RINEX 2 file
245 | print(f"Processing {rinex_file}...")
246 | rinex_reader = Rinex2Reader(rinex_file)
247 |
248 | # Get metadata and observations
249 | metadata = rinex_reader.metadata
250 | obs_data = rinex_reader.get_obs_data()
251 |
252 | # Convert datetime objects to ISO format strings for JSON serialization
253 | if metadata.get('firstObsTime'):
254 | metadata['firstObsTime'] = metadata['firstObsTime'].isoformat()
255 | if metadata.get('lastObsTime'):
256 | metadata['lastObsTime'] = metadata['lastObsTime'].isoformat()
257 |
258 | # Save to parquet format
259 | parquet_file = os.path.join(output_dir, f"{base_name}.parquet")
260 | metadata_file = os.path.join(output_dir, f"{base_name}_metadata.json")
261 |
262 | # Write to parquet with metadata
263 | write_to_parquet(obs_data, parquet_file, metadata, metadata_file)
264 |
265 | print(f"Saved to {parquet_file} and {metadata_file}")
266 | ```
267 |
268 | ### Comparing Metadata from Multiple Files
269 |
270 | ```python
271 | from pygnsslab.io.rinex2 import Rinex2Reader
272 | from pygnsslab.io.rinex2.metadata import compare_metadata
273 | import os
274 |
275 | # Paths to RINEX 2 files
276 | rinex_file1 = "path/to/rinex_file1.o"
277 | rinex_file2 = "path/to/rinex_file2.o"
278 |
279 | # Read RINEX 2 files
280 | rinex_reader1 = Rinex2Reader(rinex_file1)
281 | rinex_reader2 = Rinex2Reader(rinex_file2)
282 |
283 | # Get metadata
284 | metadata1 = rinex_reader1.metadata
285 | metadata2 = rinex_reader2.metadata
286 |
287 | # Convert datetime objects to strings for comparison
288 | for metadata in [metadata1, metadata2]:
289 | if metadata.get('firstObsTime'):
290 | metadata['firstObsTime'] = metadata['firstObsTime'].isoformat()
291 | if metadata.get('lastObsTime'):
292 | metadata['lastObsTime'] = metadata['lastObsTime'].isoformat()
293 |
294 | # Compare metadata
295 | differences = compare_metadata(metadata1, metadata2)
296 |
297 | # Print differences
298 | if differences:
299 | print("Differences found in metadata:")
300 | for key, diff in differences.items():
301 | print(f" {key}: {diff}")
302 | else:
303 | print("No differences found in metadata")
304 | ```
305 |
306 | ## Advanced Usage
307 |
308 | ### Filtering Observation Data
309 |
310 | ```python
311 | from pygnsslab.io.rinex2 import Rinex2Reader
312 | import pandas as pd
313 |
314 | # Read RINEX 2 file
315 | rinex_reader = Rinex2Reader("path/to/rinex_file.o")
316 | obs_data = rinex_reader.get_obs_data()
317 |
318 | # Filter by constellation (e.g., GPS only)
319 | gps_data = obs_data[obs_data['constellation'] == 'G']
320 |
321 | # Filter by satellite
322 | sat_data = obs_data[obs_data['sat'] == 'G01']
323 |
324 | # Filter by time range
325 | start_mjd = 59000.0
326 | end_mjd = 59001.0
327 | time_filtered_data = obs_data[(obs_data['mjd'] >= start_mjd) & (obs_data['mjd'] <= end_mjd)]
328 |
329 | # Filter by signal quality (SNR > 30)
330 | quality_data = obs_data[obs_data['snr'] > 30]
331 |
332 | # Filter by observation type (has valid pseudorange)
333 | range_data = obs_data[obs_data['range'].notna()]
334 | ```
335 |
336 | ### Analyzing Observation Data
337 |
338 | ```python
339 | import matplotlib.pyplot as plt
340 | from pygnsslab.io.rinex2 import Rinex2Reader
341 |
342 | # Read RINEX 2 file
343 | rinex_reader = Rinex2Reader("path/to/rinex_file.o")
344 | obs_data = rinex_reader.get_obs_data()
345 |
346 | # Group by constellation and count satellites
347 | sat_counts = obs_data.groupby('constellation')['sat'].nunique()
348 | print("Satellites by constellation:")
349 | print(sat_counts)
350 |
351 | # Plot satellite counts
352 | sat_counts.plot(kind='bar')
353 | plt.title('Number of Satellites by Constellation')
354 | plt.xlabel('Constellation')
355 | plt.ylabel('Number of Satellites')
356 | plt.grid(True)
357 | plt.savefig('satellite_counts.png')
358 |
359 | # Analyze SNR distribution
360 | plt.figure()
361 | obs_data['snr'].hist(bins=20)
362 | plt.title('SNR Distribution')
363 | plt.xlabel('SNR')
364 | plt.ylabel('Frequency')
365 | plt.grid(True)
366 | plt.savefig('snr_distribution.png')
367 |
368 | # Analyze time coverage
369 | obs_data['mjd_rounded'] = obs_data['mjd'].round(5) # Round to avoid floating point issues
370 | unique_epochs = obs_data['mjd_rounded'].unique()
371 | print(f"Number of unique epochs: {len(unique_epochs)}")
372 | print(f"First epoch: {unique_epochs.min()}")
373 | print(f"Last epoch: {unique_epochs.max()}")
374 | print(f"Approximate interval: {(unique_epochs[1:] - unique_epochs[:-1]).mean() * 86400:.1f} seconds")
375 | ```
376 |
377 | ### Converting Between RINEX 2 and RINEX 3 Format Data
378 |
379 | The module's DataFrame structure is designed to be compatible with the RINEX 3 module, allowing for easy conversion between formats.
380 |
381 | ```python
382 | from pygnsslab.io.rinex2 import Rinex2Reader
383 | from pygnsslab.io.rinex3.writer import write_to_parquet as write_rinex3
384 |
385 | # Read RINEX 2 file
386 | rinex2_reader = Rinex2Reader("path/to/rinex_file.o")
387 | obs_data = rinex2_reader.get_obs_data()
388 | metadata = rinex2_reader.metadata
389 |
390 | # Convert datetime objects to strings for JSON serialization
391 | if metadata.get('firstObsTime'):
392 | metadata['firstObsTime'] = metadata['firstObsTime'].isoformat()
393 | if metadata.get('lastObsTime'):
394 | metadata['lastObsTime'] = metadata['lastObsTime'].isoformat()
395 |
396 | # Save in RINEX 3 compatible format
397 | output_file = "output_rinex3_format.parquet"
398 | metadata_file = "output_rinex3_metadata.json"
399 |
400 | # Use the RINEX 3 writer
401 | write_rinex3(obs_data, output_file, metadata, metadata_file)
402 | ```
403 |
404 | ## Handling RINEX 2 Specific Features
405 |
406 | ### Wavelength Factors
407 |
408 | RINEX 2 files may contain wavelength factor information, which is stored in the metadata dictionary.
409 |
410 | ```python
411 | from pygnsslab.io.rinex2 import Rinex2Reader
412 |
413 | # Read RINEX 2 file
414 | rinex_reader = Rinex2Reader("path/to/rinex_file.o")
415 | metadata = rinex_reader.metadata
416 |
417 | # Access wavelength factors
418 | wavelength_factors = metadata.get('wavelengthFactors', [])
419 |
420 | for wf in wavelength_factors:
421 | print(f"L1 factor: {wf['L1']}")
422 | print(f"L2 factor: {wf['L2']}")
423 | if wf['satellites']:
424 | print(f"Applies to satellites: {', '.join(wf['satellites'])}")
425 | else:
426 | print("Applies to all satellites")
427 | ```
428 |
429 | ### Two-Digit Year Handling
430 |
431 | RINEX 2 files often use two-digit years, which are handled by the parser according to the following convention:
432 | - Years 00-79 are interpreted as 2000-2079
433 | - Years 80-99 are interpreted as 1980-1999
434 |
435 | ```python
436 | from pygnsslab.io.rinex2 import Rinex2Reader
437 |
438 | # Read RINEX 2 file with two-digit year
439 | rinex_reader = Rinex2Reader("path/to/rinex_file.99o") # Year 1999
440 | metadata = rinex_reader.metadata
441 |
442 | # Access first observation time (correctly interpreted as 1999)
443 | first_obs = metadata.get('firstObsTime')
444 | print(f"First observation time: {first_obs}")
445 | ```
446 |
447 | ## Limitations and Considerations
448 |
449 | 1. **Memory Usage**: Processing large RINEX files can be memory-intensive, especially when loading the entire observation dataset into a pandas DataFrame.
450 |
451 | 2. **File Compatibility**: The module is designed specifically for RINEX 2 format. It may not work correctly with RINEX 1, RINEX 3, or other specialized variations.
452 |
453 | 3. **Missing Data**: RINEX files may contain missing or corrupted data. The module handles these cases by setting corresponding values to None in the DataFrame.
454 |
455 | 4. **Performance**: The current implementation prioritizes readability and correctness over performance. For very large files or high-performance applications, additional optimizations may be necessary.
456 |
457 | 5. **Two-Digit Year Ambiguity**: The RINEX 2 standard uses two-digit years which can be ambiguous. The parser follows the convention that years 00-79 are interpreted as 2000-2079 and years 80-99 are interpreted as 1980-1999.
458 |
459 | 6. **Constellation Compatibility**: RINEX 2 was developed primarily for GPS observations, with limited support for other GNSS constellations. The module attempts to handle multi-constellation RINEX 2 files, but some specialized constellation-specific data may not be properly parsed.
460 |
461 | ## Differences Between RINEX 2 and RINEX 3 Handling
462 |
463 | The `pygnsslab.io.rinex2` module is designed to work alongside the `pygnsslab.io.rinex3` module, with several key differences in how data is processed:
464 |
465 | 1. **Observation Types**:
466 | - RINEX 2: Observation types are defined globally for all satellites in the header
467 | - RINEX 3: Observation types are defined per constellation
468 |
469 | 2. **Time Format**:
470 | - RINEX 2: Often uses two-digit years with specific epoch line formatting
471 | - RINEX 3: Uses four-digit years with a different epoch line format
472 |
473 | 3. **Multi-Constellation Support**:
474 | - RINEX 2: Limited built-in support for multiple constellations
475 | - RINEX 3: Full support for multiple GNSS constellations
476 |
477 | 4. **Compatibility Layer**:
478 | - The module converts RINEX 2 observation types to a RINEX 3-like structure where they are organized by constellation
479 | - Original global observation type list is preserved under the 'ALL' key in metadata
480 |
481 | ## Conclusion
482 |
483 | The `pygnsslab.io.rinex2` module provides a comprehensive set of tools for working with RINEX 2 observation files. It enables users to extract metadata, process observation data, convert between different time formats, and save processed data to more efficient file formats. The module is designed to work seamlessly with the RINEX 3 module, allowing for consistent processing of different RINEX version files.
--------------------------------------------------------------------------------
/docs/detailed/io/rinex3.md:
--------------------------------------------------------------------------------
1 | # pygnsslab.io.rinex3 Module Documentation
2 |
3 | ## Overview
4 |
5 | The `pygnsslab.io.rinex3` module is a Python package designed for parsing, processing, and manipulating RINEX 3 observation files. RINEX (Receiver Independent Exchange Format) is a standard format for GNSS (Global Navigation Satellite System) data. This module provides tools for extracting metadata and observation data from RINEX 3 files, converting between different time formats, and saving processed data to more efficient formats.
6 |
7 | ## Module Structure
8 |
9 | The `pygnsslab.io.rinex3` module consists of the following Python files:
10 |
11 | - `__init__.py`: Main module initialization that exports key functionality
12 | - `reader.py`: Contains the `Rinex3Reader` class for parsing RINEX 3 files
13 | - `writer.py`: Functions for saving observation data to parquet format
14 | - `metadata.py`: Functions for handling RINEX metadata
15 | - `utils.py`: Utility functions for time conversions and file operations
16 | - `example_usage.py`: Example script demonstrating how to use the module
17 |
18 | ## Installation
19 |
20 | The module is part of the `pygnsslab` package and is located at `./src/pygnsslab/io/rinex3`. To use it, you need to install the `pygnsslab` package.
21 |
22 | ## Key Components
23 |
24 | ### Rinex3Reader
25 |
26 | The `Rinex3Reader` class is the primary interface for reading and parsing RINEX 3 observation files.
27 |
28 | #### Usage
29 |
30 | ```python
31 | from pygnsslab.io.rinex3 import Rinex3Reader
32 |
33 | # Initialize the reader with a RINEX 3 file
34 | rinex_reader = Rinex3Reader("path/to/rinex_file.rnx")
35 |
36 | # Access metadata
37 | metadata = rinex_reader.metadata
38 |
39 | # Access observation data as a pandas DataFrame
40 | obs_data = rinex_reader.get_obs_data()
41 | ```
42 |
43 | #### Input
44 |
45 | - RINEX 3 observation files (typically with extensions `.rnx`, `.crx`, or `.obs`)
46 |
47 | #### Output
48 |
49 | - `metadata`: Dictionary containing header information from the RINEX file
50 | - `obs_data`: Pandas DataFrame containing observation data
51 |
52 | ### Metadata Handling
53 |
54 | The `metadata.py` module provides functions for extracting, formatting, and saving header metadata from RINEX 3 files.
55 |
56 | #### Key Functions
57 |
58 | - `extract_metadata(rinex_header)`: Extracts and formats metadata from RINEX header
59 | - `save_metadata(metadata, output_file)`: Saves metadata to a JSON file
60 | - `load_metadata(metadata_file)`: Loads metadata from a JSON file
61 | - `compare_metadata(metadata1, metadata2)`: Compares two metadata dictionaries and returns their differences
62 |
63 | #### Usage
64 |
65 | ```python
66 | from pygnsslab.io.rinex3.metadata import extract_metadata, save_metadata, load_metadata, compare_metadata
67 |
68 | # Extract metadata from RINEX header
69 | metadata = extract_metadata(rinex_header)
70 |
71 | # Save metadata to a JSON file
72 | save_metadata(metadata, "output_metadata.json")
73 |
74 | # Load metadata from a JSON file
75 | loaded_metadata = load_metadata("metadata.json")
76 |
77 | # Compare two metadata dictionaries
78 | differences = compare_metadata(metadata1, metadata2)
79 | ```
80 |
81 | ### Data Writing
82 |
83 | The `writer.py` module provides functions for saving RINEX observation data to parquet format and creating summary reports.
84 |
85 | #### Key Functions
86 |
87 | - `write_to_parquet(obs_data, output_file, metadata=None, metadata_file=None)`: Writes observation data to a parquet file and optionally saves metadata
88 | - `write_obs_summary(obs_data, output_file)`: Writes a summary of observation data to a text file
89 |
90 | #### Usage
91 |
92 | ```python
93 | from pygnsslab.io.rinex3.writer import write_to_parquet, write_obs_summary
94 |
95 | # Write observation data to parquet format
96 | parquet_file, metadata_file = write_to_parquet(obs_data, "output.parquet", metadata)
97 |
98 | # Write observation data summary
99 | summary_file = write_obs_summary(obs_data, "summary.txt")
100 | ```
101 |
102 | ### Utility Functions
103 |
104 | The `utils.py` module provides utility functions for time conversions and file operations.
105 |
106 | #### Time Conversion Functions
107 |
108 | - `datetime_to_mjd(dt)`: Converts a datetime object to Modified Julian Date (MJD)
109 | - `datetime_to_jd(dt)`: Converts a datetime object to Julian Date
110 | - `mjd_to_datetime(mjd)`: Converts Modified Julian Date to datetime object
111 | - `jd_to_datetime(jd)`: Converts Julian Date to datetime object
112 | - `time_to_seconds_of_day(time_obj)`: Converts a time object to seconds of day
113 |
114 | #### File Operations
115 |
116 | - `is_rinex3_file(filename)`: Checks if a file is a RINEX 3 observation file
117 | - `find_rinex3_files(directory)`: Finds all RINEX 3 observation files in a directory
118 |
119 | #### Usage
120 |
121 | ```python
122 | from pygnsslab.io.rinex3.utils import datetime_to_mjd, is_rinex3_file, find_rinex3_files
123 | import datetime
124 |
125 | # Convert datetime to MJD
126 | dt = datetime.datetime.now()
127 | mjd = datetime_to_mjd(dt)
128 |
129 | # Check if a file is a RINEX 3 file
130 | is_rinex3 = is_rinex3_file("path/to/file.rnx")
131 |
132 | # Find all RINEX 3 files in a directory
133 | rinex_files = find_rinex3_files("path/to/directory")
134 | ```
135 |
136 | ## Data Formats
137 |
138 | ### RINEX 3 Format
139 |
140 | The RINEX 3 format is a standard format for GNSS data. RINEX 3 observation files contain:
141 |
142 | 1. **Header Section**: Contains metadata about the observation session, including station information, receiver and antenna details, and observation types.
143 | 2. **Observation Section**: Contains the actual observation data from different satellites and constellations.
144 |
145 | ### Metadata Structure
146 |
147 | The extracted metadata from RINEX 3 files is structured as a Python dictionary with the following key fields:
148 |
149 | - `rinexVersion`: RINEX version number
150 | - `stationName`: Name of the station
151 | - `approxPos`: Approximate position of the station (X, Y, Z coordinates)
152 | - `receiverNumber`: Receiver number
153 | - `receiverType`: Receiver type
154 | - `receiverVersion`: Receiver firmware version
155 | - `antennaNumber`: Antenna number
156 | - `antennaType`: Antenna type
157 | - `observationTypes`: Dictionary of observation types by constellation
158 |
159 | ### Observation Data Structure
160 |
161 | The observation data is structured as a pandas DataFrame with the following columns:
162 |
163 | - `date`: Observation date
164 | - `epoch_sec`: Seconds of day
165 | - `mjd`: Modified Julian Date
166 | - `constellation`: GNSS constellation (G=GPS, R=GLONASS, E=Galileo, C=BeiDou, J=QZSS, S=SBAS)
167 | - `sat`: Satellite identifier
168 | - `channel`: Signal channel/frequency
169 | - `range`: Pseudorange measurement
170 | - `phase`: Carrier phase measurement
171 | - `doppler`: Doppler measurement
172 | - `snr`: Signal-to-noise ratio
173 |
174 | ## Example Usage
175 |
176 | ### Basic Example
177 |
178 | ```python
179 | from pygnsslab.io.rinex3 import Rinex3Reader
180 | from pygnsslab.io.rinex3.writer import write_to_parquet
181 | import os
182 |
183 | # Path to RINEX 3 file
184 | rinex_file = "path/to/rinex_file.rnx"
185 |
186 | # Read RINEX 3 file
187 | rinex_reader = Rinex3Reader(rinex_file)
188 |
189 | # Get metadata and observations
190 | metadata = rinex_reader.metadata
191 | obs_data = rinex_reader.get_obs_data()
192 |
193 | # Save to parquet format
194 | output_dir = "output"
195 | os.makedirs(output_dir, exist_ok=True)
196 | base_name = os.path.splitext(os.path.basename(rinex_file))[0]
197 | parquet_file = os.path.join(output_dir, f"{base_name}.parquet")
198 | metadata_file = os.path.join(output_dir, f"{base_name}_metadata.json")
199 |
200 | # Write to parquet with metadata
201 | write_to_parquet(obs_data, parquet_file, metadata, metadata_file)
202 | ```
203 |
204 | ### Processing Multiple Files
205 |
206 | ```python
207 | from pygnsslab.io.rinex3 import Rinex3Reader
208 | from pygnsslab.io.rinex3.writer import write_to_parquet
209 | from pygnsslab.io.rinex3.utils import find_rinex3_files
210 | import os
211 |
212 | # Find all RINEX 3 files in a directory
213 | rinex_dir = "path/to/rinex_directory"
214 | rinex_files = find_rinex3_files(rinex_dir)
215 |
216 | # Process each file
217 | output_dir = "output"
218 | os.makedirs(output_dir, exist_ok=True)
219 |
220 | for rinex_file in rinex_files:
221 | # Extract base name
222 | base_name = os.path.splitext(os.path.basename(rinex_file))[0]
223 |
224 | # Read RINEX 3 file
225 | print(f"Processing {rinex_file}...")
226 | rinex_reader = Rinex3Reader(rinex_file)
227 |
228 | # Get metadata and observations
229 | metadata = rinex_reader.metadata
230 | obs_data = rinex_reader.get_obs_data()
231 |
232 | # Save to parquet format
233 | parquet_file = os.path.join(output_dir, f"{base_name}.parquet")
234 | metadata_file = os.path.join(output_dir, f"{base_name}_metadata.json")
235 |
236 | # Write to parquet with metadata
237 | write_to_parquet(obs_data, parquet_file, metadata, metadata_file)
238 |
239 | print(f"Saved to {parquet_file} and {metadata_file}")
240 | ```
241 |
242 | ### Comparing Metadata from Multiple Files
243 |
244 | ```python
245 | from pygnsslab.io.rinex3 import Rinex3Reader
246 | from pygnsslab.io.rinex3.metadata import compare_metadata
247 | import os
248 |
249 | # Paths to RINEX 3 files
250 | rinex_file1 = "path/to/rinex_file1.rnx"
251 | rinex_file2 = "path/to/rinex_file2.rnx"
252 |
253 | # Read RINEX 3 files
254 | rinex_reader1 = Rinex3Reader(rinex_file1)
255 | rinex_reader2 = Rinex3Reader(rinex_file2)
256 |
257 | # Get metadata
258 | metadata1 = rinex_reader1.metadata
259 | metadata2 = rinex_reader2.metadata
260 |
261 | # Compare metadata
262 | differences = compare_metadata(metadata1, metadata2)
263 |
264 | # Print differences
265 | if differences:
266 | print("Differences found in metadata:")
267 | for key, diff in differences.items():
268 | print(f" {key}: {diff}")
269 | else:
270 | print("No differences found in metadata")
271 | ```
272 |
273 | ## Advanced Usage
274 |
275 | ### Filtering Observation Data
276 |
277 | ```python
278 | from pygnsslab.io.rinex3 import Rinex3Reader
279 | import pandas as pd
280 |
281 | # Read RINEX 3 file
282 | rinex_reader = Rinex3Reader("path/to/rinex_file.rnx")
283 | obs_data = rinex_reader.get_obs_data()
284 |
285 | # Filter by constellation (e.g., GPS only)
286 | gps_data = obs_data[obs_data['constellation'] == 'G']
287 |
288 | # Filter by satellite
289 | sat_data = obs_data[obs_data['sat'] == 'G01']
290 |
291 | # Filter by time range
292 | start_mjd = 59000.0
293 | end_mjd = 59001.0
294 | time_filtered_data = obs_data[(obs_data['mjd'] >= start_mjd) & (obs_data['mjd'] <= end_mjd)]
295 |
296 | # Filter by signal quality (SNR > 30)
297 | quality_data = obs_data[obs_data['snr'] > 30]
298 | ```
299 |
300 | ### Analyzing Observation Data
301 |
302 | ```python
303 | import matplotlib.pyplot as plt
304 | from pygnsslab.io.rinex3 import Rinex3Reader
305 |
306 | # Read RINEX 3 file
307 | rinex_reader = Rinex3Reader("path/to/rinex_file.rnx")
308 | obs_data = rinex_reader.get_obs_data()
309 |
310 | # Group by constellation and count satellites
311 | sat_counts = obs_data.groupby('constellation')['sat'].nunique()
312 | print("Satellites by constellation:")
313 | print(sat_counts)
314 |
315 | # Plot satellite counts
316 | sat_counts.plot(kind='bar')
317 | plt.title('Number of Satellites by Constellation')
318 | plt.xlabel('Constellation')
319 | plt.ylabel('Number of Satellites')
320 | plt.grid(True)
321 | plt.savefig('satellite_counts.png')
322 |
323 | # Analyze SNR distribution
324 | plt.figure()
325 | obs_data['snr'].hist(bins=20)
326 | plt.title('SNR Distribution')
327 | plt.xlabel('SNR')
328 | plt.ylabel('Frequency')
329 | plt.grid(True)
330 | plt.savefig('snr_distribution.png')
331 | ```
332 |
333 | ## Limitations and Considerations
334 |
335 | 1. **Memory Usage**: Processing large RINEX files can be memory-intensive, especially when loading the entire observation dataset into a pandas DataFrame.
336 |
337 | 2. **File Compatibility**: The module is designed specifically for RINEX 3 format. It may not work correctly with RINEX 2 or other variations.
338 |
339 | 3. **Missing Data**: RINEX files may contain missing or corrupted data. The module handles these cases by setting corresponding values to None in the DataFrame.
340 |
341 | 4. **Performance**: The current implementation prioritizes readability and correctness over performance. For very large files or high-performance applications, additional optimizations may be necessary.
342 |
343 | ## Conclusion
344 |
345 | The `pygnsslab.io.rinex3` module provides a comprehensive set of tools for working with RINEX 3 observation files. It enables users to extract metadata, process observation data, and convert between different time formats and file formats. With its intuitive API and versatile functionality, it serves as a valuable resource for GNSS data processing and analysis.
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=source
11 | set BUILDDIR=build
12 |
13 | %SPHINXBUILD% >NUL 2>NUL
14 | if errorlevel 9009 (
15 | echo.
16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
17 | echo.installed, then set the SPHINXBUILD environment variable to point
18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
19 | echo.may add the Sphinx directory to PATH.
20 | echo.
21 | echo.If you don't have Sphinx installed, grab it from
22 | echo.https://www.sphinx-doc.org/
23 | exit /b 1
24 | )
25 |
26 | if "%1" == "" goto help
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/source/api.rst:
--------------------------------------------------------------------------------
1 | API Reference
2 | =============
3 |
4 | .. toctree::
5 | :maxdepth: 1
6 |
7 | modules/rinex2
8 | modules/rinex3
9 | modules/rinexnav
10 |
11 | Detailed Module Documentation
12 | -----------------------------
13 |
14 | This section contains the auto-generated API documentation for each module.
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | sys.path.insert(0, os.path.abspath('../../src'))
4 |
5 | # Configuration file for the Sphinx documentation builder.
6 | #
7 | # For the full list of built-in configuration values, see the documentation:
8 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
9 |
10 | # -- Project information -----------------------------------------------------
11 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
12 |
13 | project = 'pygnsslab'
14 | copyright = '2025, Cemali Altuntas, PyGNSSLab'
15 | author = 'Cemali Altuntas, PyGNSSLab'
16 | release = '0.1.0'
17 |
18 | # -- General configuration ---------------------------------------------------
19 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
20 |
21 | extensions = [
22 | 'sphinx.ext.autodoc',
23 | 'sphinx.ext.napoleon', # Support for Google and NumPy style docstrings
24 | 'sphinx.ext.viewcode', # Add links to source code
25 | 'sphinx.ext.intersphinx', # Link to other projects' documentation
26 | # Add 'myst_parser' if you want to use Markdown
27 | ]
28 |
29 | # Configuration for intersphinx: refer to the Python standard library.
30 | intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
31 |
32 | # Napoleon settings (optional, if you want to customize)
33 | napoleon_google_docstring = True
34 | napoleon_numpy_docstring = True
35 | napoleon_include_init_with_doc = False
36 | napoleon_include_private_with_doc = False
37 | napoleon_include_special_with_doc = True
38 | napoleon_use_admonition_for_examples = False
39 | napoleon_use_admonition_for_notes = False
40 | napoleon_use_admonition_for_references = False
41 | napoleon_use_ivar = False
42 | napoleon_use_param = True
43 | napoleon_use_rtype = True
44 |
45 | templates_path = ['_templates']
46 | exclude_patterns = []
47 |
48 |
49 |
50 | # -- Options for HTML output -------------------------------------------------
51 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
52 |
53 | html_theme = 'sphinx_rtd_theme' # Use the Read the Docs theme
54 | html_static_path = ['_static']
55 |
56 | # Add any paths that contain custom static files (such as style sheets) here,
57 | # relative to this directory. They are copied after the builtin static files,
58 | # so a file named "default.css" will overwrite the builtin "default.css".
59 | # html_static_path = ['_static']
60 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | .. pygnsslab documentation master file, created by
2 | sphinx-quickstart on Fri Apr 25 15:30:31 2025.
3 | You can adapt this file completely to your liking, but it should at least
4 | contain the root `toctree` directive.
5 |
6 | pygnsslab documentation
7 | =======================
8 |
9 | Add your content using ``reStructuredText`` syntax. See the
10 | `reStructuredText `_
11 | documentation for details.
12 |
13 |
14 | .. toctree::
15 | :maxdepth: 2
16 | :caption: Contents:
17 |
18 | usage
19 | api
20 |
21 | Indices and tables
22 | ==================
23 |
24 |
--------------------------------------------------------------------------------
/docs/source/modules/rinex2.rst:
--------------------------------------------------------------------------------
1 | pygnsslab.io.rinex2
2 | =====================
3 |
4 | .. automodule:: pygnsslab.io.rinex2
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
--------------------------------------------------------------------------------
/docs/source/modules/rinex3.rst:
--------------------------------------------------------------------------------
1 | pygnsslab.io.rinex3
2 | =====================
3 |
4 | .. automodule:: pygnsslab.io.rinex3
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
--------------------------------------------------------------------------------
/docs/source/modules/rinexnav.rst:
--------------------------------------------------------------------------------
1 | pygnsslab.io.rinexnav
2 | =======================
3 |
4 | .. automodule:: pygnsslab.io.rinexnav
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
--------------------------------------------------------------------------------
/docs/source/usage.rst:
--------------------------------------------------------------------------------
1 | Usage
2 | =====
3 |
4 | .. toctree::
5 | :maxdepth: 1
6 |
7 | This section will contain usage examples and tutorials.
--------------------------------------------------------------------------------
/pygnsslab_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PyGnssLab/pygnsslab/ed124951cf3db268a9fc6c63392a308b84680220/pygnsslab_logo.png
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=45", "wheel"]
3 | build-backend = "setuptools.build_meta"
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | numpy
2 | pandas
3 | matplotlib
4 | setuptools
5 | pytest
6 | pyarrow
7 | sphinx
8 | sphinx-rtd-theme
9 | myst-parser
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup, find_packages
2 |
3 | # Read requirements from requirements.txt
4 | with open("requirements.txt", "r") as f:
5 | requirements = [line.strip() for line in f if line.strip() and not line.startswith("#")]
6 |
7 | setup(
8 | name="pygnsslab",
9 | version="0.1.0",
10 | packages=find_packages(where="src"),
11 | package_dir={"": "src"},
12 | install_requires=requirements,
13 | )
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | pygnsslab - A Python package for GNSS data processing and analysis
3 | """
4 |
--------------------------------------------------------------------------------
/src/pygnsslab/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PyGnssLab/pygnsslab/ed124951cf3db268a9fc6c63392a308b84680220/src/pygnsslab/__init__.py
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex2/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | RINEX 2 parser module for reading observation files.
3 | Extracts header metadata and observation data.
4 | """
5 |
6 | from .reader import Rinex2Reader
7 | from .writer import write_to_parquet
8 |
9 | __all__ = ['Rinex2Reader', 'write_to_parquet']
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex2/example_usage.py:
--------------------------------------------------------------------------------
1 | """
2 | Example script demonstrating how to use the RINEX 2 parser module.
3 | """
4 |
5 | import os
6 | import json
7 | import logging
8 | import pandas as pd
9 | from pathlib import Path
10 | from datetime import datetime
11 | from pygnsslab.io.rinex2.reader import Rinex2Reader
12 | from pygnsslab.io.rinex2.writer import write_to_parquet
13 | from pygnsslab.io.rinex2.utils import find_rinex2_files
14 |
15 | def process_rinex_file(rinex_file, output_dir):
16 | """
17 | Process a RINEX 2 file, extracting metadata and observation data.
18 |
19 | Parameters:
20 | -----------
21 | rinex_file : str
22 | Path to the RINEX 2 file
23 | output_dir : str
24 | Path to the output directory
25 |
26 | Returns:
27 | --------
28 | tuple
29 | (metadata_file_path, parquet_file_path)
30 | """
31 | # Create output directory if it doesn't exist
32 | os.makedirs(output_dir, exist_ok=True)
33 |
34 | # Get the base filename without extension
35 | base_name = Path(rinex_file).stem
36 |
37 | # Create output file paths
38 | metadata_file = os.path.join(output_dir, f"{base_name}_metadata.json")
39 | parquet_file = os.path.join(output_dir, f"{base_name}_observations.parquet")
40 |
41 | # Read RINEX 2 file
42 | print(f"Reading RINEX file: {rinex_file}")
43 | rinex_reader = Rinex2Reader(rinex_file)
44 |
45 | # Get metadata and observations
46 | metadata = rinex_reader.metadata
47 | obs_data = rinex_reader.get_obs_data()
48 |
49 | # Convert datetime objects to ISO format strings
50 | if metadata.get('firstObsTime'):
51 | metadata['firstObsTime'] = metadata['firstObsTime'].isoformat()
52 | if metadata.get('lastObsTime'):
53 | metadata['lastObsTime'] = metadata['lastObsTime'].isoformat()
54 |
55 | # Save metadata to JSON
56 | with open(metadata_file, 'w') as f:
57 | json.dump(metadata, f, indent=2)
58 | print(f"Metadata saved to: {metadata_file}")
59 |
60 | # Save observations to parquet
61 | write_to_parquet(obs_data, parquet_file, metadata, metadata_file)
62 | print(f"Observations saved to: {parquet_file}")
63 |
64 | return metadata_file, parquet_file
65 |
66 | def print_metadata_summary(metadata_file):
67 | """
68 | Print a summary of the metadata.
69 |
70 | Parameters:
71 | -----------
72 | metadata_file : str
73 | Path to the metadata JSON file
74 | """
75 | with open(metadata_file, 'r') as f:
76 | metadata = json.load(f)
77 |
78 | print("\nRINEX Metadata Summary:")
79 | print(f" RINEX Version: {metadata.get('rinexVersion', 'N/A')}")
80 | print(f" Station Name: {metadata.get('stationName', 'N/A')}")
81 | print(f" Receiver Type: {metadata.get('receiverType', 'N/A')}")
82 | print(f" Antenna Type: {metadata.get('antennaType', 'N/A')}")
83 |
84 | # Print observation types
85 | print("\nObservation Types:")
86 | for constellation, obs_types in metadata.get('observationTypes', {}).items():
87 | # Join observation types with commas and wrap at 60 characters
88 | obs_str = ', '.join(obs_types)
89 | # Split into lines of max 60 characters
90 | obs_lines = [obs_str[i:i+60] for i in range(0, len(obs_str), 60)]
91 | print(f" {constellation}: {obs_lines[0]}")
92 | for line in obs_lines[1:]:
93 | print(f" {line}")
94 |
95 | # Print time information
96 | print("\nTime Information:")
97 | print(f" First Observation: {metadata.get('firstObsTime', 'N/A')}")
98 | print(f" Last Observation: {metadata.get('lastObsTime', 'N/A')}")
99 | print(f" Interval: {metadata.get('interval', 'N/A')} seconds")
100 |
101 | def main():
102 | """
103 | Main function to demonstrate the RINEX 2 parser.
104 | """
105 | # Example file path (replace with an actual file path)
106 | rinex_file = os.path.join("data", "obs", "ajac0010.22o")
107 |
108 | # Check if the file exists
109 | if not os.path.exists(rinex_file):
110 | print(f"File not found: {rinex_file}")
111 | print("Please provide a valid RINEX 2 observation file path.")
112 | return
113 |
114 | # Process the file
115 | output_dir = "data/jsonpqt"
116 | metadata_file, parquet_file = process_rinex_file(rinex_file, output_dir)
117 |
118 | # Print metadata summary
119 | print_metadata_summary(metadata_file)
120 |
121 | # Alternatively, process all RINEX 2 files in a directory
122 | # rinex_dir = "rinex_data"
123 | # rinex_files = find_rinex2_files(rinex_dir)
124 | # for rinex_file in rinex_files:
125 | # process_rinex_file(rinex_file, output_dir)
126 |
127 | if __name__ == "__main__":
128 | main()
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex2/metadata.py:
--------------------------------------------------------------------------------
1 | """
2 | Metadata handling module for RINEX 2 files.
3 | Provides functions to extract, format, and save header metadata.
4 | """
5 |
6 | import json
7 | import os
8 | from pathlib import Path
9 |
10 | def extract_metadata(rinex_header):
11 | """
12 | Extract and format metadata from RINEX header.
13 |
14 | Parameters:
15 | -----------
16 | rinex_header : dict
17 | Dictionary containing RINEX header information
18 |
19 | Returns:
20 | --------
21 | dict
22 | Formatted metadata dictionary
23 | """
24 | metadata = {
25 | 'rinexVersion': rinex_header.get('rinexVersion'),
26 | 'stationName': rinex_header.get('stationName'),
27 | 'approxPos': rinex_header.get('approxPos'),
28 | 'receiverNumber': rinex_header.get('receiverNumber'),
29 | 'receiverType': rinex_header.get('receiverType'),
30 | 'receiverVersion': rinex_header.get('receiverVersion'),
31 | 'antennaNumber': rinex_header.get('antennaNumber'),
32 | 'antennaType': rinex_header.get('antennaType'),
33 | 'observationTypes': dict(rinex_header.get('observationTypes', {})),
34 | 'firstObsTime': rinex_header.get('firstObsTime'),
35 | 'lastObsTime': rinex_header.get('lastObsTime'),
36 | 'interval': rinex_header.get('interval')
37 | }
38 |
39 | # Convert datetime objects to strings for JSON serialization
40 | if metadata['firstObsTime']:
41 | metadata['firstObsTime'] = metadata['firstObsTime'].isoformat()
42 | if metadata['lastObsTime']:
43 | metadata['lastObsTime'] = metadata['lastObsTime'].isoformat()
44 |
45 | return metadata
46 |
47 | def save_metadata(metadata, output_file):
48 | """
49 | Save metadata to a JSON file.
50 |
51 | Parameters:
52 | -----------
53 | metadata : dict
54 | Metadata dictionary
55 | output_file : str
56 | Path to the output JSON file
57 |
58 | Returns:
59 | --------
60 | str
61 | Path to the output JSON file
62 | """
63 | # Create output directory if it doesn't exist
64 | output_path = Path(output_file)
65 | output_dir = output_path.parent
66 | os.makedirs(output_dir, exist_ok=True)
67 |
68 | # Write metadata to JSON
69 | with open(output_file, 'w') as f:
70 | json.dump(metadata, f, indent=2)
71 |
72 | return output_file
73 |
74 | def load_metadata(metadata_file):
75 | """
76 | Load metadata from a JSON file.
77 |
78 | Parameters:
79 | -----------
80 | metadata_file : str
81 | Path to the metadata JSON file
82 |
83 | Returns:
84 | --------
85 | dict
86 | Metadata dictionary
87 | """
88 | with open(metadata_file, 'r') as f:
89 | metadata = json.load(f)
90 |
91 | return metadata
92 |
93 | def compare_metadata(metadata1, metadata2):
94 | """
95 | Compare two metadata dictionaries and return their differences.
96 |
97 | Parameters:
98 | -----------
99 | metadata1 : dict
100 | First metadata dictionary
101 | metadata2 : dict
102 | Second metadata dictionary
103 |
104 | Returns:
105 | --------
106 | dict
107 | Dictionary of differences
108 | """
109 | differences = {}
110 |
111 | # Compare simple key-value pairs
112 | for key in set(metadata1.keys()) | set(metadata2.keys()):
113 | if key == 'observationTypes':
114 | continue # Handle separately
115 |
116 | if key not in metadata1:
117 | differences[key] = {'in_metadata1': None, 'in_metadata2': metadata2[key]}
118 | elif key not in metadata2:
119 | differences[key] = {'in_metadata1': metadata1[key], 'in_metadata2': None}
120 | elif metadata1[key] != metadata2[key]:
121 | differences[key] = {'in_metadata1': metadata1[key], 'in_metadata2': metadata2[key]}
122 |
123 | # Compare observation types
124 | if 'observationTypes' in metadata1 and 'observationTypes' in metadata2:
125 | obs_types1 = metadata1['observationTypes']
126 | obs_types2 = metadata2['observationTypes']
127 |
128 | obs_diff = {}
129 |
130 | # Check constellations in both
131 | for const in set(obs_types1.keys()) | set(obs_types2.keys()):
132 | if const not in obs_types1:
133 | obs_diff[const] = {'in_metadata1': None, 'in_metadata2': obs_types2[const]}
134 | elif const not in obs_types2:
135 | obs_diff[const] = {'in_metadata1': obs_types1[const], 'in_metadata2': None}
136 | elif set(obs_types1[const]) != set(obs_types2[const]):
137 | # Find differences in observation types
138 | only_in_1 = set(obs_types1[const]) - set(obs_types2[const])
139 | only_in_2 = set(obs_types2[const]) - set(obs_types1[const])
140 |
141 | obs_diff[const] = {
142 | 'only_in_metadata1': list(only_in_1) if only_in_1 else None,
143 | 'only_in_metadata2': list(only_in_2) if only_in_2 else None
144 | }
145 |
146 | if obs_diff:
147 | differences['observationTypes'] = obs_diff
148 |
149 | return differences
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex2/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Utility functions for RINEX 2 processing.
3 | """
4 |
5 | import datetime
6 | import numpy as np
7 | import os
8 | from pathlib import Path
9 |
10 | def datetime_to_mjd(dt):
11 | """
12 | Convert a datetime object to Modified Julian Date (MJD).
13 |
14 | Parameters:
15 | -----------
16 | dt : datetime.datetime
17 | Datetime object to convert
18 |
19 | Returns:
20 | --------
21 | float
22 | Modified Julian Date
23 | """
24 | # Convert to Julian date first
25 | jd = datetime_to_jd(dt)
26 | # Convert Julian date to Modified Julian Date
27 | mjd = jd - 2400000.5
28 |
29 | return mjd
30 |
31 | def datetime_to_jd(dt):
32 | """
33 | Convert a datetime object to Julian date.
34 |
35 | Parameters:
36 | -----------
37 | dt : datetime.datetime
38 | Datetime object to convert
39 |
40 | Returns:
41 | --------
42 | float
43 | Julian date
44 | """
45 | # Extract components
46 | year, month, day = dt.year, dt.month, dt.day
47 | hour, minute, second = dt.hour, dt.minute, dt.second
48 | microsecond = dt.microsecond
49 |
50 | # Calculate decimal day
51 | decimal_day = day + (hour / 24.0) + (minute / 1440.0) + ((second + microsecond / 1e6) / 86400.0)
52 |
53 | # Calculate Julian date
54 | if month <= 2:
55 | year -= 1
56 | month += 12
57 |
58 | A = int(year / 100)
59 | B = 2 - A + int(A / 4)
60 |
61 | jd = int(365.25 * (year + 4716)) + int(30.6001 * (month + 1)) + decimal_day + B - 1524.5
62 |
63 | return jd
64 |
65 | def mjd_to_datetime(mjd):
66 | """
67 | Convert Modified Julian Date (MJD) to datetime object.
68 |
69 | Parameters:
70 | -----------
71 | mjd : float
72 | Modified Julian Date
73 |
74 | Returns:
75 | --------
76 | datetime.datetime
77 | Datetime object
78 | """
79 | # Convert MJD to Julian date
80 | jd = mjd + 2400000.5
81 |
82 | # Convert Julian date to datetime
83 | return jd_to_datetime(jd)
84 |
85 | def jd_to_datetime(jd):
86 | """
87 | Convert Julian date to datetime object.
88 |
89 | Parameters:
90 | -----------
91 | jd : float
92 | Julian date
93 |
94 | Returns:
95 | --------
96 | datetime.datetime
97 | Datetime object
98 | """
99 | jd = jd + 0.5 # Shift by 0.5 day
100 |
101 | # Calculate integer and fractional parts
102 | F, I = np.modf(jd)
103 | I = int(I)
104 |
105 | A = np.trunc((I - 1867216.25) / 36524.25)
106 | B = I + 1 + A - np.trunc(A / 4)
107 |
108 | C = B + 1524
109 | D = np.trunc((C - 122.1) / 365.25)
110 | E = np.trunc(365.25 * D)
111 | G = np.trunc((C - E) / 30.6001)
112 |
113 | # Calculate day, month, year
114 | day = C - E + F - np.trunc(30.6001 * G)
115 |
116 | if G < 13.5:
117 | month = G - 1
118 | else:
119 | month = G - 13
120 |
121 | if month > 2.5:
122 | year = D - 4716
123 | else:
124 | year = D - 4715
125 |
126 | # Calculate time
127 | day_frac = day - int(day)
128 | hours = int(day_frac * 24)
129 | minutes = int((day_frac * 24 - hours) * 60)
130 | seconds = ((day_frac * 24 - hours) * 60 - minutes) * 60
131 |
132 | # Create datetime object
133 | try:
134 | dt = datetime.datetime(
135 | int(year),
136 | int(month),
137 | int(day),
138 | hours,
139 | minutes,
140 | int(seconds),
141 | int((seconds - int(seconds)) * 1e6)
142 | )
143 | return dt
144 | except ValueError:
145 | # Handling edge cases
146 | if int(day) == 0:
147 | # Handle case where day is calculated as 0
148 | month = int(month) - 1
149 | if month == 0:
150 | month = 12
151 | year = int(year) - 1
152 | days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
153 | # Adjust for leap year
154 | if month == 2 and (year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)):
155 | day = 29
156 | else:
157 | day = days_in_month[month]
158 | else:
159 | day = int(day)
160 |
161 | return datetime.datetime(
162 | int(year),
163 | int(month),
164 | day,
165 | hours,
166 | minutes,
167 | int(seconds),
168 | int((seconds - int(seconds)) * 1e6)
169 | )
170 |
171 | def time_to_seconds_of_day(time_obj):
172 | """
173 | Convert a time object to seconds of day.
174 |
175 | Parameters:
176 | -----------
177 | time_obj : datetime.time
178 | Time object to convert
179 |
180 | Returns:
181 | --------
182 | float
183 | Seconds of day
184 | """
185 | return time_obj.hour * 3600 + time_obj.minute * 60 + time_obj.second + time_obj.microsecond / 1e6
186 |
187 | def is_rinex2_file(filename):
188 | """
189 | Check if a file is a RINEX 2 observation file.
190 |
191 | Parameters:
192 | -----------
193 | filename : str
194 | Path to the file
195 |
196 | Returns:
197 | --------
198 | bool
199 | True if the file is a RINEX 2 observation file, False otherwise
200 | """
201 | if not os.path.exists(filename):
202 | return False
203 |
204 | # Check file extension
205 | ext = Path(filename).suffix.lower()
206 | if ext not in ['.obs', '.o', '.yyo', '.yyO', '.yyN', '.yyG']:
207 | # Not a standard RINEX 2 observation file extension
208 | return False
209 |
210 | # Check file content
211 | try:
212 | with open(filename, 'r') as f:
213 | # Read first few lines
214 | header_lines = [f.readline() for _ in range(10)]
215 |
216 | # Check for RINEX 2 version marker
217 | for line in header_lines:
218 | if "RINEX VERSION / TYPE" in line and line.strip().startswith("2"):
219 | return True
220 | except:
221 | # If there's an error reading the file, assume it's not a valid RINEX file
222 | return False
223 |
224 | return False
225 |
226 | def find_rinex2_files(directory):
227 | """
228 | Find all RINEX 2 observation files in a directory.
229 |
230 | Parameters:
231 | -----------
232 | directory : str
233 | Path to the directory
234 |
235 | Returns:
236 | --------
237 | list
238 | List of paths to RINEX 2 observation files
239 | """
240 | rinex_files = []
241 |
242 | for root, _, files in os.walk(directory):
243 | for file in files:
244 | filepath = os.path.join(root, file)
245 | if is_rinex2_file(filepath):
246 | rinex_files.append(filepath)
247 |
248 | return rinex_files
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex2/writer.py:
--------------------------------------------------------------------------------
1 | """
2 | Writer module for saving RINEX 2 observation data to parquet format.
3 | """
4 |
5 | import os
6 | import json
7 | import pandas as pd
8 | from pathlib import Path
9 |
10 | def write_to_parquet(obs_data, output_file, metadata=None, metadata_file=None):
11 | """
12 | Write observation data to a parquet file and optionally save metadata to a JSON file.
13 |
14 | Parameters:
15 | -----------
16 | obs_data : pandas.DataFrame
17 | DataFrame containing observation data
18 | output_file : str
19 | Path to the output parquet file
20 | metadata : dict, optional
21 | Metadata dictionary to save
22 | metadata_file : str, optional
23 | Path to the output metadata JSON file. If None but metadata is provided,
24 | a default name will be created based on output_file
25 |
26 | Returns:
27 | --------
28 | tuple
29 | (parquet_file_path, metadata_file_path)
30 | """
31 | # Create output directory if it doesn't exist
32 | output_path = Path(output_file)
33 | output_dir = output_path.parent
34 | os.makedirs(output_dir, exist_ok=True)
35 |
36 | # Ensure data has the required columns
37 | required_columns = ['date', 'epoch_sec', 'mjd', 'constellation', 'sat',
38 | 'channel', 'range', 'phase', 'doppler', 'snr']
39 |
40 | # Create empty columns if they don't exist
41 | for col in required_columns:
42 | if col not in obs_data.columns:
43 | obs_data[col] = None
44 |
45 | # Write observation data to parquet
46 | obs_data.to_parquet(output_file, index=False)
47 |
48 | # Write metadata to JSON if provided
49 | metadata_path = None
50 | if metadata:
51 | if not metadata_file:
52 | # Create default metadata filename
53 | metadata_file = str(output_path.with_suffix('.json'))
54 |
55 | with open(metadata_file, 'w') as f:
56 | json.dump(metadata, f, indent=2)
57 |
58 | metadata_path = metadata_file
59 |
60 | return output_file, metadata_path
61 |
62 | def write_obs_summary(obs_data, output_file):
63 | """
64 | Write a summary of observation data to a text file.
65 |
66 | Parameters:
67 | -----------
68 | obs_data : pandas.DataFrame
69 | DataFrame containing observation data
70 | output_file : str
71 | Path to the output summary file
72 |
73 | Returns:
74 | --------
75 | str
76 | Path to the output summary file
77 | """
78 | # Create output directory if it doesn't exist
79 | output_path = Path(output_file)
80 | output_dir = output_path.parent
81 | os.makedirs(output_dir, exist_ok=True)
82 |
83 | # Generate summary statistics
84 | summary = {}
85 |
86 | # Count observations by constellation
87 | constellation_counts = obs_data['constellation'].value_counts().to_dict()
88 | summary['observations_by_constellation'] = constellation_counts
89 |
90 | # Count satellites by constellation
91 | sat_counts = obs_data.groupby('constellation')['sat'].nunique().to_dict()
92 | summary['satellites_by_constellation'] = sat_counts
93 |
94 | # Get date range
95 | if 'date' in obs_data.columns and not obs_data['date'].empty:
96 | min_date = obs_data['date'].min()
97 | max_date = obs_data['date'].max()
98 | summary['date_range'] = {'start': min_date, 'end': max_date}
99 |
100 | # Write summary to file
101 | with open(output_file, 'w') as f:
102 | f.write("RINEX 2 Observation Data Summary\n")
103 | f.write("================================\n\n")
104 |
105 | f.write("Observations by Constellation:\n")
106 | for const, count in summary.get('observations_by_constellation', {}).items():
107 | f.write(f" {const}: {count}\n")
108 |
109 | f.write("\nSatellites by Constellation:\n")
110 | for const, count in summary.get('satellites_by_constellation', {}).items():
111 | f.write(f" {const}: {count}\n")
112 |
113 | if 'date_range' in summary:
114 | f.write("\nDate Range:\n")
115 | f.write(f" Start: {summary['date_range']['start']}\n")
116 | f.write(f" End: {summary['date_range']['end']}\n")
117 |
118 | return output_file
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex3/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | RINEX 3 parser module for reading observation files.
3 | Extracts header metadata and observation data.
4 | """
5 |
6 | from .reader import Rinex3Reader
7 | from .writer import write_to_parquet
8 |
9 | __all__ = ['Rinex3Reader', 'write_to_parquet']
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex3/example_usage.py:
--------------------------------------------------------------------------------
1 | """
2 | Example script demonstrating how to use the RINEX 3 parser module.
3 | """
4 |
5 | import os
6 | import json
7 | import logging
8 | import pandas as pd
9 | from pathlib import Path
10 | from datetime import datetime
11 | from pygnsslab.io.rinex3.reader import Rinex3Reader
12 | from pygnsslab.io.rinex3.writer import write_to_parquet
13 | from pygnsslab.io.rinex3.utils import find_rinex3_files
14 |
15 | def process_rinex_file(rinex_file, output_dir):
16 | """
17 | Process a RINEX 3 file, extracting metadata and observation data.
18 |
19 | Parameters:
20 | -----------
21 | rinex_file : str
22 | Path to the RINEX 3 file
23 | output_dir : str
24 | Path to the output directory
25 |
26 | Returns:
27 | --------
28 | tuple
29 | (metadata_file_path, parquet_file_path)
30 | """
31 | # Create output directory if it doesn't exist
32 | os.makedirs(output_dir, exist_ok=True)
33 |
34 | # Get the base filename without extension
35 | base_name = Path(rinex_file).stem
36 |
37 | # Create output file paths
38 | metadata_file = os.path.join(output_dir, f"{base_name}_metadata.json")
39 | parquet_file = os.path.join(output_dir, f"{base_name}_observations.parquet")
40 |
41 | # Read RINEX 3 file
42 | print(f"Reading RINEX file: {rinex_file}")
43 | rinex_reader = Rinex3Reader(rinex_file)
44 |
45 | # Get metadata and observations
46 | metadata = rinex_reader.metadata
47 | obs_data = rinex_reader.get_obs_data()
48 |
49 | # Save metadata to JSON
50 | with open(metadata_file, 'w') as f:
51 | json.dump(metadata, f, indent=2)
52 | print(f"Metadata saved to: {metadata_file}")
53 |
54 | # Save observations to parquet
55 | write_to_parquet(obs_data, parquet_file, metadata, metadata_file)
56 | print(f"Observations saved to: {parquet_file}")
57 |
58 | return metadata_file, parquet_file
59 |
60 | def print_metadata_summary(metadata_file):
61 | """
62 | Print a summary of the metadata.
63 |
64 | Parameters:
65 | -----------
66 | metadata_file : str
67 | Path to the metadata JSON file
68 | """
69 | with open(metadata_file, 'r') as f:
70 | metadata = json.load(f)
71 |
72 | print("\nRINEX Metadata Summary:")
73 | print(f" RINEX Version: {metadata.get('rinexVersion', 'N/A')}")
74 | print(f" Station Name: {metadata.get('stationName', 'N/A')}")
75 | print(f" Receiver Type: {metadata.get('receiverType', 'N/A')}")
76 | print(f" Antenna Type: {metadata.get('antennaType', 'N/A')}")
77 |
78 | # Print observation types
79 | print("\nObservation Types:")
80 | for constellation, obs_types in metadata.get('observationTypes', {}).items():
81 | print(f" {constellation}: {len(obs_types)} types")
82 |
83 | def main():
84 | """
85 | Main function to demonstrate the RINEX 3 parser.
86 | """
87 | # Example file path (replace with an actual file path)
88 | rinex_file = os.path.join("data","obs","PTLD00AUS_R_20220010000_01D_30S_MO.rnx")
89 |
90 | # Check if the file exists
91 | if not os.path.exists(rinex_file):
92 | print(f"File not found: {rinex_file}")
93 | print("Please provide a valid RINEX 3 observation file path.")
94 | return
95 |
96 | # Process the file
97 | output_dir = "data/jsonpqt"
98 | metadata_file, parquet_file = process_rinex_file(rinex_file, output_dir)
99 |
100 | # Print metadata summary
101 | print_metadata_summary(metadata_file)
102 |
103 | # Alternatively, process all RINEX 3 files in a directory
104 | # rinex_dir = "rinex_data"
105 | # rinex_files = find_rinex3_files(rinex_dir)
106 | # for rinex_file in rinex_files:
107 | # process_rinex_file(rinex_file, output_dir)
108 |
109 | if __name__ == "__main__":
110 | main()
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex3/metadata.py:
--------------------------------------------------------------------------------
1 | """
2 | Metadata handling module for RINEX 3 files.
3 | Provides functions to extract, format, and save header metadata.
4 | """
5 |
6 | import json
7 | import os
8 | from pathlib import Path
9 |
10 | def extract_metadata(rinex_header):
11 | """
12 | Extract and format metadata from RINEX header.
13 |
14 | Parameters:
15 | -----------
16 | rinex_header : dict
17 | Dictionary containing RINEX header information
18 |
19 | Returns:
20 | --------
21 | dict
22 | Formatted metadata dictionary
23 | """
24 | metadata = {
25 | 'rinexVersion': rinex_header.get('rinexVersion'),
26 | 'stationName': rinex_header.get('stationName'),
27 | 'approxPos': rinex_header.get('approxPos'),
28 | 'receiverNumber': rinex_header.get('receiverNumber'),
29 | 'receiverType': rinex_header.get('receiverType'),
30 | 'receiverVersion': rinex_header.get('receiverVersion'),
31 | 'antennaNumber': rinex_header.get('antennaNumber'),
32 | 'antennaType': rinex_header.get('antennaType'),
33 | 'observationTypes': dict(rinex_header.get('observationTypes', {}))
34 | }
35 |
36 | return metadata
37 |
38 | def save_metadata(metadata, output_file):
39 | """
40 | Save metadata to a JSON file.
41 |
42 | Parameters:
43 | -----------
44 | metadata : dict
45 | Metadata dictionary
46 | output_file : str
47 | Path to the output JSON file
48 |
49 | Returns:
50 | --------
51 | str
52 | Path to the output JSON file
53 | """
54 | # Create output directory if it doesn't exist
55 | output_path = Path(output_file)
56 | output_dir = output_path.parent
57 | os.makedirs(output_dir, exist_ok=True)
58 |
59 | # Write metadata to JSON
60 | with open(output_file, 'w') as f:
61 | json.dump(metadata, f, indent=2)
62 |
63 | return output_file
64 |
65 | def load_metadata(metadata_file):
66 | """
67 | Load metadata from a JSON file.
68 |
69 | Parameters:
70 | -----------
71 | metadata_file : str
72 | Path to the metadata JSON file
73 |
74 | Returns:
75 | --------
76 | dict
77 | Metadata dictionary
78 | """
79 | with open(metadata_file, 'r') as f:
80 | metadata = json.load(f)
81 |
82 | return metadata
83 |
84 | def compare_metadata(metadata1, metadata2):
85 | """
86 | Compare two metadata dictionaries and return their differences.
87 |
88 | Parameters:
89 | -----------
90 | metadata1 : dict
91 | First metadata dictionary
92 | metadata2 : dict
93 | Second metadata dictionary
94 |
95 | Returns:
96 | --------
97 | dict
98 | Dictionary of differences
99 | """
100 | differences = {}
101 |
102 | # Compare simple key-value pairs
103 | for key in set(metadata1.keys()) | set(metadata2.keys()):
104 | if key == 'observationTypes':
105 | continue # Handle separately
106 |
107 | if key not in metadata1:
108 | differences[key] = {'in_metadata1': None, 'in_metadata2': metadata2[key]}
109 | elif key not in metadata2:
110 | differences[key] = {'in_metadata1': metadata1[key], 'in_metadata2': None}
111 | elif metadata1[key] != metadata2[key]:
112 | differences[key] = {'in_metadata1': metadata1[key], 'in_metadata2': metadata2[key]}
113 |
114 | # Compare observation types
115 | if 'observationTypes' in metadata1 and 'observationTypes' in metadata2:
116 | obs_types1 = metadata1['observationTypes']
117 | obs_types2 = metadata2['observationTypes']
118 |
119 | obs_diff = {}
120 |
121 | # Check constellations in both
122 | for const in set(obs_types1.keys()) | set(obs_types2.keys()):
123 | if const not in obs_types1:
124 | obs_diff[const] = {'in_metadata1': None, 'in_metadata2': obs_types2[const]}
125 | elif const not in obs_types2:
126 | obs_diff[const] = {'in_metadata1': obs_types1[const], 'in_metadata2': None}
127 | elif set(obs_types1[const]) != set(obs_types2[const]):
128 | # Find differences in observation types
129 | only_in_1 = set(obs_types1[const]) - set(obs_types2[const])
130 | only_in_2 = set(obs_types2[const]) - set(obs_types1[const])
131 |
132 | obs_diff[const] = {
133 | 'only_in_metadata1': list(only_in_1) if only_in_1 else None,
134 | 'only_in_metadata2': list(only_in_2) if only_in_2 else None
135 | }
136 |
137 | if obs_diff:
138 | differences['observationTypes'] = obs_diff
139 |
140 | return differences
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex3/reader.py:
--------------------------------------------------------------------------------
1 | """
2 | RINEX 3 reader module for parsing observation files.
3 | """
4 |
5 | import os
6 | import re
7 | import datetime
8 | import json
9 | import numpy as np
10 | import pandas as pd
11 | from collections import defaultdict
12 |
13 | class Rinex3Reader:
14 | """
15 | Class for reading and parsing RINEX 3 observation files.
16 | """
17 |
18 | def __init__(self, filename):
19 | """
20 | Initialize the Rinex3Reader with a RINEX 3 observation file.
21 |
22 | Parameters:
23 | -----------
24 | filename : str
25 | Path to the RINEX 3 observation file
26 | """
27 | self.filename = filename
28 | self.header = {}
29 | self.obs_data = []
30 | self.metadata = {}
31 | self._parse_file()
32 |
33 | def _parse_file(self):
34 | """
35 | Parse the RINEX 3 file, extracting header information and observation data.
36 | """
37 | if not os.path.exists(self.filename):
38 | raise FileNotFoundError(f"RINEX file not found: {self.filename}")
39 |
40 | # Read the entire file
41 | with open(self.filename, 'r') as f:
42 | lines = f.readlines()
43 |
44 | # Determine where the header ends (look for END OF HEADER marker)
45 | header_end_idx = 0
46 | for i, line in enumerate(lines):
47 | if "END OF HEADER" in line:
48 | header_end_idx = i
49 | break
50 |
51 | # Extract header and observation sections
52 | header_lines = lines[:header_end_idx + 1]
53 | observation_lines = lines[header_end_idx + 1:]
54 |
55 | # Parse header
56 | self._parse_header(header_lines)
57 |
58 | # Parse observations
59 | self._parse_observations(observation_lines)
60 |
61 | def _parse_header(self, header_lines):
62 | """
63 | Parse the header section of the RINEX file.
64 |
65 | Parameters:
66 | -----------
67 | header_lines : list
68 | List of strings containing the header section
69 | """
70 | # Initialize header data structures
71 | self.header = {
72 | 'rinexVersion': None,
73 | 'stationName': None,
74 | 'approxPos': [None, None, None],
75 | 'receiverNumber': None,
76 | 'receiverType': None,
77 | 'receiverVersion': None,
78 | 'antennaNumber': None,
79 | 'antennaType': None,
80 | 'observationTypes': defaultdict(list)
81 | }
82 |
83 | # Parse each header line
84 | for line in header_lines:
85 | label = line[60:].strip()
86 |
87 | # RINEX Version / Type
88 | if "RINEX VERSION / TYPE" in label:
89 | self.header['rinexVersion'] = line[:9].strip()
90 |
91 | # Marker Name
92 | elif "MARKER NAME" in label:
93 | self.header['stationName'] = line[:60].strip()
94 |
95 | # Approx Position XYZ
96 | elif "APPROX POSITION XYZ" in label:
97 | try:
98 | x = float(line[:14].strip())
99 | y = float(line[14:28].strip())
100 | z = float(line[28:42].strip())
101 | self.header['approxPos'] = [x, y, z]
102 | except ValueError:
103 | pass
104 |
105 | # Receiver info
106 | elif "REC # / TYPE / VERS" in label:
107 | self.header['receiverNumber'] = line[:20].strip()
108 | self.header['receiverType'] = line[20:40].strip()
109 | self.header['receiverVersion'] = line[40:60].strip()
110 |
111 | # Antenna info
112 | elif "ANT # / TYPE" in label:
113 | self.header['antennaNumber'] = line[:20].strip()
114 | self.header['antennaType'] = line[20:40].strip()
115 |
116 | # Observation types
117 | elif "SYS / # / OBS TYPES" in label:
118 | constellation = line[0].strip()
119 | if not constellation:
120 | # Continuation line
121 | last_constellation = list(self.header['observationTypes'].keys())[-1]
122 | obs_types = [line[i:i+4].strip() for i in range(7, 60, 4) if line[i:i+4].strip() and i+4 <= 60]
123 | self.header['observationTypes'][last_constellation].extend(obs_types)
124 | else:
125 | # New constellation
126 | obs_types = [line[i:i+4].strip() for i in range(7, 60, 4) if line[i:i+4].strip() and i+4 <= 60]
127 | self.header['observationTypes'][constellation] = obs_types
128 |
129 | # Convert metadata to the required format
130 | self.metadata = self.header.copy()
131 |
132 | def _parse_observations(self, observation_lines):
133 | """
134 | Parse the observation section of the RINEX file.
135 |
136 | Parameters:
137 | -----------
138 | observation_lines : list
139 | List of strings containing the observation section
140 | """
141 | # Preallocation - start with a reasonable capacity
142 | initial_capacity = 100000
143 |
144 | # Data arrays for each column
145 | dates = []
146 | epoch_secs = []
147 | mjds = []
148 | constellations = []
149 | sats = []
150 | channels = []
151 | ranges = []
152 | phases = []
153 | dopplers = []
154 | snrs = []
155 |
156 | i = 0
157 |
158 | while i < len(observation_lines):
159 | line = observation_lines[i]
160 |
161 | # Skip empty lines
162 | if not line.strip():
163 | i += 1
164 | continue
165 |
166 | # Check if this is an epoch line (starts with ">")
167 | if line.startswith(">"):
168 | try:
169 | # Parse epoch information
170 | year = int(line[2:6])
171 | month = int(line[7:9])
172 | day = int(line[10:12])
173 | hour = int(line[13:15])
174 | minute = int(line[16:18])
175 | second = float(line[19:30])
176 | epoch_flag = int(line[31:32])
177 | num_sats = int(line[32:35])
178 |
179 | # Skip special epochs (flags > 1)
180 | if epoch_flag > 1:
181 | i += 1
182 | while i < len(observation_lines) and not observation_lines[i].startswith(">"):
183 | i += 1
184 | continue
185 |
186 | epoch_datetime = datetime.datetime(year, month, day, hour, minute, int(second))
187 | epoch_date = epoch_datetime.date().isoformat()
188 | epoch_sec = hour * 3600 + minute * 60 + second
189 |
190 | # Calculate Modified Julian Date (MJD)
191 | jd = self._datetime_to_jd(epoch_datetime)
192 | mjd = jd - 2400000.5
193 |
194 | # Move to next line to start processing satellites
195 | i += 1
196 |
197 | # Parse and process satellites
198 | sat_count = 0
199 | sat_lines = []
200 |
201 | # Collect satellite lines (could span multiple lines)
202 | while sat_count < num_sats and i < len(observation_lines):
203 | line = observation_lines[i]
204 | if line.startswith(">"):
205 | break
206 |
207 | # If first character is a constellation identifier, it's a satellite line
208 | if line and line[0] in "GRECJS":
209 | sat_lines.append(line)
210 | sat_count += 1
211 |
212 | i += 1
213 |
214 | # Process each satellite
215 | for sat_line in sat_lines:
216 | # Extract satellite ID (first 3 characters)
217 | if len(sat_line) >= 3:
218 | sat_id = sat_line[0:3].strip()
219 | if not sat_id:
220 | continue
221 |
222 | constellation = sat_id[0]
223 |
224 | # Get observation types for this constellation
225 | obs_types = self.header['observationTypes'].get(constellation, [])
226 | num_obs = len(obs_types)
227 |
228 | if num_obs == 0:
229 | continue
230 |
231 | # Initialize data structures
232 | unique_channels = {} # Maps channel code to index
233 | channel_data = {} # Stores observation data by channel
234 |
235 | # Process this satellite's observations
236 | for obs_idx in range(num_obs):
237 | # Calculate position in the line
238 | # Each observation takes 16 characters (14 for value, 2 for LLI and signal strength)
239 | # First 3 characters are for satellite ID
240 | line_idx = obs_idx // 5 # 5 observations per line
241 | pos_in_line = obs_idx % 5
242 |
243 | start_pos = 3 + pos_in_line * 16 # Starting at position 3 (after satellite ID)
244 |
245 | # Check if we need to read from next lines
246 | curr_line = sat_line
247 | if line_idx > 0:
248 | # Need to read from continuation lines
249 | line_offset = i - len(sat_lines) + line_idx
250 | if line_offset < len(observation_lines):
251 | curr_line = observation_lines[line_offset]
252 | else:
253 | continue
254 |
255 | # Skip if line is too short
256 | if len(curr_line) <= start_pos:
257 | continue
258 |
259 | # Extract observation value
260 | end_pos = start_pos + 14
261 | if end_pos > len(curr_line):
262 | end_pos = len(curr_line)
263 |
264 | obs_str = curr_line[start_pos:end_pos].strip()
265 | obs_value = None
266 | if obs_str:
267 | try:
268 | obs_value = float(obs_str)
269 | except ValueError:
270 | obs_value = None
271 |
272 | # Skip if no valid observation
273 | if obs_value is None:
274 | continue
275 |
276 | # Extract SNR (signal strength)
277 | snr_value = None
278 | if end_pos + 2 <= len(curr_line):
279 | snr_str = curr_line[end_pos:end_pos+2].strip()
280 | if snr_str:
281 | try:
282 | snr_value = float(snr_str)
283 | except ValueError:
284 | snr_value = None
285 |
286 | # Get observation type and channel
287 | obs_type = obs_types[obs_idx]
288 |
289 | if len(obs_type) >= 2:
290 | # First character is observation type (C, L, D, S)
291 | obs_code = obs_type[0]
292 | # Rest is the channel code
293 | channel_code = obs_type[1:]
294 |
295 | # Get or create channel index
296 | if channel_code not in unique_channels:
297 | unique_channels[channel_code] = len(unique_channels)
298 | channel_data[channel_code] = {
299 | 'range': None,
300 | 'phase': None,
301 | 'doppler': None,
302 | 'snr': None
303 | }
304 |
305 | # Map observation type to appropriate category
306 | if obs_code == 'C': # Pseudorange
307 | channel_data[channel_code]['range'] = obs_value
308 | elif obs_code == 'L': # Carrier phase
309 | channel_data[channel_code]['phase'] = obs_value
310 | elif obs_code == 'D': # Doppler
311 | channel_data[channel_code]['doppler'] = obs_value
312 | elif obs_code == 'S': # Signal strength
313 | channel_data[channel_code]['snr'] = obs_value
314 |
315 | # If we have a separate SNR value, use it regardless of observation type
316 | if snr_value is not None:
317 | channel_data[channel_code]['snr'] = snr_value
318 |
319 | # Add data for each channel to our lists
320 | for channel_code, data in channel_data.items():
321 | dates.append(epoch_date)
322 | epoch_secs.append(epoch_sec)
323 | mjds.append(mjd)
324 | constellations.append(constellation)
325 | sats.append(sat_id)
326 | channels.append(channel_code)
327 | ranges.append(data['range'])
328 | phases.append(data['phase'])
329 | dopplers.append(data['doppler'])
330 | snrs.append(data['snr'])
331 |
332 | except Exception as e:
333 | # Handle parsing errors
334 | print(f"Error parsing epoch: {str(e)}")
335 | i += 1
336 | else:
337 | i += 1
338 |
339 | # Create final DataFrame
340 | self.obs_data = pd.DataFrame({
341 | 'date': dates,
342 | 'epoch_sec': epoch_secs,
343 | 'mjd': mjds,
344 | 'constellation': constellations,
345 | 'sat': sats,
346 | 'channel': channels,
347 | 'range': ranges,
348 | 'phase': phases,
349 | 'doppler': dopplers,
350 | 'snr': snrs
351 | })
352 |
353 |
354 | # Note: This method is no longer used with the improved parsing approach
355 |
356 | def _datetime_to_jd(self, dt):
357 | """
358 | Convert a datetime object to Julian date.
359 |
360 | Parameters:
361 | -----------
362 | dt : datetime.datetime
363 | Datetime object to convert
364 |
365 | Returns:
366 | --------
367 | float
368 | Julian date
369 | """
370 | # Extract components
371 | year, month, day = dt.year, dt.month, dt.day
372 | hour, minute, second = dt.hour, dt.minute, dt.second
373 | microsecond = dt.microsecond
374 |
375 | # Calculate decimal day
376 | decimal_day = day + (hour / 24.0) + (minute / 1440.0) + ((second + microsecond / 1e6) / 86400.0)
377 |
378 | # Calculate Julian date
379 | if month <= 2:
380 | year -= 1
381 | month += 12
382 |
383 | A = int(year / 100)
384 | B = 2 - A + int(A / 4)
385 |
386 | jd = int(365.25 * (year + 4716)) + int(30.6001 * (month + 1)) + decimal_day + B - 1524.5
387 |
388 | return jd
389 |
390 | def get_metadata_json(self):
391 | """
392 | Get the metadata in JSON format.
393 |
394 | Returns:
395 | --------
396 | str
397 | Metadata in JSON format
398 | """
399 | return json.dumps(self.metadata)
400 |
401 | def get_obs_data(self):
402 | """
403 | Get the observation data as a DataFrame.
404 |
405 | Returns:
406 | --------
407 | pandas.DataFrame
408 | Observation data
409 | """
410 | return self.obs_data
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex3/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Utility functions for RINEX 3 processing.
3 | """
4 |
5 | import datetime
6 | import numpy as np
7 | import os
8 | from pathlib import Path
9 |
10 | def datetime_to_mjd(dt):
11 | """
12 | Convert a datetime object to Modified Julian Date (MJD).
13 |
14 | Parameters:
15 | -----------
16 | dt : datetime.datetime
17 | Datetime object to convert
18 |
19 | Returns:
20 | --------
21 | float
22 | Modified Julian Date
23 | """
24 | # Convert to Julian date first
25 | jd = datetime_to_jd(dt)
26 | # Convert Julian date to Modified Julian Date
27 | mjd = jd - 2400000.5
28 |
29 | return mjd
30 |
31 | def datetime_to_jd(dt):
32 | """
33 | Convert a datetime object to Julian date.
34 |
35 | Parameters:
36 | -----------
37 | dt : datetime.datetime
38 | Datetime object to convert
39 |
40 | Returns:
41 | --------
42 | float
43 | Julian date
44 | """
45 | # Extract components
46 | year, month, day = dt.year, dt.month, dt.day
47 | hour, minute, second = dt.hour, dt.minute, dt.second
48 | microsecond = dt.microsecond
49 |
50 | # Calculate decimal day
51 | decimal_day = day + (hour / 24.0) + (minute / 1440.0) + ((second + microsecond / 1e6) / 86400.0)
52 |
53 | # Calculate Julian date
54 | if month <= 2:
55 | year -= 1
56 | month += 12
57 |
58 | A = int(year / 100)
59 | B = 2 - A + int(A / 4)
60 |
61 | jd = int(365.25 * (year + 4716)) + int(30.6001 * (month + 1)) + decimal_day + B - 1524.5
62 |
63 | return jd
64 |
65 | def mjd_to_datetime(mjd):
66 | """
67 | Convert Modified Julian Date (MJD) to datetime object.
68 |
69 | Parameters:
70 | -----------
71 | mjd : float
72 | Modified Julian Date
73 |
74 | Returns:
75 | --------
76 | datetime.datetime
77 | Datetime object
78 | """
79 | # Convert MJD to Julian date
80 | jd = mjd + 2400000.5
81 |
82 | # Convert Julian date to datetime
83 | return jd_to_datetime(jd)
84 |
85 | def jd_to_datetime(jd):
86 | """
87 | Convert Julian date to datetime object.
88 |
89 | Parameters:
90 | -----------
91 | jd : float
92 | Julian date
93 |
94 | Returns:
95 | --------
96 | datetime.datetime
97 | Datetime object
98 | """
99 | jd = jd + 0.5 # Shift by 0.5 day
100 |
101 | # Calculate integer and fractional parts
102 | F, I = np.modf(jd)
103 | I = int(I)
104 |
105 | A = np.trunc((I - 1867216.25) / 36524.25)
106 | B = I + 1 + A - np.trunc(A / 4)
107 |
108 | C = B + 1524
109 | D = np.trunc((C - 122.1) / 365.25)
110 | E = np.trunc(365.25 * D)
111 | G = np.trunc((C - E) / 30.6001)
112 |
113 | # Calculate day, month, year
114 | day = C - E + F - np.trunc(30.6001 * G)
115 |
116 | if G < 13.5:
117 | month = G - 1
118 | else:
119 | month = G - 13
120 |
121 | if month > 2.5:
122 | year = D - 4716
123 | else:
124 | year = D - 4715
125 |
126 | # Calculate time
127 | day_frac = day - int(day)
128 | hours = int(day_frac * 24)
129 | minutes = int((day_frac * 24 - hours) * 60)
130 | seconds = ((day_frac * 24 - hours) * 60 - minutes) * 60
131 |
132 | # Create datetime object
133 | try:
134 | dt = datetime.datetime(
135 | int(year),
136 | int(month),
137 | int(day),
138 | hours,
139 | minutes,
140 | int(seconds),
141 | int((seconds - int(seconds)) * 1e6)
142 | )
143 | return dt
144 | except ValueError:
145 | # Handling edge cases
146 | if int(day) == 0:
147 | # Handle case where day is calculated as 0
148 | month = int(month) - 1
149 | if month == 0:
150 | month = 12
151 | year = int(year) - 1
152 | days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
153 | # Adjust for leap year
154 | if month == 2 and (year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)):
155 | day = 29
156 | else:
157 | day = days_in_month[month]
158 | else:
159 | day = int(day)
160 |
161 | return datetime.datetime(
162 | int(year),
163 | int(month),
164 | day,
165 | hours,
166 | minutes,
167 | int(seconds),
168 | int((seconds - int(seconds)) * 1e6)
169 | )
170 |
171 | def time_to_seconds_of_day(time_obj):
172 | """
173 | Convert a time object to seconds of day.
174 |
175 | Parameters:
176 | -----------
177 | time_obj : datetime.time
178 | Time object to convert
179 |
180 | Returns:
181 | --------
182 | float
183 | Seconds of day
184 | """
185 | return time_obj.hour * 3600 + time_obj.minute * 60 + time_obj.second + time_obj.microsecond / 1e6
186 |
187 | def is_rinex3_file(filename):
188 | """
189 | Check if a file is a RINEX 3 observation file.
190 |
191 | Parameters:
192 | -----------
193 | filename : str
194 | Path to the file
195 |
196 | Returns:
197 | --------
198 | bool
199 | True if the file is a RINEX 3 observation file, False otherwise
200 | """
201 | if not os.path.exists(filename):
202 | return False
203 |
204 | # Check file extension
205 | ext = Path(filename).suffix.lower()
206 | if ext not in ['.rnx', '.crx', '.obs']:
207 | # Not a standard RINEX observation file extension
208 | return False
209 |
210 | # Check file content
211 | try:
212 | with open(filename, 'r') as f:
213 | # Read first few lines
214 | header_lines = [f.readline() for _ in range(10)]
215 |
216 | # Check for RINEX 3 version marker
217 | for line in header_lines:
218 | if "RINEX VERSION / TYPE" in line and line.strip().startswith("3"):
219 | return True
220 | except:
221 | # If there's an error reading the file, assume it's not a valid RINEX file
222 | return False
223 |
224 | return False
225 |
226 | def find_rinex3_files(directory):
227 | """
228 | Find all RINEX 3 observation files in a directory.
229 |
230 | Parameters:
231 | -----------
232 | directory : str
233 | Path to the directory
234 |
235 | Returns:
236 | --------
237 | list
238 | List of paths to RINEX 3 observation files
239 | """
240 | rinex_files = []
241 |
242 | for root, _, files in os.walk(directory):
243 | for file in files:
244 | filepath = os.path.join(root, file)
245 | if is_rinex3_file(filepath):
246 | rinex_files.append(filepath)
247 |
248 | return rinex_files
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinex3/writer.py:
--------------------------------------------------------------------------------
1 | """
2 | Writer module for saving RINEX observation data to parquet format.
3 | """
4 |
5 | import os
6 | import json
7 | import pandas as pd
8 | from pathlib import Path
9 |
10 | def write_to_parquet(obs_data, output_file, metadata=None, metadata_file=None):
11 | """
12 | Write observation data to a parquet file and optionally save metadata to a JSON file.
13 |
14 | Parameters:
15 | -----------
16 | obs_data : pandas.DataFrame
17 | DataFrame containing observation data
18 | output_file : str
19 | Path to the output parquet file
20 | metadata : dict, optional
21 | Metadata dictionary to save
22 | metadata_file : str, optional
23 | Path to the output metadata JSON file. If None but metadata is provided,
24 | a default name will be created based on output_file
25 |
26 | Returns:
27 | --------
28 | tuple
29 | (parquet_file_path, metadata_file_path)
30 | """
31 | # Create output directory if it doesn't exist
32 | output_path = Path(output_file)
33 | output_dir = output_path.parent
34 | os.makedirs(output_dir, exist_ok=True)
35 |
36 | # Ensure data has the required columns
37 | required_columns = ['date', 'epoch_sec', 'mjd', 'constellation', 'sat',
38 | 'channel', 'range', 'phase', 'doppler', 'snr']
39 |
40 | # Create empty columns if they don't exist
41 | for col in required_columns:
42 | if col not in obs_data.columns:
43 | obs_data[col] = None
44 |
45 | # Write observation data to parquet
46 | obs_data.to_parquet(output_file, index=False)
47 |
48 | # Write metadata to JSON if provided
49 | metadata_path = None
50 | if metadata:
51 | if not metadata_file:
52 | # Create default metadata filename
53 | metadata_file = str(output_path.with_suffix('.json'))
54 |
55 | with open(metadata_file, 'w') as f:
56 | json.dump(metadata, f, indent=2)
57 |
58 | metadata_path = metadata_file
59 |
60 | return output_file, metadata_path
61 |
62 | def write_obs_summary(obs_data, output_file):
63 | """
64 | Write a summary of observation data to a text file.
65 |
66 | Parameters:
67 | -----------
68 | obs_data : pandas.DataFrame
69 | DataFrame containing observation data
70 | output_file : str
71 | Path to the output summary file
72 |
73 | Returns:
74 | --------
75 | str
76 | Path to the output summary file
77 | """
78 | # Create output directory if it doesn't exist
79 | output_path = Path(output_file)
80 | output_dir = output_path.parent
81 | os.makedirs(output_dir, exist_ok=True)
82 |
83 | # Generate summary statistics
84 | summary = {}
85 |
86 | # Count observations by constellation
87 | constellation_counts = obs_data['constellation'].value_counts().to_dict()
88 | summary['observations_by_constellation'] = constellation_counts
89 |
90 | # Count satellites by constellation
91 | sat_counts = obs_data.groupby('constellation')['sat'].nunique().to_dict()
92 | summary['satellites_by_constellation'] = sat_counts
93 |
94 | # Get date range
95 | if 'date' in obs_data.columns and not obs_data['date'].empty:
96 | min_date = obs_data['date'].min()
97 | max_date = obs_data['date'].max()
98 | summary['date_range'] = {'start': min_date, 'end': max_date}
99 |
100 | # Write summary to file
101 | with open(output_file, 'w') as f:
102 | f.write("RINEX Observation Data Summary\n")
103 | f.write("==============================\n\n")
104 |
105 | f.write("Observations by Constellation:\n")
106 | for const, count in summary.get('observations_by_constellation', {}).items():
107 | f.write(f" {const}: {count}\n")
108 |
109 | f.write("\nSatellites by Constellation:\n")
110 | for const, count in summary.get('satellites_by_constellation', {}).items():
111 | f.write(f" {const}: {count}\n")
112 |
113 | if 'date_range' in summary:
114 | f.write("\nDate Range:\n")
115 | f.write(f" Start: {summary['date_range']['start']}\n")
116 | f.write(f" End: {summary['date_range']['end']}\n")
117 |
118 | return output_file
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinexnav/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | RINEX Navigation File Reader Module for PyGNSSLab.
3 |
4 | This module handles the reading of RINEX Navigation files (versions 2, 3, and 4),
5 | and provides a unified interface for accessing navigation data across different RINEX versions.
6 | """
7 |
8 | from .reader import (
9 | read_rinex_nav,
10 | RinexNavReader,
11 | Rinex2NavReader,
12 | Rinex3NavReader
13 | )
14 |
15 | __all__ = [
16 | 'read_rinex_nav',
17 | 'RinexNavReader',
18 | 'Rinex2NavReader',
19 | 'Rinex3NavReader'
20 | ]
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinexnav/example_usage.py:
--------------------------------------------------------------------------------
1 | """
2 | Example usage of the RINEX Navigation reader module.
3 |
4 | This script demonstrates how to use the RINEX Navigation reader to read navigation data
5 | from files of different RINEX versions and how to process the data.
6 | """
7 |
8 | import os
9 | import sys
10 | import numpy as np
11 | import pandas as pd
12 | import matplotlib.pyplot as plt
13 | from datetime import datetime, timedelta
14 |
15 | # Add the parent directory to the path
16 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..')))
17 |
18 | from pygnsslab.io.rinexnav.reader import read_rinex_nav
19 | from pygnsslab.io.rinexnav.utils import (
20 | gps_time_to_datetime,
21 | datetime_to_gps_time,
22 | interpolate_orbit,
23 | compute_sv_clock_correction
24 | )
25 |
26 | def read_and_display_rinex2_nav(filename):
27 | """
28 | Read a RINEX 2 navigation file and display basic information.
29 |
30 | Parameters
31 | ----------
32 | filename : str
33 | Path to the RINEX 2 navigation file.
34 | """
35 | print(f"\nReading RINEX 2 Navigation file: {filename}")
36 |
37 | # Read the file
38 | nav_reader = read_rinex_nav(filename)
39 |
40 | # Display header information
41 | print("\nHeader Information:")
42 | for key, value in nav_reader.header.items():
43 | print(f" {key}: {value}")
44 |
45 | # Display data summary
46 | print("\nData Summary:")
47 | for sys, df in nav_reader.data.items():
48 | if not df.empty:
49 | print(f" System {sys}: {len(df)} records")
50 | print(f" PRNs: {sorted(df['PRN'].unique())}")
51 |
52 | # Display time span
53 | min_time = df['Epoch'].min()
54 | max_time = df['Epoch'].max()
55 | print(f" Time span: {min_time} to {max_time}")
56 |
57 | # Display first record
58 | print("\nFirst record:")
59 | display_record(df.iloc[0])
60 |
61 | def read_and_display_rinex3_nav(filename):
62 | """
63 | Read a RINEX 3 navigation file and display basic information.
64 |
65 | Parameters
66 | ----------
67 | filename : str
68 | Path to the RINEX 3 navigation file.
69 | """
70 | print(f"\nReading RINEX 3 Navigation file: {filename}")
71 |
72 | # Read the file
73 | nav_reader = read_rinex_nav(filename)
74 |
75 | # Display header information
76 | print("\nHeader Information:")
77 | for key, value in nav_reader.header.items():
78 | if key not in ['ionospheric_corr', 'time_system_corr']:
79 | print(f" {key}: {value}")
80 |
81 | # Display ionospheric correction if available
82 | if 'ionospheric_corr' in nav_reader.header:
83 | print("\nIonospheric Correction:")
84 | for corr_type, values in nav_reader.header['ionospheric_corr'].items():
85 | print(f" {corr_type}: {values}")
86 |
87 | # Display time system correction if available
88 | if 'time_system_corr' in nav_reader.header:
89 | print("\nTime System Correction:")
90 | for corr_type, values in nav_reader.header['time_system_corr'].items():
91 | print(f" {corr_type}: {values}")
92 |
93 | # Display data summary
94 | print("\nData Summary:")
95 | for sys, df in nav_reader.data.items():
96 | if not df.empty:
97 | print(f"\nSystem {sys}: {len(df)} records")
98 | print(f" PRNs: {sorted(df['PRN'].unique())}")
99 |
100 | # Display time span
101 | min_time = df['Epoch'].min()
102 | max_time = df['Epoch'].max()
103 | print(f" Time span: {min_time} to {max_time}")
104 |
105 | # Display first record
106 | print("\nFirst record for system", sys)
107 | display_record(df.iloc[0])
108 |
109 | def display_record(record):
110 | """
111 | Display a navigation record in a readable format.
112 |
113 | Parameters
114 | ----------
115 | record : pd.Series
116 | Navigation record as a pandas Series.
117 | """
118 | # Display basic information
119 | print(f" PRN: {record['PRN']}")
120 | print(f" Epoch: {record['Epoch']}")
121 | print(f" SV Clock Bias: {record['SV_clock_bias']:.12e} s")
122 | print(f" SV Clock Drift: {record['SV_clock_drift']:.12e} s/s")
123 | print(f" SV Clock Drift Rate: {record['SV_clock_drift_rate']:.12e} s/s²")
124 |
125 | # Display orbital parameters if they exist
126 | for param in ['IODE', 'e', 'sqrt_A', 'Toe']:
127 | if param in record and not pd.isna(record[param]):
128 | if param == 'sqrt_A':
129 | print(f" {param}: {record[param]:.12e} m^(1/2)")
130 | elif param == 'e':
131 | print(f" {param}: {record[param]:.12e} (dimensionless)")
132 | elif param == 'Toe':
133 | print(f" {param}: {record[param]}")
134 | else:
135 | print(f" {param}: {record[param]}")
136 |
137 | def plot_satellite_orbit(nav_data, sat_system, prn, start_time, duration_hours):
138 | """
139 | Plot the satellite orbit over a specified time period.
140 |
141 | Parameters
142 | ----------
143 | nav_data : dict of pd.DataFrame
144 | Navigation data dictionary where keys are satellite systems.
145 | sat_system : str
146 | Satellite system identifier (e.g., 'G' for GPS).
147 | prn : int
148 | Satellite PRN number.
149 | start_time : datetime
150 | Start time for the plot.
151 | duration_hours : float
152 | Duration of the plot in hours.
153 | """
154 | if sat_system not in nav_data:
155 | print(f"No data available for satellite system {sat_system}")
156 | return
157 |
158 | # Get dataframe for the specified satellite system
159 | df = nav_data[sat_system]
160 |
161 | # Ensure the data contains the specified PRN
162 | if prn not in df['PRN'].values:
163 | print(f"No data available for satellite {sat_system}{prn}")
164 | return
165 |
166 | # Check if satellite system is supported for interpolation
167 | supported_systems = ['G'] # Currently only GPS is supported
168 | if sat_system not in supported_systems:
169 | print(f"Interpolation for satellite system {sat_system} is not supported yet. Skipping orbit plotting.")
170 | return
171 |
172 | # Create time points for interpolation
173 | num_points = int(duration_hours * 12) # One point every 5 minutes
174 | times = [start_time + timedelta(hours=duration_hours*i/num_points) for i in range(num_points+1)]
175 |
176 | # Interpolate the orbit at each time point
177 | positions = []
178 | for t in times:
179 | try:
180 | pos = interpolate_orbit(df, t, sat_system, prn)
181 | # Check for NaN values in the position
182 | if np.isnan(pos['X']) or np.isnan(pos['Y']) or np.isnan(pos['Z']):
183 | continue
184 | positions.append((t, pos['X'], pos['Y'], pos['Z']))
185 | except Exception as e:
186 | print(f"Error interpolating at time {t}: {e}")
187 |
188 | # Extract coordinates for plotting
189 | if positions:
190 | t_vals, x_vals, y_vals, z_vals = zip(*positions)
191 |
192 | # Create the plot
193 | plt.figure(figsize=(12, 10))
194 |
195 | # 3D plot
196 | ax = plt.subplot(111, projection='3d')
197 | ax.plot(x_vals, y_vals, z_vals, 'b-', linewidth=2)
198 |
199 | # Plot Earth (simplified as a sphere)
200 | u, v = np.mgrid[0:2*np.pi:20j, 0:np.pi:10j]
201 | earth_radius = 6371000 # Earth's radius in meters
202 | x = earth_radius * np.cos(u) * np.sin(v)
203 | y = earth_radius * np.sin(u) * np.sin(v)
204 | z = earth_radius * np.cos(v)
205 | ax.plot_surface(x, y, z, color='g', alpha=0.2)
206 |
207 | # Add starting point marker
208 | ax.scatter([x_vals[0]], [y_vals[0]], [z_vals[0]], color='r', s=100, label='Start')
209 |
210 | # Set labels and title
211 | ax.set_xlabel('X (m)')
212 | ax.set_ylabel('Y (m)')
213 | ax.set_zlabel('Z (m)')
214 | ax.set_title(f'Orbit of Satellite {sat_system}{prn} over {duration_hours} hours')
215 |
216 | # Set equal aspect ratio
217 | max_range = max([max(x_vals) - min(x_vals), max(y_vals) - min(y_vals), max(z_vals) - min(z_vals)])
218 | mid_x = (max(x_vals) + min(x_vals)) * 0.5
219 | mid_y = (max(y_vals) + min(y_vals)) * 0.5
220 | mid_z = (max(z_vals) + min(z_vals)) * 0.5
221 | ax.set_xlim(mid_x - max_range/2, mid_x + max_range/2)
222 | ax.set_ylim(mid_y - max_range/2, mid_y + max_range/2)
223 | ax.set_zlim(mid_z - max_range/2, mid_z + max_range/2)
224 |
225 | plt.legend()
226 | plt.tight_layout()
227 | plt.savefig(f'satellite_{sat_system}{prn}_orbit.png', dpi=300)
228 | plt.close()
229 |
230 | print(f"Orbit plot saved as 'satellite_{sat_system}{prn}_orbit.png'")
231 |
232 | # Plot clock correction
233 | plt.figure(figsize=(10, 6))
234 |
235 | clock_corrections = []
236 | for t in times:
237 | try:
238 | clock_corr = compute_sv_clock_correction(df, t, sat_system, prn)
239 | clock_corrections.append(clock_corr)
240 | except Exception as e:
241 | print(f"Error computing clock correction at time {t}: {e}")
242 | clock_corrections.append(np.nan)
243 |
244 | # Remove NaN values for plotting
245 | valid_times = []
246 | valid_corrections = []
247 | for i, corr in enumerate(clock_corrections):
248 | if not np.isnan(corr):
249 | valid_times.append(times[i])
250 | valid_corrections.append(corr)
251 |
252 | if valid_corrections:
253 | # Convert to nanoseconds for better visualization
254 | clock_corrections_ns = [c * 1e9 for c in valid_corrections]
255 |
256 | # Create time axis in hours from start
257 | time_hours = [(t - start_time).total_seconds()/3600 for t in valid_times]
258 |
259 | plt.plot(time_hours, clock_corrections_ns, 'b-', linewidth=2)
260 | plt.xlabel('Time (hours from start)')
261 | plt.ylabel('Clock Correction (ns)')
262 | plt.title(f'Clock Correction of Satellite {sat_system}{prn} over {duration_hours} hours')
263 | plt.grid(True)
264 | plt.tight_layout()
265 | plt.savefig(f'satellite_{sat_system}{prn}_clock_correction.png', dpi=300)
266 | plt.close()
267 |
268 | print(f"Clock correction plot saved as 'satellite_{sat_system}{prn}_clock_correction.png'")
269 | else:
270 | print("No valid clock corrections calculated for the specified time range.")
271 | else:
272 | print("No valid positions calculated for the specified time range.")
273 |
274 | def compute_dop_values(nav_data, receiver_pos, time, visible_sats=None):
275 | """
276 | Compute Dilution of Precision (DOP) values.
277 |
278 | Parameters
279 | ----------
280 | nav_data : dict of pd.DataFrame
281 | Navigation data dictionary where keys are satellite systems.
282 | receiver_pos : tuple of float
283 | Receiver position (X, Y, Z) in ECEF coordinates.
284 | time : datetime
285 | Time for DOP computation.
286 | visible_sats : list of tuple, optional
287 | List of visible satellites as (system, prn) tuples.
288 | If None, all satellites in the navigation data are used.
289 |
290 | Returns
291 | -------
292 | dict
293 | Dictionary containing DOP values (GDOP, PDOP, HDOP, VDOP, TDOP).
294 | """
295 | # Currently supported satellite systems for interpolation
296 | supported_systems = ['G'] # Currently only GPS is supported
297 |
298 | if visible_sats is None:
299 | # Use all satellites in the navigation data from supported systems
300 | visible_sats = []
301 | for sys, df in nav_data.items():
302 | if sys in supported_systems:
303 | for prn in df['PRN'].unique():
304 | visible_sats.append((sys, prn))
305 |
306 | if not visible_sats:
307 | print(f"No satellites from supported systems ({', '.join(supported_systems)}) found in navigation data.")
308 | return None
309 |
310 | # Compute satellite positions
311 | sat_positions = []
312 |
313 | for sys, prn in visible_sats:
314 | if sys not in supported_systems:
315 | continue # Skip unsupported satellite systems
316 |
317 | if sys in nav_data and prn in nav_data[sys]['PRN'].values:
318 | try:
319 | pos = interpolate_orbit(nav_data[sys], time, sys, prn)
320 | # Check for NaN values in the position
321 | if np.isnan(pos['X']) or np.isnan(pos['Y']) or np.isnan(pos['Z']):
322 | continue
323 | sat_positions.append((sys, prn, pos['X'], pos['Y'], pos['Z']))
324 | except Exception as e:
325 | print(f"Error interpolating position for {sys}{prn} at {time}: {e}")
326 |
327 | if len(sat_positions) < 4:
328 | print(f"Insufficient satellites ({len(sat_positions)}) for DOP computation.")
329 | return None
330 |
331 | # Compute line-of-sight unit vectors
332 | rx, ry, rz = receiver_pos
333 | G_matrix = []
334 |
335 | for _, _, sx, sy, sz in sat_positions:
336 | # Vector from receiver to satellite
337 | dx = sx - rx
338 | dy = sy - ry
339 | dz = sz - rz
340 |
341 | # Range
342 | range_val = np.sqrt(dx**2 + dy**2 + dz**2)
343 |
344 | # Unit vector components
345 | ax = dx / range_val
346 | ay = dy / range_val
347 | az = dz / range_val
348 |
349 | # Add to G matrix with clock term
350 | G_matrix.append([ax, ay, az, 1])
351 |
352 | # Compute DOP from G matrix
353 | G = np.array(G_matrix)
354 | try:
355 | Q = np.linalg.inv(G.T @ G)
356 |
357 | # Extract DOP values
358 | GDOP = np.sqrt(np.trace(Q))
359 | PDOP = np.sqrt(Q[0,0] + Q[1,1] + Q[2,2])
360 | HDOP = np.sqrt(Q[0,0] + Q[1,1])
361 | VDOP = np.sqrt(Q[2,2])
362 | TDOP = np.sqrt(Q[3,3])
363 |
364 | return {
365 | 'GDOP': GDOP,
366 | 'PDOP': PDOP,
367 | 'HDOP': HDOP,
368 | 'VDOP': VDOP,
369 | 'TDOP': TDOP
370 | }
371 | except np.linalg.LinAlgError as e:
372 | print(f"Linear algebra error in DOP computation: {e}")
373 | return None
374 |
375 | def verify_data_integrity(nav_data, sat_system):
376 | """
377 | Verify the integrity of navigation data by checking for required parameters.
378 |
379 | Parameters
380 | ----------
381 | nav_data : dict of pd.DataFrame
382 | Navigation data dictionary where keys are satellite systems.
383 | sat_system : str
384 | Satellite system identifier (e.g., 'G' for GPS).
385 |
386 | Returns
387 | -------
388 | list
389 | List of valid PRNs with complete data for orbit calculations.
390 | """
391 | if sat_system not in nav_data:
392 | print(f"No data available for satellite system {sat_system}")
393 | return []
394 |
395 | # Get dataframe for the specified satellite system
396 | df = nav_data[sat_system]
397 |
398 | # List of required parameters for orbit calculations
399 | required_params = ['sqrt_A', 'e', 'M0', 'omega', 'OMEGA', 'i0', 'Delta_n',
400 | 'Cuc', 'Cus', 'Crc', 'Crs', 'Cic', 'Cis', 'IDOT', 'OMEGA_DOT', 'Toe']
401 |
402 | valid_prns = []
403 |
404 | # Check all unique PRNs in the dataframe
405 | for prn in df['PRN'].unique():
406 | # Get all records for this PRN
407 | prn_data = df[df['PRN'] == prn]
408 |
409 | # Check if there's at least one complete record
410 | for _, row in prn_data.iterrows():
411 | is_valid = True
412 | for param in required_params:
413 | if param not in row or pd.isna(row[param]):
414 | is_valid = False
415 | break
416 |
417 | if is_valid:
418 | valid_prns.append(prn)
419 | break
420 |
421 | return valid_prns
422 |
423 | def main():
424 | """Main function to demonstrate the usage of the RINEX Navigation reader."""
425 | # Example usage with RINEX 2 file
426 | rinex2_file = "data/nav/ajac0010.22n" # Replace with actual path
427 |
428 | try:
429 | # Read RINEX 2 navigation file
430 | nav_reader_v2 = read_rinex_nav(rinex2_file)
431 | read_and_display_rinex2_nav(rinex2_file)
432 |
433 | # Verify data integrity and find valid satellites
434 | if 'G' in nav_reader_v2.data:
435 | valid_prns = verify_data_integrity(nav_reader_v2.data, 'G')
436 | print(f"\nValid GPS satellites in RINEX 2 file with complete orbital data: {valid_prns}")
437 |
438 | if valid_prns:
439 | prn_to_plot = valid_prns[0] # Choose the first valid PRN
440 |
441 | # Find a suitable start time (with valid data)
442 | # Use a time within the data range
443 | all_epochs = sorted(nav_reader_v2.data['G']['Epoch'].unique())
444 | if all_epochs:
445 | # Choose a time that's not at the very beginning or end
446 | start_idx = len(all_epochs) // 4 # 25% into the data
447 | start_time = all_epochs[start_idx]
448 |
449 | print(f"\nPlotting orbit for GPS satellite PRN {prn_to_plot} starting at {start_time}")
450 | plot_satellite_orbit(nav_reader_v2.data, 'G', prn_to_plot, start_time, 12) # Reduced to 12 hours
451 |
452 | # Example of DOP computation
453 | # Receiver position in ECEF coordinates (example: near Greenwich)
454 | receiver_pos = (3980000, 0, 4970000)
455 | print(f"\nComputing DOP values at {start_time}")
456 | dop_values = compute_dop_values(nav_reader_v2.data, receiver_pos, start_time)
457 | if dop_values:
458 | print("\nDOP Values:")
459 | for key, value in dop_values.items():
460 | print(f" {key}: {value:.2f}")
461 | else:
462 | print("Could not compute DOP values.")
463 | else:
464 | print("No GPS epoch data available for plotting.")
465 | else:
466 | print("No GPS satellites with complete orbital data found in the RINEX 2 file.")
467 | else:
468 | print("No GPS data found in the RINEX 2 file.")
469 |
470 | except Exception as e:
471 | print(f"Error processing RINEX 2 file: {e}")
472 |
473 | # Example usage with RINEX 3 file
474 | rinex3_file = "data/nav/PTLD00AUS_R_20220010000_01D_30S_MN.rnx" # Replace with actual path
475 |
476 | try:
477 | # Read RINEX 3 navigation file
478 | nav_reader_v3 = read_rinex_nav(rinex3_file)
479 | read_and_display_rinex3_nav(rinex3_file)
480 |
481 | # Verify data integrity and find valid satellites
482 | if 'G' in nav_reader_v3.data:
483 | valid_prns = verify_data_integrity(nav_reader_v3.data, 'G')
484 | print(f"\nValid GPS satellites in RINEX 3 file with complete orbital data: {valid_prns}")
485 |
486 | if valid_prns:
487 | prn_to_plot = valid_prns[0] # Choose the first valid PRN
488 |
489 | # Find a suitable start time (with valid data)
490 | all_epochs = sorted(nav_reader_v3.data['G']['Epoch'].unique())
491 | if all_epochs:
492 | # Choose a time that's not at the very beginning or end
493 | start_idx = len(all_epochs) // 4 # 25% into the data
494 | start_time = all_epochs[start_idx]
495 |
496 | print(f"\nPlotting orbit for GPS satellite PRN {prn_to_plot} from RINEX 3 file starting at {start_time}")
497 | plot_satellite_orbit(nav_reader_v3.data, 'G', prn_to_plot, start_time, 12) # Reduced to 12 hours
498 | else:
499 | print("No GPS epoch data available for plotting from RINEX 3.")
500 | else:
501 | print("No GPS satellites with complete orbital data found in the RINEX 3 file.")
502 | else:
503 | print("No GPS data found in the RINEX 3 file.")
504 |
505 | # Print a note about unsupported satellite systems
506 | unsupported_systems = [sys for sys in nav_reader_v3.data.keys() if sys != 'G']
507 | if unsupported_systems:
508 | print(f"\nNote: Interpolation for satellite systems {', '.join(unsupported_systems)} is not yet supported.")
509 | print("Only GPS (G) satellites can be used for orbit plotting and DOP calculations.")
510 |
511 | except Exception as e:
512 | print(f"Error processing RINEX 3 file: {e}")
513 |
514 | if __name__ == "__main__":
515 | main()
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinexnav/metadata.py:
--------------------------------------------------------------------------------
1 | """
2 | Metadata for RINEX Navigation files.
3 |
4 | This module provides metadata and constants related to RINEX Navigation files
5 | and GNSS systems.
6 | """
7 |
8 | from typing import Dict, List, Tuple
9 |
10 | # GNSS System Identifiers
11 | GNSS_SYSTEMS = {
12 | 'G': 'GPS',
13 | 'R': 'GLONASS',
14 | 'E': 'Galileo',
15 | 'C': 'BeiDou',
16 | 'J': 'QZSS',
17 | 'I': 'IRNSS/NavIC',
18 | 'S': 'SBAS'
19 | }
20 |
21 | # File Type Identifiers
22 | FILE_TYPES = {
23 | 'N': 'GPS Navigation Data',
24 | 'G': 'GLONASS Navigation Data',
25 | 'H': 'GEO Navigation Data',
26 | 'B': 'SBAS Broadcast Data',
27 | 'L': 'GALILEO Navigation Data',
28 | 'P': 'Mixed GNSS Navigation Data',
29 | 'Q': 'QZSS Navigation Data',
30 | 'C': 'BDS Navigation Data',
31 | 'I': 'IRNSS Navigation Data'
32 | }
33 |
34 | # RINEX Version Compatibility
35 | RINEX_VERSIONS = {
36 | 2.0: ["GPS", "GLONASS"],
37 | 2.1: ["GPS", "GLONASS"],
38 | 2.11: ["GPS", "GLONASS"],
39 | 2.12: ["GPS", "GLONASS"],
40 | 3.0: ["GPS", "GLONASS", "Galileo", "SBAS", "QZSS"],
41 | 3.01: ["GPS", "GLONASS", "Galileo", "SBAS", "QZSS"],
42 | 3.02: ["GPS", "GLONASS", "Galileo", "SBAS", "QZSS", "BeiDou"],
43 | 3.03: ["GPS", "GLONASS", "Galileo", "SBAS", "QZSS", "BeiDou", "IRNSS"],
44 | 3.04: ["GPS", "GLONASS", "Galileo", "SBAS", "QZSS", "BeiDou", "IRNSS"],
45 | 4.0: ["GPS", "GLONASS", "Galileo", "SBAS", "QZSS", "BeiDou", "IRNSS"]
46 | }
47 |
48 | # Navigation Data Record Structure by GNSS System
49 | # Key: System identifier
50 | # Value: List of parameter names in order of appearance
51 | NAV_DATA_STRUCTURE = {
52 | 'G': [ # GPS Navigation Message
53 | 'IODE', 'Crs', 'Delta_n', 'M0',
54 | 'Cuc', 'e', 'Cus', 'sqrt_A',
55 | 'Toe', 'Cic', 'OMEGA', 'Cis',
56 | 'i0', 'Crc', 'omega', 'OMEGA_DOT',
57 | 'IDOT', 'L2_codes', 'GPS_week', 'L2_P_flag',
58 | 'SV_accuracy', 'SV_health', 'TGD', 'IODC',
59 | 'Transmission_time', 'Fit_interval'
60 | ],
61 | 'R': [ # GLONASS Navigation Message
62 | 'Tau', 'Gamma', 'tb', 'X',
63 | 'X_velocity', 'X_acceleration', 'health', 'Y',
64 | 'Y_velocity', 'Y_acceleration', 'frequency_number', 'Z',
65 | 'Z_velocity', 'Z_acceleration', 'Age'
66 | ],
67 | 'E': [ # Galileo Navigation Message
68 | 'IODnav', 'Crs', 'Delta_n', 'M0',
69 | 'Cuc', 'e', 'Cus', 'sqrt_A',
70 | 'Toe', 'Cic', 'OMEGA', 'Cis',
71 | 'i0', 'Crc', 'omega', 'OMEGA_DOT',
72 | 'IDOT', 'Data_sources', 'GAL_week', 'SISA',
73 | 'SV_health', 'BGD_E5a_E1', 'BGD_E5b_E1', 'Transmission_time'
74 | ],
75 | 'C': [ # BeiDou Navigation Message
76 | 'AODE', 'Crs', 'Delta_n', 'M0',
77 | 'Cuc', 'e', 'Cus', 'sqrt_A',
78 | 'Toe', 'Cic', 'OMEGA', 'Cis',
79 | 'i0', 'Crc', 'omega', 'OMEGA_DOT',
80 | 'IDOT', 'spare1', 'BDT_week', 'spare2',
81 | 'SV_accuracy', 'SatH1', 'TGD1', 'TGD2',
82 | 'Transmission_time', 'AODC'
83 | ],
84 | 'J': [ # QZSS Navigation Message (similar to GPS)
85 | 'IODE', 'Crs', 'Delta_n', 'M0',
86 | 'Cuc', 'e', 'Cus', 'sqrt_A',
87 | 'Toe', 'Cic', 'OMEGA', 'Cis',
88 | 'i0', 'Crc', 'omega', 'OMEGA_DOT',
89 | 'IDOT', 'L2_codes', 'GPS_week', 'L2_P_flag',
90 | 'SV_accuracy', 'SV_health', 'TGD', 'IODC',
91 | 'Transmission_time', 'Fit_interval'
92 | ],
93 | 'I': [ # IRNSS Navigation Message
94 | 'IODEC', 'Crs', 'Delta_n', 'M0',
95 | 'Cuc', 'e', 'Cus', 'sqrt_A',
96 | 'Toe', 'Cic', 'OMEGA', 'Cis',
97 | 'i0', 'Crc', 'omega', 'OMEGA_DOT',
98 | 'IDOT', 'spare', 'IRNSS_week', 'spare',
99 | 'SV_accuracy', 'SV_health', 'TGD', 'spare',
100 | 'Transmission_time', 'spare'
101 | ],
102 | 'S': [ # SBAS Navigation Message
103 | 'spare', 'Crs', 'Delta_n', 'M0',
104 | 'Cuc', 'e', 'Cus', 'sqrt_A',
105 | 'Toe', 'Cic', 'OMEGA', 'Cis',
106 | 'i0', 'Crc', 'omega', 'OMEGA_DOT',
107 | 'IDOT', 'spare', 'GPS_week', 'spare',
108 | 'SV_accuracy', 'SV_health', 'spare', 'spare',
109 | 'Transmission_time', 'spare'
110 | ]
111 | }
112 |
113 | # Physical Constants
114 | PHYSICAL_CONSTANTS = {
115 | 'c': 299792458.0, # Speed of light in vacuum (m/s)
116 | 'mu_earth': 3.986005e14, # Earth's gravitational constant (m^3/s^2)
117 | 'omega_e': 7.2921151467e-5, # Earth's rotation rate (rad/s)
118 | 'F': -4.442807633e-10, # Relativistic correction constant (-2*sqrt(mu)/(c^2))
119 | 'earth_radius': 6371000 # Earth's mean radius (m)
120 | }
121 |
122 | # Time System Offsets
123 | TIME_SYSTEM_OFFSETS = {
124 | # (From, To): Offset in seconds
125 | ('GPST', 'UTC'): lambda week, tow: leap_seconds(week, tow) - 19, # GPS Time to UTC
126 | ('GLST', 'UTC'): lambda week, tow: 0, # GLONASS Time (UTC-based) to UTC
127 | ('GST', 'UTC'): lambda week, tow: leap_seconds(week, tow) - 19, # Galileo System Time to UTC
128 | ('BDT', 'UTC'): lambda week, tow: leap_seconds(week, tow) - 33, # BeiDou Time to UTC
129 | ('QZSST', 'UTC'): lambda week, tow: leap_seconds(week, tow) - 19, # QZSS Time to UTC
130 | ('IRNWT', 'UTC'): lambda week, tow: leap_seconds(week, tow) - 19, # IRNSS Time to UTC
131 | }
132 |
133 | # Epoch reference times for different GNSS systems
134 | EPOCH_REFERENCES = {
135 | 'G': (1980, 1, 6, 0, 0, 0), # GPS: January 6, 1980 00:00:00 UTC
136 | 'R': (1996, 1, 1, 0, 0, 0), # GLONASS: January 1, 1996 00:00:00 UTC
137 | 'E': (1999, 8, 22, 0, 0, 0), # Galileo: August 22, 1999 00:00:00 UTC
138 | 'C': (2006, 1, 1, 0, 0, 0), # BeiDou: January 1, 2006 00:00:00 UTC
139 | 'J': (1980, 1, 6, 0, 0, 0), # QZSS: Same as GPS
140 | 'I': (1980, 1, 6, 0, 0, 0), # IRNSS: Same as GPS
141 | 'S': (1980, 1, 6, 0, 0, 0) # SBAS: Same as GPS
142 | }
143 |
144 | def leap_seconds(gps_week, tow):
145 | """
146 | Get the number of leap seconds for a given GPS time.
147 |
148 | Parameters
149 | ----------
150 | gps_week : int
151 | GPS week number.
152 | tow : float
153 | Time of week in seconds.
154 |
155 | Returns
156 | -------
157 | int
158 | Number of leap seconds.
159 |
160 | Notes
161 | -----
162 | GPS time is ahead of UTC by a number of leap seconds that increases over time.
163 | This function provides a rough estimate based on known leap second insertions.
164 | For precise applications, use an up-to-date leap second table.
165 | """
166 | # Convert GPS week and ToW to seconds since GPS epoch
167 | seconds_since_gps_epoch = gps_week * 604800 + tow
168 |
169 | # Define leap second insertion times in seconds since GPS epoch
170 | leap_second_insertions = [
171 | (46828800, 1), # July 1, 1981
172 | (78364801, 2), # July 1, 1982
173 | (109900802, 3), # July 1, 1983
174 | (173059203, 4), # July 1, 1985
175 | (252028804, 5), # January 1, 1988
176 | (315187205, 6), # January 1, 1990
177 | (346723206, 7), # January 1, 1991
178 | (393984007, 8), # July 1, 1992
179 | (425520008, 9), # July 1, 1993
180 | (457056009, 10), # July 1, 1994
181 | (504489610, 11), # January 1, 1996
182 | (551750411, 12), # July 1, 1997
183 | (599184012, 13), # January 1, 1999
184 | (820108813, 14), # January 1, 2006
185 | (914803214, 15), # January 1, 2009
186 | (1025136015, 16), # July 1, 2012
187 | (1119744016, 17), # July 1, 2015
188 | (1167264017, 18), # January 1, 2017
189 | # Add more as they occur
190 | ]
191 |
192 | # Find the number of leap seconds at the given time
193 | leap_seconds = 0
194 | for insertion_time, num_leaps in leap_second_insertions:
195 | if seconds_since_gps_epoch >= insertion_time:
196 | leap_seconds = num_leaps
197 | else:
198 | break
199 |
200 | return leap_seconds
--------------------------------------------------------------------------------
/src/pygnsslab/io/rinexnav/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Utility functions for RINEX Navigation file handling.
3 |
4 | This module provides helper functions for working with RINEX navigation data.
5 | """
6 |
7 | import numpy as np
8 | import pandas as pd
9 | from datetime import datetime, timedelta
10 | from typing import Dict, List, Tuple, Union, Optional
11 |
12 | def gps_time_to_datetime(week: int, seconds: float) -> datetime:
13 | """
14 | Convert GPS time (week number + seconds of week) to datetime.
15 |
16 | Parameters
17 | ----------
18 | week : int
19 | GPS week number.
20 | seconds : float
21 | Seconds of the GPS week.
22 |
23 | Returns
24 | -------
25 | datetime
26 | Equivalent datetime object in UTC.
27 | """
28 | gps_epoch = datetime(1980, 1, 6, 0, 0, 0)
29 | delta = timedelta(weeks=week, seconds=seconds)
30 | return gps_epoch + delta
31 |
32 | def datetime_to_gps_time(dt: datetime) -> Tuple[int, float]:
33 | """
34 | Convert datetime to GPS time (week number + seconds of week).
35 |
36 | Parameters
37 | ----------
38 | dt : datetime
39 | Datetime object in UTC.
40 |
41 | Returns
42 | -------
43 | Tuple[int, float]
44 | GPS week number and seconds of the week.
45 | """
46 | gps_epoch = datetime(1980, 1, 6, 0, 0, 0)
47 | delta = dt - gps_epoch
48 |
49 | # Total seconds since GPS epoch
50 | total_seconds = delta.total_seconds()
51 |
52 | # GPS week number
53 | week = int(total_seconds / (7 * 24 * 3600))
54 |
55 | # Seconds of the week
56 | sow = total_seconds % (7 * 24 * 3600)
57 |
58 | return week, sow
59 |
60 | def interpolate_orbit(nav_data: pd.DataFrame, time: datetime,
61 | sat_system: str = 'G', prn: int = 1) -> Dict[str, float]:
62 | """
63 | Interpolate satellite position at a given time using navigation data.
64 |
65 | Parameters
66 | ----------
67 | nav_data : pd.DataFrame
68 | Navigation data DataFrame for a specific satellite system.
69 | time : datetime
70 | Time at which to interpolate the satellite position.
71 | sat_system : str, optional
72 | Satellite system identifier (default: 'G' for GPS).
73 | prn : int, optional
74 | Satellite PRN number (default: 1).
75 |
76 | Returns
77 | -------
78 | Dict[str, float]
79 | Dictionary containing interpolated position (X, Y, Z) and velocity (VX, VY, VZ).
80 |
81 | Notes
82 | -----
83 | This is a simplified version that works for GPS satellites.
84 | Different algorithms are needed for other GNSS systems.
85 | """
86 | if sat_system not in ['G', 'J']: # Only GPS and QZSS supported for now
87 | raise ValueError(f"Interpolation for {sat_system} not supported yet.")
88 |
89 | # Filter navigation data for the specific satellite
90 | sat_data = nav_data[nav_data['PRN'] == prn].sort_values('Epoch')
91 |
92 | if sat_data.empty:
93 | raise ValueError(f"No navigation data found for {sat_system}{prn:02d}")
94 |
95 | # Find the closest epoch before the requested time
96 | closest_row = sat_data[sat_data['Epoch'] <= time].iloc[-1]
97 |
98 | # Extract orbit parameters
99 | mu = 3.986005e14 # Earth's gravitational constant (m^3/s^2)
100 | omega_e = 7.2921151467e-5 # Earth's rotation rate (rad/s)
101 |
102 | # Check for NaN values in key parameters
103 | required_params = ['sqrt_A', 'e', 'M0', 'omega', 'OMEGA', 'i0', 'Delta_n', 'Cuc', 'Cus', 'Crc', 'Crs', 'Cic', 'Cis', 'IDOT', 'OMEGA_DOT']
104 | for param in required_params:
105 | if param not in closest_row or pd.isna(closest_row[param]):
106 | raise ValueError(f"Missing or NaN value for {param} in navigation data")
107 |
108 | # Semi-major axis
109 | A = closest_row['sqrt_A'] ** 2
110 |
111 | # Computed mean motion
112 | n0 = np.sqrt(mu / (A**3))
113 |
114 | # Time difference from epoch
115 | toe = closest_row['Toe']
116 | if isinstance(toe, float):
117 | # Toe is in seconds of GPS week
118 | # Try to get the GPS week - different satellite systems might use different column names
119 | week = None
120 | for week_col in ['GPS_week', 'GAL_week', 'BDT_week']:
121 | if week_col in closest_row and not pd.isna(closest_row[week_col]):
122 | try:
123 | # Ensure the week is an integer
124 | week = int(float(closest_row[week_col]))
125 | break
126 | except (ValueError, TypeError):
127 | # Skip if value can't be converted to int
128 | continue
129 |
130 | if week is None:
131 | # If no week column is found, use current week as fallback
132 | # This is not accurate but better than failing completely
133 | current_week, _ = datetime_to_gps_time(datetime.now())
134 | week = current_week
135 |
136 | toe_datetime = gps_time_to_datetime(week, toe)
137 | else:
138 | # Toe is already a datetime
139 | toe_datetime = toe
140 |
141 | dt = (time - toe_datetime).total_seconds()
142 |
143 | # Corrected mean motion
144 | n = n0 + closest_row['Delta_n']
145 |
146 | # Mean anomaly at time t
147 | M = closest_row['M0'] + n * dt
148 |
149 | # Eccentric anomaly (iterative solution)
150 | e = closest_row['e']
151 | E = M
152 | for _ in range(10): # Usually converges within a few iterations
153 | E_next = M + e * np.sin(E)
154 | if abs(E_next - E) < 1e-12:
155 | break
156 | E = E_next
157 |
158 | # True anomaly
159 | v = np.arctan2(np.sqrt(1 - e**2) * np.sin(E), np.cos(E) - e)
160 |
161 | # Argument of latitude
162 | phi = v + closest_row['omega']
163 |
164 | # Second harmonic perturbations
165 | du = closest_row['Cuc'] * np.cos(2*phi) + closest_row['Cus'] * np.sin(2*phi) # Argument of latitude correction
166 | dr = closest_row['Crc'] * np.cos(2*phi) + closest_row['Crs'] * np.sin(2*phi) # Radius correction
167 | di = closest_row['Cic'] * np.cos(2*phi) + closest_row['Cis'] * np.sin(2*phi)
168 |
169 | # Corrected argument of latitude
170 | u = phi + du
171 |
172 | # Corrected radius
173 | r = A * (1 - e * np.cos(E)) + dr
174 |
175 | # Corrected inclination
176 | i = closest_row['i0'] + di + closest_row['IDOT'] * dt
177 |
178 | # Positions in orbital plane
179 | x_prime = r * np.cos(u)
180 | y_prime = r * np.sin(u)
181 |
182 | # Corrected longitude of ascending node
183 | Omega = closest_row['OMEGA'] + (closest_row['OMEGA_DOT'] - omega_e) * dt - omega_e * toe
184 |
185 | # Earth-fixed coordinates
186 | X = x_prime * np.cos(Omega) - y_prime * np.cos(i) * np.sin(Omega)
187 | Y = x_prime * np.sin(Omega) + y_prime * np.cos(i) * np.cos(Omega)
188 | Z = y_prime * np.sin(i)
189 |
190 | # Velocity calculations (simplified)
191 | # Time derivative of eccentric anomaly
192 | E_dot = n / (1 - e * np.cos(E))
193 |
194 | # Time derivative of argument of latitude
195 | phi_dot = np.sqrt(1 - e**2) * E_dot / (1 - e * np.cos(E))
196 |
197 | # Time derivative of corrected argument of latitude
198 | u_dot = phi_dot * (1 + 2 * (closest_row['Cus'] * np.cos(2*phi) - closest_row['Cuc'] * np.sin(2*phi)))
199 |
200 | # Time derivative of corrected radius
201 | r_dot = A * e * np.sin(E) * E_dot + 2 * phi_dot * (closest_row['Crs'] * np.cos(2*phi) - closest_row['Crc'] * np.sin(2*phi))
202 |
203 | # Time derivatives in orbital plane
204 | x_prime_dot = r_dot * np.cos(u) - r * u_dot * np.sin(u)
205 | y_prime_dot = r_dot * np.sin(u) + r * u_dot * np.cos(u)
206 |
207 | # Time derivative of longitude of ascending node
208 | Omega_dot = closest_row['OMEGA_DOT'] - omega_e
209 |
210 | # Time derivative of corrected inclination
211 | i_dot = closest_row['IDOT'] + 2 * phi_dot * (closest_row['Cis'] * np.cos(2*phi) - closest_row['Cic'] * np.sin(2*phi))
212 |
213 | # Earth-fixed velocity components
214 | VX = (x_prime_dot * np.cos(Omega) - y_prime_dot * np.cos(i) * np.sin(Omega) -
215 | y_prime * np.sin(i) * i_dot * np.sin(Omega) -
216 | (x_prime * np.sin(Omega) + y_prime * np.cos(i) * np.cos(Omega)) * Omega_dot)
217 |
218 | VY = (x_prime_dot * np.sin(Omega) + y_prime_dot * np.cos(i) * np.cos(Omega) +
219 | y_prime * np.sin(i) * i_dot * np.cos(Omega) +
220 | (x_prime * np.cos(Omega) - y_prime * np.cos(i) * np.sin(Omega)) * Omega_dot)
221 |
222 | VZ = y_prime_dot * np.sin(i) + y_prime * np.cos(i) * i_dot
223 |
224 | return {
225 | 'X': X,
226 | 'Y': Y,
227 | 'Z': Z,
228 | 'VX': VX,
229 | 'VY': VY,
230 | 'VZ': VZ
231 | }
232 |
233 | def compute_sv_clock_correction(nav_data: pd.DataFrame, time: datetime,
234 | sat_system: str = 'G', prn: int = 1) -> float:
235 | """
236 | Compute satellite clock correction at a given time.
237 |
238 | Parameters
239 | ----------
240 | nav_data : pd.DataFrame
241 | Navigation data DataFrame for a specific satellite system.
242 | time : datetime
243 | Time at which to compute the clock correction.
244 | sat_system : str, optional
245 | Satellite system identifier (default: 'G' for GPS).
246 | prn : int, optional
247 | Satellite PRN number (default: 1).
248 |
249 | Returns
250 | -------
251 | float
252 | Clock correction value in seconds.
253 | """
254 | # Filter navigation data for the specific satellite
255 | sat_data = nav_data[nav_data['PRN'] == prn].sort_values('Epoch')
256 |
257 | if sat_data.empty:
258 | raise ValueError(f"No navigation data found for {sat_system}{prn:02d}")
259 |
260 | # Find the closest epoch before the requested time
261 | closest_row = sat_data[sat_data['Epoch'] <= time].iloc[-1]
262 |
263 | # Check for NaN values in key parameters
264 | required_params = ['SV_clock_bias', 'SV_clock_drift', 'SV_clock_drift_rate']
265 | for param in required_params:
266 | if param not in closest_row or pd.isna(closest_row[param]):
267 | raise ValueError(f"Missing or NaN value for {param} in navigation data")
268 |
269 | # Get clock parameters
270 | a0 = closest_row['SV_clock_bias']
271 | a1 = closest_row['SV_clock_drift']
272 | a2 = closest_row['SV_clock_drift_rate']
273 |
274 | # Time difference from epoch
275 | toc = closest_row['Epoch'] # Time of clock parameters
276 | dt = (time - toc).total_seconds()
277 |
278 | # Compute clock correction
279 | dt_corr = a0 + a1 * dt + a2 * dt**2
280 |
281 | # For GPS satellites, apply relativistic correction
282 | if sat_system in ['G', 'J']:
283 | # Check if we have the required orbital parameters
284 | if 'e' in closest_row and 'sqrt_A' in closest_row and not pd.isna(closest_row['e']) and not pd.isna(closest_row['sqrt_A']):
285 | # Get orbital parameters
286 | e = closest_row['e']
287 | A = closest_row['sqrt_A']**2
288 |
289 | # Compute eccentric anomaly for relativistic correction
290 | # (Simplified approach - for full implementation, use the same E as in interpolate_orbit)
291 | mu = 3.986005e14 # Earth's gravitational constant
292 | n0 = np.sqrt(mu / (A**3))
293 |
294 | if 'Toe' in closest_row and not pd.isna(closest_row['Toe']) and 'M0' in closest_row and not pd.isna(closest_row['M0']):
295 | toe = closest_row['Toe']
296 | if isinstance(toe, float):
297 | # Toe is in seconds of GPS week
298 | # Try to get the GPS week - different satellite systems might use different column names
299 | week = None
300 | for week_col in ['GPS_week', 'GAL_week', 'BDT_week']:
301 | if week_col in closest_row and not pd.isna(closest_row[week_col]):
302 | try:
303 | # Ensure the week is an integer
304 | week = int(float(closest_row[week_col]))
305 | break
306 | except (ValueError, TypeError):
307 | # Skip if value can't be converted to int
308 | continue
309 |
310 | if week is None:
311 | # If no week column is found, use current week as fallback
312 | # This is not accurate but better than failing completely
313 | current_week, _ = datetime_to_gps_time(datetime.now())
314 | week = current_week
315 |
316 | toe_datetime = gps_time_to_datetime(week, toe)
317 | else:
318 | # Toe is already a datetime
319 | toe_datetime = toe
320 |
321 | dt_toe = (time - toe_datetime).total_seconds()
322 | M = closest_row['M0'] + n0 * dt_toe
323 |
324 | # Iterative solution for eccentric anomaly
325 | E = M
326 | for _ in range(10):
327 | E_next = M + e * np.sin(E)
328 | if abs(E_next - E) < 1e-12:
329 | break
330 | E = E_next
331 |
332 | # Relativistic correction
333 | F = -2 * np.sqrt(mu) / (3e8**2)
334 | rel_corr = F * e * np.sqrt(A) * np.sin(E)
335 |
336 | # Add relativistic correction to the clock correction
337 | dt_corr += rel_corr
338 |
339 | return dt_corr
340 |
341 | def detect_rinex_version(filename: str) -> float:
342 | """
343 | Detect the RINEX version from a navigation file.
344 |
345 | Parameters
346 | ----------
347 | filename : str
348 | Path to the RINEX navigation file.
349 |
350 | Returns
351 | -------
352 | float
353 | RINEX version number.
354 |
355 | Raises
356 | ------
357 | ValueError
358 | If the RINEX version cannot be determined.
359 | """
360 | with open(filename, 'r') as f:
361 | first_line = f.readline()
362 |
363 | try:
364 | version = float(first_line[:9].strip())
365 | return version
366 | except ValueError:
367 | raise ValueError(f"Cannot determine RINEX version from file: {filename}")
--------------------------------------------------------------------------------
/src/pygnsslab/io/sp3/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | SP3 (Standard Product 3) file I/O module.
3 |
4 | This module provides functionality to read and write SP3 format files,
5 | which contain precise GNSS satellite orbit and clock information.
6 |
7 | The module supports both SP3-c and SP3-d formats.
8 | """
9 |
10 | from .reader import SP3Reader
11 | from .writer import SP3Writer, write_sp3_file
12 | from .metadata import SP3Header, SP3Epoch, SP3SatelliteRecord
13 | from .utils import identify_sp3_version
14 |
15 | __all__ = [
16 | 'SP3Reader',
17 | 'SP3Writer',
18 | 'write_sp3_file',
19 | 'SP3Header',
20 | 'SP3Epoch',
21 | 'SP3SatelliteRecord',
22 | 'identify_sp3_version'
23 | ]
--------------------------------------------------------------------------------
/src/pygnsslab/io/sp3/example_usage.py:
--------------------------------------------------------------------------------
1 | """
2 | Example usage of the SP3 module.
3 |
4 | This module demonstrates how to read, manipulate, and write SP3 files.
5 | """
6 |
7 | from datetime import datetime, timedelta
8 | import os
9 | import sys
10 | import matplotlib.pyplot as plt
11 | import numpy as np
12 |
13 | # Add the parent directory to the path so we can import the module
14 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
15 |
16 | from pygnsslab.io.sp3.reader import SP3Reader
17 | from pygnsslab.io.sp3.writer import write_sp3_file
18 | from pygnsslab.io.sp3.metadata import SP3Header, SP3Epoch, SP3SatelliteRecord
19 |
20 |
21 | def read_sp3_file(file_path):
22 | """
23 | Read an SP3 file and print basic information.
24 |
25 | Parameters
26 | ----------
27 | file_path : str
28 | Path to the SP3 file.
29 | """
30 | # Read the SP3 file
31 | print(f"Reading SP3 file: {file_path}")
32 | reader = SP3Reader(file_path)
33 |
34 | # Print header information
35 | header = reader.header
36 | print("\nHeader Information:")
37 | print(f" Version: SP3-{header.version}")
38 | print(f" File Type: {header.file_type}")
39 | print(f" Time: {header.time}")
40 | print(f" Epoch Count: {header.epoch_count}")
41 | print(f" Satellite Count: {header.num_satellites}")
42 | if header.satellite_ids:
43 | satellite_ids_str = ', '.join(header.satellite_ids[:5])
44 | if len(header.satellite_ids) > 5:
45 | satellite_ids_str += "..."
46 | print(f" Satellites: {satellite_ids_str}")
47 | else:
48 | print(" Satellites: None")
49 |
50 | # Print information about the first epoch
51 | if reader.epochs:
52 | first_epoch = reader.epochs[0]
53 | print("\nFirst Epoch Information:")
54 | print(f" Time: {first_epoch.time}")
55 | print(f" Number of Satellites: {len(first_epoch.satellites)}")
56 |
57 | # Print information about the first satellite in the first epoch
58 | if first_epoch.satellites:
59 | first_sat_id = list(first_epoch.satellites.keys())[0]
60 | first_sat = first_epoch.satellites[first_sat_id]
61 | print(f"\nSatellite {first_sat_id} Information:")
62 | print(f" Position (X, Y, Z): ({first_sat.x}, {first_sat.y}, {first_sat.z}) km")
63 | print(f" Clock: {first_sat.clock} µs")
64 |
65 | if first_sat.x_vel is not None:
66 | print(f" Velocity (X, Y, Z): ({first_sat.x_vel}, {first_sat.y_vel}, {first_sat.z_vel}) dm/s")
67 |
68 | # Print summary
69 | print("\nSummary:")
70 | print(f" Number of Epochs: {len(reader.epochs)}")
71 | if len(reader.epochs) > 1:
72 | time_diff = reader.epochs[1].time - reader.epochs[0].time
73 | print(f" Epoch Interval: {time_diff.total_seconds()} seconds")
74 |
75 | return reader
76 |
77 |
78 | def plot_satellite_positions(reader, satellite_id, title=None):
79 | """
80 | Plot the positions of a satellite over time.
81 |
82 | Parameters
83 | ----------
84 | reader : SP3Reader
85 | The SP3 reader object.
86 | satellite_id : str
87 | The satellite ID to plot.
88 | title : str, optional
89 | The title for the plot.
90 | """
91 | positions = reader.get_satellite_positions(satellite_id)
92 | if not positions:
93 | print(f"No position data for satellite {satellite_id}")
94 | return
95 |
96 | # Extract data for plotting
97 | times = [pos[0] for pos in positions]
98 | x_coords = [pos[1] for pos in positions]
99 | y_coords = [pos[2] for pos in positions]
100 | z_coords = [pos[3] for pos in positions]
101 |
102 | # Create a figure with 3 subplots
103 | fig, axs = plt.subplots(3, 1, figsize=(10, 12))
104 |
105 | # Convert datetime to hours from the first epoch for x-axis
106 | t0 = times[0]
107 | hours = [(t - t0).total_seconds() / 3600 for t in times]
108 |
109 | # Plot X coordinate
110 | axs[0].plot(hours, x_coords, 'r-')
111 | axs[0].set_ylabel('X Coordinate (km)')
112 | axs[0].set_title(f'Satellite {satellite_id} Position - X Coordinate')
113 | axs[0].grid(True)
114 |
115 | # Plot Y coordinate
116 | axs[1].plot(hours, y_coords, 'g-')
117 | axs[1].set_ylabel('Y Coordinate (km)')
118 | axs[1].set_title(f'Satellite {satellite_id} Position - Y Coordinate')
119 | axs[1].grid(True)
120 |
121 | # Plot Z coordinate
122 | axs[2].plot(hours, z_coords, 'b-')
123 | axs[2].set_xlabel('Time (hours from start)')
124 | axs[2].set_ylabel('Z Coordinate (km)')
125 | axs[2].set_title(f'Satellite {satellite_id} Position - Z Coordinate')
126 | axs[2].grid(True)
127 |
128 | # Add a main title if provided
129 | if title:
130 | fig.suptitle(title, fontsize=16)
131 |
132 | plt.tight_layout()
133 | plt.show(block=True)
134 |
135 |
136 | def plot_satellite_3d_orbit(reader, satellite_id, title=None):
137 | """
138 | Create a 3D plot of the satellite orbit.
139 |
140 | Parameters
141 | ----------
142 | reader : SP3Reader
143 | The SP3 reader object.
144 | satellite_id : str
145 | The satellite ID to plot.
146 | title : str, optional
147 | The title for the plot.
148 | """
149 | positions = reader.get_satellite_positions(satellite_id)
150 | if not positions:
151 | print(f"No position data for satellite {satellite_id}")
152 | return
153 |
154 | # Extract data for plotting
155 | x_coords = [pos[1] for pos in positions]
156 | y_coords = [pos[2] for pos in positions]
157 | z_coords = [pos[3] for pos in positions]
158 |
159 | # Create a figure
160 | fig = plt.figure(figsize=(10, 8))
161 | ax = fig.add_subplot(111, projection='3d')
162 |
163 | # Plot orbit
164 | ax.plot(x_coords, y_coords, z_coords, 'b-', label=f'Satellite {satellite_id} Orbit')
165 |
166 | # Plot the Earth (simplified as a sphere)
167 | # Earth radius in km
168 | r_earth = 6371.0
169 |
170 | # Create a sphere
171 | u, v = np.mgrid[0:2*np.pi:20j, 0:np.pi:10j]
172 | x = r_earth * np.cos(u) * np.sin(v)
173 | y = r_earth * np.sin(u) * np.sin(v)
174 | z = r_earth * np.cos(v)
175 |
176 | # Plot Earth with low alpha for transparency
177 | ax.plot_surface(x, y, z, color='blue', alpha=0.1)
178 |
179 | # Set labels and title
180 | ax.set_xlabel('X (km)')
181 | ax.set_ylabel('Y (km)')
182 | ax.set_zlabel('Z (km)')
183 |
184 | if title:
185 | ax.set_title(title)
186 | else:
187 | ax.set_title(f'Satellite {satellite_id} Orbit')
188 |
189 | # Set axis limits to be equal for proper sphere rendering
190 | max_range = max(
191 | max(x_coords) - min(x_coords),
192 | max(y_coords) - min(y_coords),
193 | max(z_coords) - min(z_coords)
194 | ) / 2.0
195 |
196 | mid_x = (max(x_coords) + min(x_coords)) / 2
197 | mid_y = (max(y_coords) + min(y_coords)) / 2
198 | mid_z = (max(z_coords) + min(z_coords)) / 2
199 |
200 | ax.set_xlim(mid_x - max_range, mid_x + max_range)
201 | ax.set_ylim(mid_y - max_range, mid_y + max_range)
202 | ax.set_zlim(mid_z - max_range, mid_z + max_range)
203 |
204 | plt.tight_layout()
205 | plt.show()
206 |
207 |
208 | def create_sample_sp3_file(output_file_path):
209 | """
210 | Create a sample SP3 file with some test data.
211 |
212 | Parameters
213 | ----------
214 | output_file_path : str
215 | Path to the output SP3 file.
216 | """
217 | # Create header
218 | header = SP3Header(
219 | version='c', # SP3-c format
220 | file_type='P', # Position only
221 | time=datetime(2023, 1, 1, 0, 0, 0),
222 | epoch_count=24, # 24 epochs
223 | data_used="ORBIT",
224 | coordinate_system="IGS14",
225 | orbit_type="FIT",
226 | agency="TEST",
227 | gps_week=2245,
228 | seconds_of_week=0.0,
229 | epoch_interval=3600.0, # 1 hour
230 | mjd=59945,
231 | fractional_day=0.0,
232 | num_satellites=3,
233 | satellite_ids=["G01", "G02", "G03"],
234 | satellite_accuracy={"G01": 7, "G02": 6, "G03": 7},
235 | file_system="G",
236 | time_system="GPS",
237 | comments=["This is a sample SP3 file", "Created for demonstration purposes"]
238 | )
239 |
240 | # Create epochs with satellite data
241 | epochs = []
242 | for i in range(24): # 24 hours
243 | epoch_time = header.time + timedelta(hours=i)
244 | epoch = SP3Epoch(time=epoch_time)
245 |
246 | # Angular position around Earth (simplified circular orbit)
247 | angle_g01 = 2 * np.pi * i / 12 # 12-hour orbit for G01
248 | angle_g02 = 2 * np.pi * i / 11 # 11-hour orbit for G02
249 | angle_g03 = 2 * np.pi * i / 10 # 10-hour orbit for G03
250 |
251 | # Orbital radius in km (simplified)
252 | radius_g01 = 26000.0
253 | radius_g02 = 26500.0
254 | radius_g03 = 27000.0
255 |
256 | # Satellite positions in Earth-centered coordinates
257 | # G01
258 | x_g01 = radius_g01 * np.cos(angle_g01)
259 | y_g01 = radius_g01 * np.sin(angle_g01)
260 | z_g01 = 5000.0 * np.sin(angle_g01 * 2) # Add some Z variation
261 |
262 | # G02
263 | x_g02 = radius_g02 * np.cos(angle_g02)
264 | y_g02 = radius_g02 * np.sin(angle_g02)
265 | z_g02 = 5500.0 * np.sin(angle_g02 * 2) # Add some Z variation
266 |
267 | # G03
268 | x_g03 = radius_g03 * np.cos(angle_g03)
269 | y_g03 = radius_g03 * np.sin(angle_g03)
270 | z_g03 = 6000.0 * np.sin(angle_g03 * 2) # Add some Z variation
271 |
272 | # Add satellite records to the epoch
273 | epoch.satellites["G01"] = SP3SatelliteRecord(
274 | sat_id="G01",
275 | x=x_g01,
276 | y=y_g01,
277 | z=z_g01,
278 | clock=100.0 + i * 0.1, # Simulated clock bias
279 | x_sdev=5.0,
280 | y_sdev=5.0,
281 | z_sdev=5.0,
282 | clock_sdev=10.0
283 | )
284 |
285 | epoch.satellites["G02"] = SP3SatelliteRecord(
286 | sat_id="G02",
287 | x=x_g02,
288 | y=y_g02,
289 | z=z_g02,
290 | clock=200.0 + i * 0.2, # Simulated clock bias
291 | x_sdev=5.0,
292 | y_sdev=5.0,
293 | z_sdev=5.0,
294 | clock_sdev=10.0
295 | )
296 |
297 | epoch.satellites["G03"] = SP3SatelliteRecord(
298 | sat_id="G03",
299 | x=x_g03,
300 | y=y_g03,
301 | z=z_g03,
302 | clock=300.0 + i * 0.3, # Simulated clock bias
303 | x_sdev=5.0,
304 | y_sdev=5.0,
305 | z_sdev=5.0,
306 | clock_sdev=10.0
307 | )
308 |
309 | epochs.append(epoch)
310 |
311 | # Write the SP3 file
312 | write_sp3_file(output_file_path, header, epochs)
313 | print(f"Created sample SP3 file: {output_file_path}")
314 |
315 |
316 | def main(show_plots=False):
317 | """
318 | Main function to demonstrate the SP3 module functionality.
319 |
320 | Parameters
321 | ----------
322 | show_plots : bool, optional
323 | Whether to show the plots, by default False
324 | """
325 | # Set the path to the example SP3 file
326 | # You should replace this with the path to your actual SP3 file
327 | sp3_file_path = "data/sp3/COD0MGXFIN_20220010000_01D_05M_ORB.SP3"
328 |
329 | # Create the directory if it doesn't exist
330 | os.makedirs(os.path.dirname(sp3_file_path), exist_ok=True)
331 |
332 | # Create a sample SP3 file
333 | create_sample_sp3_file(sp3_file_path)
334 |
335 | # Read the SP3 file
336 | reader = read_sp3_file(sp3_file_path)
337 |
338 | # Plot satellite positions and orbits if requested
339 | if show_plots:
340 | # Plot satellite positions
341 | plot_satellite_positions(reader, "G01", "Satellite G01 Position Over Time")
342 |
343 | # Plot satellite 3D orbit
344 | plot_satellite_3d_orbit(reader, "G01", "Satellite G01 Orbit")
345 |
346 |
347 | if __name__ == "__main__":
348 | # Set show_plots to False to avoid blocking when running as a module
349 | main(show_plots=False)
--------------------------------------------------------------------------------
/src/pygnsslab/io/sp3/metadata.py:
--------------------------------------------------------------------------------
1 | """
2 | SP3 metadata classes.
3 |
4 | This module contains classes to represent SP3 file metadata, including
5 | header information, epochs, and satellite records.
6 | """
7 |
8 | from dataclasses import dataclass, field
9 | from datetime import datetime
10 | from typing import Dict, List, Optional, Set, Tuple
11 |
12 |
13 | @dataclass
14 | class SP3Header:
15 | """Class representing the header of an SP3 file."""
16 | version: str # 'c' or 'd'
17 | file_type: str # 'P'=position only, 'V'=position and velocity, etc.
18 | time: datetime # Start time
19 | epoch_count: int # Number of epochs
20 | data_used: str # Data used
21 | coordinate_system: str # Coordinate system
22 | orbit_type: str # Orbit type
23 | agency: str # Agency providing the data
24 | gps_week: int # GPS week
25 | seconds_of_week: float # Seconds of week
26 | epoch_interval: float # Interval between epochs in seconds
27 | mjd: int # Modified Julian Date
28 | fractional_day: float # Fractional part of the day
29 | num_satellites: int # Number of satellites
30 | satellite_ids: List[str] = field(default_factory=list) # List of satellite IDs
31 | satellite_accuracy: Dict[str, int] = field(default_factory=dict) # Dictionary of satellite accuracy values
32 | file_system: str = "" # File system
33 | time_system: str = "" # Time system
34 | base_pos_vel: List[float] = field(default_factory=list) # Base position/velocity
35 | base_clock: List[float] = field(default_factory=list) # Base clock
36 | custom_parameters: List[str] = field(default_factory=list) # Custom parameters
37 | comments: List[str] = field(default_factory=list) # Comments
38 |
39 |
40 | @dataclass
41 | class SP3SatelliteRecord:
42 | """Class representing a satellite record in an SP3 file."""
43 | sat_id: str # Satellite ID (e.g., 'G01')
44 | x: float # X coordinate in km
45 | y: float # Y coordinate in km
46 | z: float # Z coordinate in km
47 | clock: float # Clock in microseconds
48 | x_sdev: Optional[float] = None # Standard deviation of X in m
49 | y_sdev: Optional[float] = None # Standard deviation of Y in m
50 | z_sdev: Optional[float] = None # Standard deviation of Z in m
51 | clock_sdev: Optional[float] = None # Standard deviation of clock in 1E-12 s
52 | x_vel: Optional[float] = None # X velocity in dm/s
53 | y_vel: Optional[float] = None # Y velocity in dm/s
54 | z_vel: Optional[float] = None # Z velocity in dm/s
55 | clock_rate: Optional[float] = None # Clock rate in 1E-14 s/s
56 | x_vel_sdev: Optional[float] = None # Standard deviation of X velocity
57 | y_vel_sdev: Optional[float] = None # Standard deviation of Y velocity
58 | z_vel_sdev: Optional[float] = None # Standard deviation of Z velocity
59 | clock_rate_sdev: Optional[float] = None # Standard deviation of clock rate
60 | correlation: Optional[Dict[str, float]] = None # Correlation information
61 | event_flag: Optional[int] = None # Event flag
62 | clock_event_flag: Optional[int] = None # Clock event flag
63 |
64 |
65 | @dataclass
66 | class SP3Epoch:
67 | """Class representing an epoch in an SP3 file."""
68 | time: datetime # Epoch time
69 | satellites: Dict[str, SP3SatelliteRecord] = field(default_factory=dict) # Dictionary of satellite records
--------------------------------------------------------------------------------
/src/pygnsslab/io/sp3/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Utility functions for SP3 file handling.
3 |
4 | This module provides utility functions for processing SP3 files,
5 | including format detection and conversion helpers.
6 | """
7 |
8 | import re
9 | from datetime import datetime, timedelta
10 | from typing import Tuple
11 |
12 |
13 | def identify_sp3_version(first_line: str) -> str:
14 | """
15 | Identify the version of an SP3 file from its first line.
16 |
17 | Parameters
18 | ----------
19 | first_line : str
20 | The first line of the SP3 file.
21 |
22 | Returns
23 | -------
24 | str
25 | The SP3 version ('c' or 'd').
26 |
27 | Raises
28 | ------
29 | ValueError
30 | If the first line is not a valid SP3 header line.
31 | """
32 | if not first_line.startswith('#'):
33 | raise ValueError("Not a valid SP3 file: header must start with '#'")
34 |
35 | version = first_line[1] # Second character is the version
36 |
37 | if version not in ['c', 'd']:
38 | raise ValueError(f"Unsupported SP3 version: {version}")
39 |
40 | return version
41 |
42 |
43 | def parse_sp3_datetime(year: int, month: int, day: int, hour: int, minute: int, second: float) -> datetime:
44 | """
45 | Parse SP3 date and time components into a datetime object.
46 |
47 | Parameters
48 | ----------
49 | year : int
50 | Year.
51 | month : int
52 | Month.
53 | day : int
54 | Day.
55 | hour : int
56 | Hour.
57 | minute : int
58 | Minute.
59 | second : float
60 | Second (can include fractional part).
61 |
62 | Returns
63 | -------
64 | datetime
65 | The parsed datetime object.
66 | """
67 | int_second = int(second)
68 | microsecond = int((second - int_second) * 1_000_000)
69 |
70 | return datetime(year, month, day, hour, minute, int_second, microsecond)
71 |
72 |
73 | def compute_mjd(dt: datetime) -> Tuple[int, float]:
74 | """
75 | Compute Modified Julian Date and fractional day from a datetime.
76 |
77 | Parameters
78 | ----------
79 | dt : datetime
80 | The datetime object.
81 |
82 | Returns
83 | -------
84 | Tuple[int, float]
85 | The MJD and fractional day.
86 | """
87 | # Julian date at January 1, 4713 BCE at noon
88 | jd = (dt.toordinal() + 1721425.5 +
89 | (dt.hour - 12) / 24.0 +
90 | dt.minute / 1440.0 +
91 | dt.second / 86400.0 +
92 | dt.microsecond / 86400000000.0)
93 |
94 | # Modified Julian Date (MJD = JD - 2400000.5)
95 | mjd = jd - 2400000.5
96 |
97 | # Extract integer and fractional parts
98 | mjd_int = int(mjd)
99 | mjd_frac = mjd - mjd_int
100 |
101 | return mjd_int, mjd_frac
102 |
103 |
104 | def gps_time_from_datetime(dt: datetime) -> Tuple[int, float]:
105 | """
106 | Convert datetime to GPS week and seconds of week.
107 |
108 | Parameters
109 | ----------
110 | dt : datetime
111 | The datetime object.
112 |
113 | Returns
114 | -------
115 | Tuple[int, float]
116 | The GPS week and seconds of week.
117 | """
118 | # GPS time epoch: January 6, 1980 00:00:00 UTC
119 | gps_epoch = datetime(1980, 1, 6)
120 |
121 | # Calculate time difference
122 | delta = dt - gps_epoch
123 |
124 | # Calculate GPS week
125 | gps_week = delta.days // 7
126 |
127 | # Calculate seconds of week
128 | seconds_of_week = (delta.days % 7) * 86400 + delta.seconds + delta.microseconds / 1_000_000
129 |
130 | return gps_week, seconds_of_week
131 |
132 |
133 | def datetime_from_gps_time(week: int, sow: float) -> datetime:
134 | """
135 | Convert GPS week and seconds of week to datetime.
136 |
137 | Parameters
138 | ----------
139 | week : int
140 | GPS week number.
141 | sow : float
142 | Seconds of week.
143 |
144 | Returns
145 | -------
146 | datetime
147 | The corresponding datetime object.
148 | """
149 | # GPS time epoch: January 6, 1980 00:00:00 UTC
150 | gps_epoch = datetime(1980, 1, 6)
151 |
152 | # Calculate time from GPS epoch
153 | delta = timedelta(weeks=week, seconds=sow)
154 |
155 | return gps_epoch + delta
156 |
157 |
158 | def parse_satellite_id(sat_id: str) -> Tuple[str, int]:
159 | """
160 | Parse a satellite ID into system and number.
161 |
162 | Parameters
163 | ----------
164 | sat_id : str
165 | The satellite ID (e.g., 'G01').
166 |
167 | Returns
168 | -------
169 | Tuple[str, int]
170 | The satellite system and PRN/slot number.
171 | """
172 | if len(sat_id) < 2:
173 | raise ValueError(f"Invalid satellite ID: {sat_id}")
174 |
175 | system = sat_id[0]
176 | number = int(sat_id[1:])
177 |
178 | return system, number
179 |
180 |
181 | def format_satellite_id(system: str, number: int) -> str:
182 | """
183 | Format a satellite system and number into a satellite ID.
184 |
185 | Parameters
186 | ----------
187 | system : str
188 | The satellite system (e.g., 'G').
189 | number : int
190 | The PRN/slot number.
191 |
192 | Returns
193 | -------
194 | str
195 | The formatted satellite ID (e.g., 'G01').
196 | """
197 | return f"{system}{number:02d}"
--------------------------------------------------------------------------------
/src/pygnsslab/io/sp3/writer.py:
--------------------------------------------------------------------------------
1 | """
2 | SP3 file writer module.
3 |
4 | This module provides functionality to write SP3 format files,
5 | supporting both SP3-c and SP3-d formats.
6 | """
7 |
8 | from datetime import datetime
9 | from pathlib import Path
10 | from typing import Dict, List, Optional, TextIO, Union
11 |
12 | from .metadata import SP3Epoch, SP3Header, SP3SatelliteRecord
13 | from .utils import compute_mjd, gps_time_from_datetime
14 |
15 |
16 | class SP3Writer:
17 | """
18 | Writer for SP3 (Standard Product 3) precise orbit files.
19 |
20 | This class can write both SP3-c and SP3-d format files.
21 | """
22 |
23 | def __init__(self, header: SP3Header, epochs: List[SP3Epoch]):
24 | """
25 | Initialize the SP3 writer.
26 |
27 | Parameters
28 | ----------
29 | header : SP3Header
30 | Header information for the SP3 file.
31 | epochs : List[SP3Epoch]
32 | List of epochs with satellite data.
33 | """
34 | self.header = header
35 | self.epochs = epochs
36 |
37 | def write(self, file_path: Union[str, Path]):
38 | """
39 | Write SP3 data to a file.
40 |
41 | Parameters
42 | ----------
43 | file_path : Union[str, Path]
44 | Path to the output file.
45 | """
46 | with open(file_path, 'w') as file:
47 | self._write_header(file)
48 | self._write_epochs(file)
49 | file.write("**EOF **\n")
50 |
51 | def _write_header(self, file: TextIO):
52 | """
53 | Write the SP3 header to the file.
54 |
55 | Parameters
56 | ----------
57 | file : TextIO
58 | Open file handle for writing.
59 | """
60 | h = self.header
61 | dt = h.time
62 |
63 | # First line
64 | file.write(f"#{h.version}{h.file_type}{dt.year:4d} {dt.month:2d} {dt.day:2d} "
65 | f"{dt.hour:2d} {dt.minute:2d} {dt.second:11.8f} "
66 | f"{h.epoch_count:6d} {h.data_used:5s}{h.coordinate_system:5s}"
67 | f"{h.orbit_type:3s} {h.agency:4s}{h.num_satellites:2d}\n")
68 |
69 | # Second line - if GPS week/SOW not provided, calculate them
70 | if h.gps_week == 0 and h.seconds_of_week == 0:
71 | gps_week, sow = gps_time_from_datetime(dt)
72 | else:
73 | gps_week, sow = h.gps_week, h.seconds_of_week
74 |
75 | # If MJD not provided, calculate it
76 | if h.mjd == 0 and h.fractional_day == 0:
77 | mjd, frac = compute_mjd(dt)
78 | else:
79 | mjd, frac = h.mjd, h.fractional_day
80 |
81 | file.write(f"##{gps_week:4d} {sow:15.8f} {h.epoch_interval:14.8f}"
82 | f"{mjd:5d}{frac:15.13f}\n")
83 |
84 | # Satellite IDs
85 | for i in range(0, len(h.satellite_ids), 17):
86 | sat_block = h.satellite_ids[i:i+17]
87 | line = "+"
88 | for sat in sat_block:
89 | line += f" {sat:3s}"
90 | line += " " * (3 * (17 - len(sat_block)) + 1) # Pad to fixed width
91 | file.write(f"{line}\n")
92 |
93 | # Satellite accuracy
94 | for i in range(0, len(h.satellite_ids), 17):
95 | sat_block = h.satellite_ids[i:i+17]
96 | line = "++"
97 | for sat in sat_block:
98 | acc = h.satellite_accuracy.get(sat, 0)
99 | line += f" {acc:2d}"
100 | line += " " * (3 * (17 - len(sat_block)) + 1) # Pad to fixed width
101 | file.write(f"{line}\n")
102 |
103 | # File and time system
104 | file.write(f"%c {h.file_system} cc{h.time_system}ccc cccc cccc cccc cccc "
105 | f"ccccc ccccc ccccc ccccc\n")
106 |
107 | # Additional fixed format lines for the header
108 | file.write("%c cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n")
109 | file.write("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n")
110 | file.write("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n")
111 | file.write("%i 0 0 0 0 0 0 0 0 0\n")
112 | file.write("%i 0 0 0 0 0 0 0 0 0\n")
113 |
114 | # Write comments
115 | for comment in h.comments:
116 | file.write(f"/* {comment}\n")
117 |
118 | # End of header marker
119 | file.write("*\n")
120 |
121 | def _write_epochs(self, file: TextIO):
122 | """
123 | Write epoch data to the file.
124 |
125 | Parameters
126 | ----------
127 | file : TextIO
128 | Open file handle for writing.
129 | """
130 | for epoch in self.epochs:
131 | dt = epoch.time
132 |
133 | # Write epoch line
134 | file.write(f"* {dt.year:4d} {dt.month:2d} {dt.day:2d} "
135 | f"{dt.hour:2d} {dt.minute:2d} {dt.second:11.8f}\n")
136 |
137 | # Write satellite positions
138 | for sat_id, sat in sorted(epoch.satellites.items()):
139 | self._write_satellite_record(file, sat)
140 |
141 | def _write_satellite_record(self, file: TextIO, sat: SP3SatelliteRecord):
142 | """
143 | Write a satellite record to the file.
144 |
145 | Parameters
146 | ----------
147 | file : TextIO
148 | Open file handle for writing.
149 | sat : SP3SatelliteRecord
150 | The satellite record to write.
151 | """
152 | # Position record
153 | line = f"P{sat.sat_id:3s} {sat.x:14.6f} {sat.y:14.6f} {sat.z:14.6f} {sat.clock:14.6f}"
154 |
155 | # Add event flags if present
156 | if sat.event_flag is not None:
157 | line += f" {sat.event_flag:1d}"
158 | if sat.clock_event_flag is not None:
159 | line += f" {sat.clock_event_flag:1d}"
160 |
161 | file.write(f"{line}\n")
162 |
163 | # Position standard deviations if available
164 | if sat.x_sdev is not None and sat.y_sdev is not None and sat.z_sdev is not None and sat.clock_sdev is not None:
165 | file.write(f"EP{sat.sat_id:3s} {sat.x_sdev:5.0f} {sat.y_sdev:5.0f} "
166 | f"{sat.z_sdev:5.0f} {sat.clock_sdev:5.0f}\n")
167 |
168 | # Velocity record if available and if file type is 'V'
169 | if self.header.file_type == 'V' and sat.x_vel is not None and sat.y_vel is not None and sat.z_vel is not None:
170 | file.write(f"V{sat.sat_id:3s} {sat.x_vel:14.6f} {sat.y_vel:14.6f} "
171 | f"{sat.z_vel:14.6f} {sat.clock_rate or 0.0:14.6f}\n")
172 |
173 | # Velocity standard deviations if available
174 | if (sat.x_vel_sdev is not None and sat.y_vel_sdev is not None
175 | and sat.z_vel_sdev is not None and sat.clock_rate_sdev is not None):
176 | file.write(f"EV{sat.sat_id:3s} {sat.x_vel_sdev:5.0f} {sat.y_vel_sdev:5.0f} "
177 | f"{sat.z_vel_sdev:5.0f} {sat.clock_rate_sdev:5.0f}\n")
178 |
179 |
180 | def write_sp3_file(file_path: Union[str, Path], header: SP3Header, epochs: List[SP3Epoch]):
181 | """
182 | Write SP3 data to a file.
183 |
184 | Parameters
185 | ----------
186 | file_path : Union[str, Path]
187 | Path to the output file.
188 | header : SP3Header
189 | Header information for the SP3 file.
190 | epochs : List[SP3Epoch]
191 | List of epochs with satellite data.
192 | """
193 | writer = SP3Writer(header, epochs)
194 | writer.write(file_path)
--------------------------------------------------------------------------------
/tests/rinex2/test_output.py:
--------------------------------------------------------------------------------
1 | import json
2 | import pandas as pd
3 | from pathlib import Path
4 |
5 | def test_read_rinex2_files():
6 | # Define the base directory and file paths
7 | base_dir = Path(__file__).parent.parent.parent
8 | json_file = base_dir / "data" / "jsonpqt" / "ajac0010_metadata.json"
9 | parquet_file = base_dir / "data" / "jsonpqt" / "ajac0010_observations.parquet"
10 |
11 | # Read and print JSON metadata
12 | print("\n=== Reading JSON Metadata ===")
13 | with open(json_file, 'r') as f:
14 | metadata = json.load(f)
15 | print(json.dumps(metadata, indent=2))
16 |
17 | # Read and print Parquet observations
18 | print("\n=== Reading Parquet Observations ===")
19 | df = pd.read_parquet(parquet_file)
20 | print("\nDataFrame Info:")
21 | print(df.info())
22 | print("\nFirst few rows of the DataFrame:")
23 | print(df)
24 |
25 | if __name__ == "__main__":
26 | test_read_rinex2_files()
27 |
--------------------------------------------------------------------------------
/tests/rinexnav/test_rinexnav.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "markdown",
5 | "metadata": {},
6 | "source": [
7 | "## Test for version 2 navigation file"
8 | ]
9 | },
10 | {
11 | "cell_type": "code",
12 | "execution_count": 6,
13 | "metadata": {},
14 | "outputs": [
15 | {
16 | "name": "stdout",
17 | "output_type": "stream",
18 | "text": [
19 | "\n",
20 | " PRN Epoch Toc SV_clock_bias SV_clock_drift \\\n",
21 | "0 1 2002-01-01 00:00:00 2002-01-01 00:00:00 0.000469 1.000444e-11 \n",
22 | "1 1 2022-01-01 02:00:00 2022-01-01 02:00:00 0.000469 -1.000444e-11 \n",
23 | "2 1 2022-01-01 04:00:00 2022-01-01 04:00:00 0.000469 -1.000444e-11 \n",
24 | "3 1 2022-01-01 06:00:00 2022-01-01 06:00:00 0.000469 -1.000444e-11 \n",
25 | "4 1 2022-01-01 08:00:00 2022-01-01 08:00:00 0.000469 -1.000444e-11 \n",
26 | "\n",
27 | " SV_clock_drift_rate IODE Crs Delta_n M0 ... \\\n",
28 | "0 0.0 39.0 -141.12500 3.988380e-09 -0.624294 ... \n",
29 | "1 0.0 70.0 -137.78125 4.009810e-09 0.425960 ... \n",
30 | "2 0.0 71.0 -146.31250 4.069812e-09 1.476137 ... \n",
31 | "3 0.0 85.0 -162.96875 3.931592e-09 2.526152 ... \n",
32 | "4 0.0 89.0 -168.37500 3.989095e-09 -2.706783 ... \n",
33 | "\n",
34 | " IDOT L2_codes GPS_week L2_P_flag SV_accuracy SV_health \\\n",
35 | "0 -3.778729e-10 1.0 2190.0 0.0 2.0 0.0 \n",
36 | "1 -3.832303e-10 1.0 2190.0 0.0 2.0 0.0 \n",
37 | "2 -3.578720e-10 1.0 2190.0 0.0 2.0 0.0 \n",
38 | "3 -3.042984e-10 1.0 2190.0 0.0 2.0 0.0 \n",
39 | "4 -2.375099e-10 1.0 2190.0 0.0 2.0 0.0 \n",
40 | "\n",
41 | " TGD IODC Transmission_time Fit_interval \n",
42 | "0 5.122274e-09 39.0 518400.0 NaN \n",
43 | "1 5.122274e-09 70.0 518430.0 NaN \n",
44 | "2 5.122274e-09 71.0 525630.0 NaN \n",
45 | "3 5.122274e-09 85.0 532830.0 NaN \n",
46 | "4 5.122274e-09 89.0 540030.0 NaN \n",
47 | "\n",
48 | "[5 rows x 32 columns]\n",
49 | "Index(['PRN', 'Epoch', 'Toc', 'SV_clock_bias', 'SV_clock_drift',\n",
50 | " 'SV_clock_drift_rate', 'IODE', 'Crs', 'Delta_n', 'M0', 'Cuc', 'e',\n",
51 | " 'Cus', 'sqrt_A', 'Toe', 'Cic', 'OMEGA', 'Cis', 'i0', 'Crc', 'omega',\n",
52 | " 'OMEGA_DOT', 'IDOT', 'L2_codes', 'GPS_week', 'L2_P_flag', 'SV_accuracy',\n",
53 | " 'SV_health', 'TGD', 'IODC', 'Transmission_time', 'Fit_interval'],\n",
54 | " dtype='object')\n",
55 | "1.00044417195e-11\n",
56 | "\n"
57 | ]
58 | }
59 | ],
60 | "source": [
61 | "import os\n",
62 | "import sys\n",
63 | "import tempfile\n",
64 | "import pandas as pd\n",
65 | "from datetime import datetime, timedelta\n",
66 | "from pathlib import Path\n",
67 | "\n",
68 | "from pygnsslab.io.rinexnav.reader import read_rinex_nav\n",
69 | "from pygnsslab.io.rinexnav.utils import (\n",
70 | " interpolate_orbit,\n",
71 | " compute_sv_clock_correction,\n",
72 | " detect_rinex_version\n",
73 | ")\n",
74 | "\n",
75 | "# file location\n",
76 | "# Define the base directory and file paths\n",
77 | "base_dir = Path.cwd().parent.parent\n",
78 | "file_location = base_dir / \"data\" / \"nav\" / \"ajac0010.22n\"\n",
79 | "\n",
80 | "# read the file (this is a dict of pandas DataFrames)\n",
81 | "nav_data = read_rinex_nav(file_location).to_dataframe()\n",
82 | "\n",
83 | "# print the type of the data\n",
84 | "print(type(nav_data))\n",
85 | "\n",
86 | "# get GPS data\n",
87 | "gps_data = nav_data['G']\n",
88 | "\n",
89 | "# print the first few rows of the data\n",
90 | "print(gps_data.head())\n",
91 | "\n",
92 | "# print the column names\n",
93 | "print(gps_data.columns)\n",
94 | "\n",
95 | "# print a specific SV_clock_drift value (e.g. the first row)\n",
96 | "print(gps_data['SV_clock_drift'].iloc[0])\n",
97 | "\n",
98 | "# print the type of the SV_clock_drift value\n",
99 | "print(type(gps_data['SV_clock_drift'].iloc[0]))\n"
100 | ]
101 | },
102 | {
103 | "cell_type": "markdown",
104 | "metadata": {},
105 | "source": [
106 | "## Test for version 3 navigation file"
107 | ]
108 | },
109 | {
110 | "cell_type": "code",
111 | "execution_count": 7,
112 | "metadata": {},
113 | "outputs": [
114 | {
115 | "name": "stdout",
116 | "output_type": "stream",
117 | "text": [
118 | "\n",
119 | " PRN Epoch Toc SV_clock_bias SV_clock_drift \\\n",
120 | "0 31 2022-01-01 2022-01-01 -0.000158 -1.818989e-12 \n",
121 | "1 11 2022-01-01 2022-01-01 -0.000001 -3.387868e-11 \n",
122 | "2 12 2022-01-01 2022-01-01 -0.000149 -5.570655e-12 \n",
123 | "3 24 2022-01-01 2022-01-01 0.000277 7.958079e-13 \n",
124 | "4 18 2022-01-01 2022-01-01 0.000269 -7.048584e-12 \n",
125 | "\n",
126 | " SV_clock_drift_rate IODE Crs Delta_n M0 ... \\\n",
127 | "0 0.0 11.0 -17.09375 4.802343e-09 -1.730985 ... \n",
128 | "1 0.0 187.0 -167.34375 4.416613e-09 1.617921 ... \n",
129 | "2 0.0 176.0 171.09375 4.090528e-09 -1.949393 ... \n",
130 | "3 0.0 69.0 -10.96875 5.471299e-09 -0.311958 ... \n",
131 | "4 0.0 100.0 -162.00000 4.190532e-09 -0.429474 ... \n",
132 | "\n",
133 | " IDOT L2_codes GPS_week L2_P_flag SV_accuracy SV_health \\\n",
134 | "0 -4.960921e-10 1.0 2190.0 0.0 2.0 0.0 \n",
135 | "1 -3.496574e-10 1.0 2190.0 0.0 2.0 63.0 \n",
136 | "2 7.286018e-11 1.0 2190.0 0.0 2.8 0.0 \n",
137 | "3 -6.114540e-10 1.0 2190.0 0.0 2.0 0.0 \n",
138 | "4 -4.000167e-10 1.0 2190.0 0.0 2.0 0.0 \n",
139 | "\n",
140 | " TGD IODC Transmission_time Fit_interval \n",
141 | "0 -1.303852e-08 11.0 516102.0 4.0 \n",
142 | "1 -8.847564e-09 443.0 511218.0 4.0 \n",
143 | "2 -1.257285e-08 176.0 511218.0 4.0 \n",
144 | "3 2.328306e-09 69.0 511218.0 4.0 \n",
145 | "4 -8.381903e-09 868.0 518298.0 4.0 \n",
146 | "\n",
147 | "[5 rows x 32 columns]\n",
148 | "Index(['PRN', 'Epoch', 'Toc', 'SV_clock_bias', 'SV_clock_drift',\n",
149 | " 'SV_clock_drift_rate', 'IODE', 'Crs', 'Delta_n', 'M0', 'Cuc', 'e',\n",
150 | " 'Cus', 'sqrt_A', 'Toe', 'Cic', 'OMEGA', 'Cis', 'i0', 'Crc', 'omega',\n",
151 | " 'OMEGA_DOT', 'IDOT', 'L2_codes', 'GPS_week', 'L2_P_flag', 'SV_accuracy',\n",
152 | " 'SV_health', 'TGD', 'IODC', 'Transmission_time', 'Fit_interval'],\n",
153 | " dtype='object')\n",
154 | "-1.81898940355e-12\n",
155 | "\n"
156 | ]
157 | }
158 | ],
159 | "source": [
160 | "import os\n",
161 | "import sys\n",
162 | "import tempfile\n",
163 | "import pandas as pd\n",
164 | "from datetime import datetime, timedelta\n",
165 | "from pathlib import Path\n",
166 | "\n",
167 | "from pygnsslab.io.rinexnav.reader import read_rinex_nav\n",
168 | "from pygnsslab.io.rinexnav.utils import (\n",
169 | " interpolate_orbit,\n",
170 | " compute_sv_clock_correction,\n",
171 | " detect_rinex_version\n",
172 | ")\n",
173 | "\n",
174 | "# file location\n",
175 | "# Define the base directory and file paths\n",
176 | "base_dir = Path.cwd().parent.parent\n",
177 | "file_location = base_dir / \"data\" / \"nav\" / \"PTLD00AUS_R_20220010000_01D_30S_MN.rnx\"\n",
178 | "\n",
179 | "# read the file (this is a dict of pandas DataFrames)\n",
180 | "nav_data = read_rinex_nav(file_location).to_dataframe()\n",
181 | "\n",
182 | "# print the type of the data\n",
183 | "print(type(nav_data))\n",
184 | "\n",
185 | "# get GPS data\n",
186 | "gps_data = nav_data['G']\n",
187 | "\n",
188 | "# print the first few rows of the data\n",
189 | "print(gps_data.head())\n",
190 | "\n",
191 | "# print the column names\n",
192 | "print(gps_data.columns)\n",
193 | "\n",
194 | "# print a specific SV_clock_drift value (e.g. the first row)\n",
195 | "print(gps_data['SV_clock_drift'].iloc[0])\n",
196 | "\n",
197 | "# print the type of the SV_clock_drift value\n",
198 | "print(type(gps_data['SV_clock_drift'].iloc[0]))\n"
199 | ]
200 | }
201 | ],
202 | "metadata": {
203 | "kernelspec": {
204 | "display_name": ".venv",
205 | "language": "python",
206 | "name": "python3"
207 | },
208 | "language_info": {
209 | "codemirror_mode": {
210 | "name": "ipython",
211 | "version": 3
212 | },
213 | "file_extension": ".py",
214 | "mimetype": "text/x-python",
215 | "name": "python",
216 | "nbconvert_exporter": "python",
217 | "pygments_lexer": "ipython3",
218 | "version": "3.12.0"
219 | }
220 | },
221 | "nbformat": 4,
222 | "nbformat_minor": 2
223 | }
224 |
--------------------------------------------------------------------------------
/tests/rinexnav/test_rinexnav.py:
--------------------------------------------------------------------------------
1 | """
2 | Test script for the RINEX Navigation reader module.
3 |
4 | This script tests the RINEX Navigation reader with sample files to verify functionality.
5 | """
6 |
7 | import os
8 | import sys
9 | import tempfile
10 | import pandas as pd
11 | from datetime import datetime, timedelta
12 |
13 | # Add the parent directory to the path to import the pygnsslab module
14 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..')))
15 |
16 | from pygnsslab.io.rinexnav.reader import read_rinex_nav
17 | from pygnsslab.io.rinexnav.utils import (
18 | interpolate_orbit,
19 | compute_sv_clock_correction,
20 | detect_rinex_version
21 | )
22 |
23 | def write_sample_file(content, file_extension=".txt"):
24 | """
25 | Write sample content to a temporary file.
26 |
27 | Parameters
28 | ----------
29 | content : str
30 | Content to write to the file.
31 | file_extension : str, optional
32 | File extension to use for the temporary file (default: ".txt").
33 |
34 | Returns
35 | -------
36 | str
37 | Path to the temporary file.
38 | """
39 | with tempfile.NamedTemporaryFile(suffix=file_extension, delete=False, mode="w") as f:
40 | f.write(content)
41 | temp_path = f.name
42 |
43 | return temp_path
44 |
45 | def test_rinex2_reader():
46 | """Test the RINEX 2 Navigation reader with sample data."""
47 | print("Starting RINEX 2 Navigation reader test...")
48 | # Sample RINEX 2 Navigation data
49 | rinex2_content = """ 2.11 N: GPS NAV DATA RINEX VERSION / TYPE
50 | Converto v3.5.6 IGN 20220102 000706 UTC PGM / RUN BY / DATE
51 | Linux 2.6.32-573.12.1.x86_64|x86_64|gcc|Linux 64|=+ COMMENT
52 | END OF HEADER
53 | 1 02 1 1 0 0 0.0 4.691267386079D-04-1.000444171950D-11 0.000000000000D+00
54 | 3.900000000000D+01-1.411250000000D+02 3.988380292697D-09-6.242942382352D-01
55 | -7.363036274910D-06 1.121813920327D-02 4.695728421211D-06 5.153674995422D+03
56 | 5.184000000000D+05-3.166496753693D-08-1.036611240093D+00 1.955777406693D-07
57 | 9.864187694897D-01 2.997500000000D+02 8.840876015687D-01-8.133553386358D-09
58 | -3.778728718817D-10 1.000000000000D+00 2.190000000000D+03 0.000000000000D+00
59 | 2.000000000000D+00 0.000000000000D+00 5.122274160385D-09 3.900000000000D+01
60 | 5.184000000000D+05
61 | 1 02 1 1 2 0 0.0 4.690550267696D-04-1.000444171950D-11 0.000000000000D+00
62 | 7.000000000000D+01-1.377812500000D+02 4.009809817518D-09 4.259599915377D-01
63 | -7.105991244316D-06 1.121853594668D-02 4.127621650696D-06 5.153675922394D+03
64 | 5.256000000000D+05-8.381903171539D-08-1.036670036234D+00 1.229345798492D-07
65 | 9.864159884824D-01 3.122812500000D+02 8.840083845547D-01-8.161768150217D-09
66 | -3.832302530871D-10 1.000000000000D+00 2.190000000000D+03 0.000000000000D+00
67 | 2.000000000000D+00 0.000000000000D+00 5.122274160385D-09 7.000000000000D+01
68 | 5.184300000000D+05"""
69 |
70 | print("Writing sample RINEX 2 content to temporary file...")
71 | # Write to a temporary file
72 | rinex2_file = write_sample_file(rinex2_content, ".n")
73 |
74 | try:
75 | # Test version detection
76 | version = detect_rinex_version(rinex2_file)
77 | print(f"Detected RINEX version: {version}")
78 | assert 2.0 <= version < 3.0, f"Expected RINEX 2.x, got {version}"
79 |
80 | # Test reading the file
81 | print("Reading RINEX 2 navigation file...")
82 | nav_reader = read_rinex_nav(rinex2_file)
83 |
84 | # Check if header was parsed correctly
85 | print(f"Header version: {nav_reader.header['version']}")
86 | print(f"Header file_type: '{nav_reader.header['file_type']}'")
87 |
88 | assert nav_reader.header['version'] == 2.11, "Incorrect version in header"
89 | assert nav_reader.header['file_type'] == 'N', "Incorrect file type in header"
90 | assert 'program' in nav_reader.header, "Missing program in header"
91 |
92 | # Check if data was parsed correctly
93 | assert 'G' in nav_reader.data, "Missing GPS data"
94 | gps_data = nav_reader.data['G']
95 | assert len(gps_data) == 2, f"Expected 2 records, got {len(gps_data)}"
96 |
97 | # Check the first record
98 | first_record = gps_data.iloc[0]
99 | assert first_record['PRN'] == 1, f"Expected PRN 1, got {first_record['PRN']}"
100 | assert first_record['Epoch'].year == 2002, f"Expected year 2002, got {first_record['Epoch'].year}"
101 | assert abs(first_record['SV_clock_bias'] - 4.691267386079e-04) < 1e-12, "Incorrect SV clock bias"
102 |
103 | # Test orbit interpolation
104 | test_time = first_record['Epoch'] + timedelta(hours=1)
105 | try:
106 | orbit = interpolate_orbit(gps_data, test_time, 'G', 1)
107 | print("Interpolated orbit:", orbit)
108 | assert 'X' in orbit and 'Y' in orbit and 'Z' in orbit, "Missing position components"
109 | assert 'VX' in orbit and 'VY' in orbit and 'VZ' in orbit, "Missing velocity components"
110 | except Exception as e:
111 | print(f"Warning: Orbit interpolation failed: {e}")
112 |
113 | # Test clock correction
114 | try:
115 | clock_corr = compute_sv_clock_correction(gps_data, test_time, 'G', 1)
116 | print(f"Clock correction: {clock_corr} seconds")
117 | except Exception as e:
118 | print(f"Warning: Clock correction failed: {e}")
119 |
120 | print("RINEX 2 Navigation reader test: PASSED")
121 |
122 | except Exception as e:
123 | print(f"RINEX 2 Navigation reader test: FAILED - {e}")
124 |
125 | finally:
126 | # Clean up
127 | if os.path.exists(rinex2_file):
128 | os.unlink(rinex2_file)
129 |
130 | def test_rinex3_reader():
131 | """Test the RINEX 3 Navigation reader with sample data."""
132 | print("Starting RINEX 3 Navigation reader test...")
133 | # Sample RINEX 3 Navigation data
134 | rinex3_content = """ 3.04 N: GNSS NAV DATA M: MIXED NAV DATA RINEX VERSION / TYPE
135 | Alloy 5.44 Receiver Operator 20220101 000000 UTC PGM / RUN BY / DATE
136 | GPSA .1211D-07 -.7451D-08 -.5960D-07 .1192D-06 IONOSPHERIC CORR
137 | GPSB .1167D+06 -.2458D+06 -.6554D+05 .1114D+07 IONOSPHERIC CORR
138 | GAL .8775D+02 .4180D+00 -.1074D-01 .0000D+00 IONOSPHERIC CORR
139 | QZSA .1118D-07 -.4470D-07 -.4172D-06 -.4768D-06 IONOSPHERIC CORR
140 | QZSB .9830D+05 -.3277D+05 -.1507D+07 -.8192D+07 IONOSPHERIC CORR
141 | GPUT -.1862645149D-08 -.621724894D-14 61440 2191 TIME SYSTEM CORR
142 | GAUT .1862645149D-08 -.888178420D-15 432000 2190 TIME SYSTEM CORR
143 | QZUT .2793967724D-08 .000000000D+00 94208 2191 TIME SYSTEM CORR
144 | GPGA .1979060471D-08 -.976996262D-14 518400 2190 TIME SYSTEM CORR
145 | 18 18 2185 7 LEAP SECONDS
146 | END OF HEADER
147 | G31 2022 01 01 00 00 00 -.157775823027D-03 -.181898940355D-11 .000000000000D+00
148 | .110000000000D+02 -.170937500000D+02 .480234289400D-08 -.173098544978D+01
149 | -.117905437946D-05 .104654430179D-01 .877678394318D-05 .515366246605D+04
150 | .518400000000D+06 .204890966415D-07 .211722260728D+01 -.141561031342D-06
151 | .954983160093D+00 .203406250000D+03 .359636360687D+00 -.795676000242D-08
152 | -.496092092798D-09 .100000000000D+01 .219000000000D+04 .000000000000D+00
153 | .200000000000D+01 .000000000000D+00 -.130385160446D-07 .110000000000D+02
154 | .516102000000D+06 .400000000000D+01
155 | C01 2021 12 31 23 00 00 -.285546178930D-03 .402868849392D-10 .000000000000D+00
156 | .100000000000D+01 .250171875000D+03 -.412517182996D-09 .329616550241D+00
157 | .806059688330D-05 .611470197327D-03 .322125852108D-04 .649341227531D+04
158 | .514800000000D+06 -.211410224438D-06 .302754416285D+01 -.167638063431D-06
159 | .794697272388D-01 -.992296875000D+03 -.992867610818D+00 .158042297382D-08
160 | -.118362073113D-08 .000000000000D+00 .834000000000D+03 .000000000000D+00
161 | .200000000000D+01 .000000000000D+00 -.580000003580D-08 -.102000000000D-07
162 | .514800000000D+06 .000000000000D+00
163 | R05 2021 12 31 23 45 00 .851778313518D-04 .909494701773D-12 .518370000000D+06
164 | -.215384257812D+05 -.146265029907D-01 -.279396772385D-08 .000000000000D+00
165 | -.136201572266D+05 .206674575806D+00 .279396772385D-08 .100000000000D+01
166 | -.692044921875D+03 -.358163070679D+01 -.186264514923D-08 .000000000000D+00"""
167 |
168 | print("Writing sample RINEX 3 content to temporary file...")
169 | # Write to a temporary file
170 | rinex3_file = write_sample_file(rinex3_content, ".nav")
171 |
172 | try:
173 | # Test version detection
174 | version = detect_rinex_version(rinex3_file)
175 | print(f"Detected RINEX version: {version}")
176 | assert 3.0 <= version < 4.0, f"Expected RINEX 3.x, got {version}"
177 |
178 | # Test reading the file
179 | print("Reading RINEX 3 navigation file...")
180 | nav_reader = read_rinex_nav(rinex3_file)
181 |
182 | # Check if header was parsed correctly
183 | print(f"Header file_type: '{nav_reader.header['file_type']}'")
184 | assert nav_reader.header['version'] == 3.04, "Incorrect version in header"
185 | assert nav_reader.header['file_type'] == 'N', "Incorrect file type in header"
186 | assert 'program' in nav_reader.header, "Missing program in header"
187 | assert 'ionospheric_corr' in nav_reader.header, "Missing ionospheric correction in header"
188 | assert 'time_system_corr' in nav_reader.header, "Missing time system correction in header"
189 |
190 | # Check if data was parsed correctly
191 | print(f"Systems in data: {list(nav_reader.data.keys())}")
192 | assert 'G' in nav_reader.data, "Missing GPS data"
193 | assert 'C' in nav_reader.data, "Missing BeiDou data"
194 | assert 'R' in nav_reader.data, "Missing GLONASS data"
195 |
196 | # Check GPS data
197 | gps_data = nav_reader.data['G']
198 | assert len(gps_data) == 1, f"Expected 1 GPS record, got {len(gps_data)}"
199 |
200 | # Check BeiDou data
201 | bds_data = nav_reader.data['C']
202 | assert len(bds_data) == 1, f"Expected 1 BeiDou record, got {len(bds_data)}"
203 |
204 | # Check GLONASS data
205 | glo_data = nav_reader.data['R']
206 | assert len(glo_data) == 1, f"Expected 1 GLONASS record, got {len(glo_data)}"
207 |
208 | # Check GPS record
209 | gps_record = gps_data.iloc[0]
210 | assert gps_record['PRN'] == 31, f"Expected PRN 31, got {gps_record['PRN']}"
211 | assert gps_record['Epoch'].year == 2022, f"Expected year 2022, got {gps_record['Epoch'].year}"
212 | assert abs(gps_record['SV_clock_bias'] + 1.57775823027e-04) < 1e-12, "Incorrect SV clock bias"
213 |
214 | # Check BeiDou record
215 | bds_record = bds_data.iloc[0]
216 | assert bds_record['PRN'] == 1, f"Expected PRN 1, got {bds_record['PRN']}"
217 |
218 | # Check GLONASS record
219 | glo_record = glo_data.iloc[0]
220 | assert glo_record['PRN'] == 5, f"Expected PRN 5, got {glo_record['PRN']}"
221 |
222 | # Test orbit interpolation for GPS
223 | test_time = gps_record['Epoch'] + timedelta(hours=1)
224 | try:
225 | orbit = interpolate_orbit(gps_data, test_time, 'G', 31)
226 | print("Interpolated GPS orbit:", orbit)
227 | assert 'X' in orbit and 'Y' in orbit and 'Z' in orbit, "Missing position components"
228 | except Exception as e:
229 | print(f"Warning: GPS orbit interpolation failed: {e}")
230 |
231 | print("RINEX 3 Navigation reader test: PASSED")
232 |
233 | except Exception as e:
234 | print(f"RINEX 3 Navigation reader test: FAILED - {e}")
235 | import traceback
236 | traceback.print_exc()
237 |
238 | finally:
239 | # Clean up
240 | if os.path.exists(rinex3_file):
241 | os.unlink(rinex3_file)
242 |
243 | def main():
244 | """Run all tests."""
245 | print("Testing RINEX Navigation reader...")
246 | test_rinex2_reader()
247 | test_rinex3_reader()
248 | print("All tests completed.")
249 |
250 | if __name__ == "__main__":
251 | main()
--------------------------------------------------------------------------------
/tests/sp3/test_sp3.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "code",
5 | "execution_count": 15,
6 | "metadata": {},
7 | "outputs": [
8 | {
9 | "name": "stdout",
10 | "output_type": "stream",
11 | "text": [
12 | "\n",
13 | "\n",
14 | " 0 1 2 3 4\n",
15 | "0 2023-01-01 00:00:00 26000.000000 0.000000 0.000000 100.0\n",
16 | "1 2023-01-01 01:00:00 22516.660498 13000.000000 4330.127019 100.1\n",
17 | "2 2023-01-01 02:00:00 13000.000000 22516.660498 4330.127019 100.2\n",
18 | "3 2023-01-01 03:00:00 0.000000 26000.000000 0.000000 100.3\n",
19 | "4 2023-01-01 04:00:00 -13000.000000 22516.660498 -4330.127019 100.4\n",
20 | "5 2023-01-01 05:00:00 -22516.660498 13000.000000 -4330.127019 100.5\n",
21 | "6 2023-01-01 06:00:00 -26000.000000 0.000000 -0.000000 100.6\n",
22 | "7 2023-01-01 07:00:00 -22516.660498 -13000.000000 4330.127019 100.7\n",
23 | "8 2023-01-01 08:00:00 -13000.000000 -22516.660498 4330.127019 100.8\n",
24 | "9 2023-01-01 09:00:00 -0.000000 -26000.000000 0.000000 100.9\n",
25 | "10 2023-01-01 10:00:00 13000.000000 -22516.660498 -4330.127019 101.0\n",
26 | "11 2023-01-01 11:00:00 22516.660498 -13000.000000 -4330.127019 101.1\n",
27 | "12 2023-01-01 12:00:00 26000.000000 -0.000000 -0.000000 101.2\n",
28 | "13 2023-01-01 13:00:00 22516.660498 13000.000000 4330.127019 101.3\n",
29 | "14 2023-01-01 14:00:00 13000.000000 22516.660498 4330.127019 101.4\n",
30 | "15 2023-01-01 15:00:00 0.000000 26000.000000 0.000000 101.5\n",
31 | "16 2023-01-01 16:00:00 -13000.000000 22516.660498 -4330.127019 101.6\n",
32 | "17 2023-01-01 17:00:00 -22516.660498 13000.000000 -4330.127019 101.7\n",
33 | "18 2023-01-01 18:00:00 -26000.000000 0.000000 -0.000000 101.8\n",
34 | "19 2023-01-01 19:00:00 -22516.660498 -13000.000000 4330.127019 101.9\n",
35 | "20 2023-01-01 20:00:00 -13000.000000 -22516.660498 4330.127019 102.0\n",
36 | "21 2023-01-01 21:00:00 -0.000000 -26000.000000 0.000000 102.1\n",
37 | "22 2023-01-01 22:00:00 13000.000000 -22516.660498 -4330.127019 102.2\n",
38 | "23 2023-01-01 23:00:00 22516.660498 -13000.000000 -4330.127019 102.3\n"
39 | ]
40 | }
41 | ],
42 | "source": [
43 | "from datetime import datetime, timedelta\n",
44 | "import os\n",
45 | "import sys\n",
46 | "import pandas as pd\n",
47 | "import matplotlib.pyplot as plt\n",
48 | "import numpy as np\n",
49 | "from pathlib import Path\n",
50 | "\n",
51 | "from pygnsslab.io.sp3.reader import SP3Reader\n",
52 | "from pygnsslab.io.sp3.writer import write_sp3_file\n",
53 | "from pygnsslab.io.sp3.metadata import SP3Header, SP3Epoch, SP3SatelliteRecord\n",
54 | "\n",
55 | "# file location\n",
56 | "# Define the base directory and file paths\n",
57 | "base_dir = Path.cwd().parent.parent\n",
58 | "file_location = base_dir / \"data\" / \"sp3\" / \"COD0MGXFIN_20220010000_01D_05M_ORB.SP3\"\n",
59 | "\n",
60 | "# read the Sp3 file\n",
61 | "reader = SP3Reader(file_location)\n",
62 | "\n",
63 | "# print type of reader\n",
64 | "print(type(reader))\n",
65 | "\n",
66 | "# print the including data in it\n",
67 | "print(type(reader.get_satellite_positions(\"G01\")))\n",
68 | "\n",
69 | "# convert list to pandas dataframe\n",
70 | "df = pd.DataFrame(reader.get_satellite_positions(\"G01\"))\n",
71 | "\n",
72 | "# print the dataframe\n",
73 | "print(df)\n",
74 | "\n",
75 | "\n",
76 | "\n"
77 | ]
78 | }
79 | ],
80 | "metadata": {
81 | "kernelspec": {
82 | "display_name": ".venv",
83 | "language": "python",
84 | "name": "python3"
85 | },
86 | "language_info": {
87 | "codemirror_mode": {
88 | "name": "ipython",
89 | "version": 3
90 | },
91 | "file_extension": ".py",
92 | "mimetype": "text/x-python",
93 | "name": "python",
94 | "nbconvert_exporter": "python",
95 | "pygments_lexer": "ipython3",
96 | "version": "3.12.0"
97 | }
98 | },
99 | "nbformat": 4,
100 | "nbformat_minor": 2
101 | }
102 |
--------------------------------------------------------------------------------