├── .devcontainer ├── Dockerfile ├── devcontainer.json └── noop.txt ├── .github └── workflows │ └── book.yml ├── .gitignore ├── DEVELOPING.md ├── LICENSE ├── README.md ├── _config.yml ├── _toc.yml ├── assets ├── MEDEA.png ├── MTIC00ESP_R_20191221131_05H_01S_MO.rnx.gz ├── MTIC00ESP_R_20191221131_05H_01S_MO.rnx.parquet ├── SEYG00SYC_R_20140581500_05H_01S_MO.rnx.gz ├── SEYG00SYC_R_20140581500_05H_01S_MO.rnx.parquet ├── ZED-F9P.png ├── amic_antenna_inchang.png ├── amic_network.png ├── amic_receiver_medea.jpg ├── choke_ring_antenna.png ├── esa_gssc_data_portal.png ├── igs_network.png ├── juanetal2017_fig2b_roti_sey1.png ├── mosaic.jpg ├── sample.csv ├── talysmann_tw7972.jpg └── unicorecomm_UM982.png ├── docs ├── acknowledgements.md ├── amic.md ├── gnss_observables.md ├── low_cost_gnss.md ├── projects.md ├── references.md └── roti.md ├── logo.png ├── notebooks ├── low_cost_gnss.ipynb ├── pandas.ipynb └── roti.ipynb ├── references.bib ├── requirements.txt └── source ├── gnss ├── __init__.py ├── edit.py └── observables.py └── helpers.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/anaconda:0-3 2 | 3 | # Copy environment.yml (if found) to a temp location so we update the environment. Also 4 | # copy "noop.txt" so the COPY instruction does not fail if no environment.yml exists. 5 | COPY environment.yml* .devcontainer/noop.txt /tmp/conda-tmp/ 6 | RUN if [ -f "/tmp/conda-tmp/environment.yml" ]; then umask 0002 && /opt/conda/bin/conda env update -n base -f /tmp/conda-tmp/environment.yml; fi \ 7 | && rm -rf /tmp/conda-tmp 8 | 9 | # [Optional] Uncomment this section to install additional OS packages. 10 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 11 | # && apt-get -y install --no-install-recommends 12 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/anaconda 3 | { 4 | "name": "Anaconda (Python 3)", 5 | "build": { 6 | "context": "..", 7 | "dockerfile": "Dockerfile" 8 | }, 9 | "features": { 10 | "ghcr.io/devcontainers/features/python:1": {} 11 | }, 12 | "customizations": { 13 | "vscode": { 14 | "extensions": [ 15 | "ms-toolsai.jupyter", 16 | "ms-python.python" 17 | ] 18 | } 19 | } 20 | 21 | // Features to add to the dev container. More info: https://containers.dev/features. 22 | // "features": {}, 23 | 24 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 25 | // "forwardPorts": [], 26 | 27 | // Use 'postCreateCommand' to run commands after the container is created. 28 | // "postCreateCommand": "python --version", 29 | 30 | // Configure tool-specific properties. 31 | // "customizations": {}, 32 | 33 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 34 | // "remoteUser": "root" 35 | } 36 | -------------------------------------------------------------------------------- /.devcontainer/noop.txt: -------------------------------------------------------------------------------- 1 | This file copied into the container along with environment.yml* from the parent 2 | folder. This file is included to prevents the Dockerfile COPY instruction from 3 | failing if no environment.yml is found. -------------------------------------------------------------------------------- /.github/workflows/book.yml: -------------------------------------------------------------------------------- 1 | name: deploy-book 2 | 3 | # Only run this when the main branch changes 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | # This job installs dependencies, build the book, and pushes it to `gh-pages` 10 | jobs: 11 | deploy-book: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | # Install dependencies 17 | - name: Set up Python 3.11 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: 3.11 21 | 22 | - name: Install dependencies 23 | run: | 24 | pip install -r requirements.txt 25 | 26 | 27 | # Build the book 28 | - name: Build the book 29 | run: | 30 | jupyter-book build . 31 | 32 | # Push the book's HTML to github-pages 33 | - name: GitHub Pages action 34 | uses: peaceiris/actions-gh-pages@v3.9.3 35 | with: 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | publish_dir: ./_build/html 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | .cache 3 | .local 4 | .npm 5 | .ipython 6 | .jupyter 7 | 8 | *-checkpoint.* 9 | *.pyc -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | # Developing notes 2 | 3 | This section includes some hints and tips to develop the notebooks present in this 4 | repository 5 | 6 | If you'd like to fork the project and modify the Notebooks, you can do so by 7 | using a Jupyter Notebook Docker image 8 | 9 | ```bash 10 | docker run -v `pwd`:/home/jovyan -p 9999:8888 -ti jupyter/datascience-notebook 11 | ``` 12 | 13 | Open the browser at this [url](http://127.0.0.1:9999). 14 | 15 | You can open the notebooks and edit them from Jupyterhub. The changes will be 16 | persistent, so they will modify your local folders. 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rokubun 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 | # GNSS data processing with Python 2 | 3 | Welcome to the hands-on tutorials for GNSS data processing using Python and 4 | Jupyter Notebooks/book 5 | 6 | The tutorials are written using [Jupyter books](https://jupyterbook.org) but can be executed independently 7 | using tools such as Binder. 8 | 9 | To do so, click on the following badge: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/rokubun/gnss_tutorials/HEAD) 10 | 11 | Otherwise, keep reading 😉 12 | 13 | 💡 Do you want to see some specific content? Feel free to submit a 14 | [new issue in the Github repository of this book](https://github.com/rokubun/gnss_tutorials/issues/new) 15 | and make sure you specify **feature request** label for the issue. We will try 16 | to do our best to add it. 17 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | # Book settings 2 | # Learn more at https://jupyterbook.org/customize/config.html 3 | 4 | title: GNSS data processing 5 | author: Miquel Garcia 6 | logo: logo.png 7 | 8 | # Auto-exclude files not in the toc 9 | only_build_toc_files : true 10 | 11 | # Force re-execution of notebooks on each build. 12 | # See https://jupyterbook.org/content/execute.html 13 | execute: 14 | execute_notebooks: force 15 | 16 | # Define the name of the latex output file for PDF builds 17 | latex: 18 | latex_documents: 19 | targetname: book.tex 20 | 21 | # Add a bibtex file so that we can create citations 22 | bibtex_bibfiles: 23 | - references.bib 24 | 25 | launch_buttons: 26 | notebook_interface: "jupyterlab" 27 | binderhub_url: "https://mybinder.org" 28 | colab_url: "https://colab.research.google.com" 29 | 30 | # Information about where the book exists on the web 31 | repository: 32 | url: https://github.com/rokubun/gnss_tutorials 33 | branch: master # Which branch of the repository should be used when creating links (optional) 34 | 35 | # Add GitHub buttons to your book 36 | # See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository 37 | html: 38 | use_issues_button: true 39 | use_repository_button: true 40 | google_analytics_id: "G-5DKGDEKGFV" 41 | 42 | sphinx: 43 | config: 44 | bibtex_reference_style: author_year 45 | extra_extensions: 46 | - sphinx_proof 47 | -------------------------------------------------------------------------------- /_toc.yml: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | # Learn more at https://jupyterbook.org/customize/toc.html 3 | 4 | format: jb-book 5 | root: README 6 | parts: 7 | - caption: Preliminaries 8 | chapters: 9 | - file: notebooks/pandas 10 | 11 | - caption: Satellite Navigation systems 12 | chapters: 13 | - file: docs/gnss_observables 14 | - file: docs/low_cost_gnss 15 | sections: 16 | - file: notebooks/low_cost_gnss 17 | - file: docs/roti 18 | sections: 19 | - file: notebooks/roti 20 | 21 | - caption: Projects 22 | chapters: 23 | - file: docs/projects 24 | - file: docs/amic 25 | 26 | - caption: References 27 | chapters: 28 | - file: docs/references.md 29 | 30 | - caption: Acknowledgements 31 | chapters: 32 | - file: docs/acknowledgements.md 33 | -------------------------------------------------------------------------------- /assets/MEDEA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/MEDEA.png -------------------------------------------------------------------------------- /assets/MTIC00ESP_R_20191221131_05H_01S_MO.rnx.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/MTIC00ESP_R_20191221131_05H_01S_MO.rnx.gz -------------------------------------------------------------------------------- /assets/MTIC00ESP_R_20191221131_05H_01S_MO.rnx.parquet: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/MTIC00ESP_R_20191221131_05H_01S_MO.rnx.parquet -------------------------------------------------------------------------------- /assets/SEYG00SYC_R_20140581500_05H_01S_MO.rnx.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/SEYG00SYC_R_20140581500_05H_01S_MO.rnx.gz -------------------------------------------------------------------------------- /assets/SEYG00SYC_R_20140581500_05H_01S_MO.rnx.parquet: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/SEYG00SYC_R_20140581500_05H_01S_MO.rnx.parquet -------------------------------------------------------------------------------- /assets/ZED-F9P.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/ZED-F9P.png -------------------------------------------------------------------------------- /assets/amic_antenna_inchang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/amic_antenna_inchang.png -------------------------------------------------------------------------------- /assets/amic_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/amic_network.png -------------------------------------------------------------------------------- /assets/amic_receiver_medea.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/amic_receiver_medea.jpg -------------------------------------------------------------------------------- /assets/choke_ring_antenna.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/choke_ring_antenna.png -------------------------------------------------------------------------------- /assets/esa_gssc_data_portal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/esa_gssc_data_portal.png -------------------------------------------------------------------------------- /assets/igs_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/igs_network.png -------------------------------------------------------------------------------- /assets/juanetal2017_fig2b_roti_sey1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/juanetal2017_fig2b_roti_sey1.png -------------------------------------------------------------------------------- /assets/mosaic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/mosaic.jpg -------------------------------------------------------------------------------- /assets/sample.csv: -------------------------------------------------------------------------------- 1 | sat,pseudorange_m 2 | E14,23750018.563 3 | G30,22392017.586 4 | G30,22593509.535 5 | G34,21001954.062 6 | E14,23752281.100 7 | E06,21907728.072 8 | E06,21523984.137 9 | R20,19174789.874 10 | R20,20848227.027 11 | E14,23575798.642 -------------------------------------------------------------------------------- /assets/talysmann_tw7972.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/talysmann_tw7972.jpg -------------------------------------------------------------------------------- /assets/unicorecomm_UM982.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/assets/unicorecomm_UM982.png -------------------------------------------------------------------------------- /docs/acknowledgements.md: -------------------------------------------------------------------------------- 1 | # Acknowledgements 2 | 3 | This Jupyter book was presented for the first time at the: 4 | 5 | ["Eastern Africa Capacity Building Workshop on Space Weather and Low-latitude Ionosphere"](https://indico.ictp.it/event/10216/overview) 6 | 7 | and the initial version of this Jupyter book was partially supported by the organizers of the event, 8 | by covering travel and subsistence costs to attend the conference. 9 | -------------------------------------------------------------------------------- /docs/amic.md: -------------------------------------------------------------------------------- 1 | 2 | # AMIC project 3 | 4 | **A**ffordable **M**onitoring of the **I**onosphere and Observable **C**haracterization 5 | 6 | Is a project funded by the European Space Agency[^contractnum] to build an 7 | affordable dense network of GNSS Continuousy Operating Reference Stations, 8 | specially aimed at densifying areas with a low density of GNSS receivers. 9 | 10 | [^contractnum]: ESA contract number 4000130532/20/NL/AS/hh 11 | 12 | ## Why another network? 13 | 14 | The main problem of current networks are the areas with scarcity of GNSS 15 | receivers (e.g. deserts). Look for instance in Africa, northern latitudes, Western South America, ... In addition, for ionospheric activities, these are *interesting* areas to monitor due to the presence of the Equator anomaly or the presence of Auroras. 16 | 17 | ![IGS network](../assets/igs_network.png) 18 | 19 | In order to remedy this, **AMIC** proposes the deployment of **ACORN** (**A**MIC **C**ontinuousy **O**perating **R**eference Station **N**etwork), 20 | a network based entirely on affordable receivers logging data and sending them to the [ESA's GSSC](https://gssc.esa.int/portal/) data repository. 21 | 22 | ![ESA GSSC Data portal](../assets/esa_gssc_data_portal.png) 23 | 24 | An important point of the project and the network is that the 25 | **data will be publicly available**. A first set of deployment sites of the ACORN network can be seen in the following figure. 26 | 27 | ![AMIC network](../assets/amic_network.png) 28 | 29 | Albeit special emphasis is placed on African sites, locations in other 30 | continents are also being considered. 31 | 32 | ## The network receivers 33 | 34 | The AMIC GNSS receiver is a receiver based on the [u-blox ZED-F9P](https://www.u-blox.com/en/product/zed-f9p-module) GNSS chipset, which is a dual frequency (L1/L2/E5b) receiver able to track multiple constellations (GPS, Galileo, Beidou, Glonass, QZSS, ...). The AMIC receiver is a MEDEA GNSS computer 35 | developed by Rokubun, which follows the trend of the new generation of 36 | [affordable GNSS receivers](./low_cost_gnss.md). 37 | 38 | The power consumption of the device is very low (similar to the consumption of a Raspberry Pi). 39 | 40 | The device will transfer around 35 Mbytes per day of data to ESA servers in Europe. 41 | 42 | ![AMIC receiver MEDEA](../assets/amic_receiver_medea.jpg) 43 | 44 | The antenna corresponds to an [Inchang JCA228B](http://www.jinchanggps.com/JCA228B-pd46958135.html) multi-frequency GNSS antenna 45 | 46 | ![AMIC antenna Inchang](../assets/amic_antenna_inchang.png) 47 | 48 | The AMIC receivers have these additional characteristics: 49 | 50 | - are **affordable** enough (~ 1k€) so that they might be easier to replace in the likely event that the device is lost or damaged in remote areas, where human access can be challenging. 51 | - they are shipped **preconfigured** so that they have to be plugged and left. The standard configuration is logging of GNSS pseudoranges, carrier-phase, Doppler and SNR at 1Hz rate 52 | - Storing data in **RINEX** file, with file rotation of 15 minutes. 53 | - Whenever a new RINEX file is available and connectivity is up, it will be **automatically uploaded** to ESA's GSSC servers. 54 | - The receiver can handle intermittent Internet connectivity outages, as it can store ca 1 month worth of data. 55 | -------------------------------------------------------------------------------- /docs/gnss_observables.md: -------------------------------------------------------------------------------- 1 | --- 2 | jupytext: 3 | formats: md:myst 4 | text_representation: 5 | extension: .md 6 | format_name: myst 7 | kernelspec: 8 | display_name: Python 3 9 | language: python 10 | name: python3 11 | --- 12 | 13 | # GNSS observables 14 | 15 | We understand by GNSS observables as 16 | 17 | - pseudorange 18 | - carrier phase 19 | - Doppler 20 | - Signal-to-Noise ratio 21 | 22 | These are delivered usually in RINEX format 23 | 24 | ## Observable model 25 | 26 | The GNSS observables can be modelled usign the following expressions for the code and phase 27 | 28 | $$ 29 | P = \rho + c \cdot (dt_r - dt^s) + \frac{40.3}{f^2}\cdot STEC_r^s+T + b_P + \varepsilon_P 30 | $$ 31 | 32 | $$ 33 | L = \rho + c \cdot (dt_r - dt^s) - \frac{40.3}{f^2}\cdot STEC_r^s+ T + b_L + \lambda \cdot N + \varepsilon_P 34 | $$ 35 | 36 | where: 37 | 38 | - $P$ and $L$ represent the pseudorange and carrier-phase respectively 39 | - $\rho$ is the geometric (Euclidean) distance between the receiver and satellite 40 | - $dt_r$ and $dt^s$ is the clock bias for the receiver and satellite. $c$ is the speed of light 41 | - $f$ is the frequency of the signal (and $\lambda$ is the corresponding wavelength) 42 | - $b_P$ and $b_L$ are the code and phase hardware biases respectively 43 | - $N$ is the integer phase ambiguity 44 | 45 | 46 | ## Observable combination 47 | 48 | ### Ionospheric (geometry-free) combination 49 | 50 | Between frequencies $a$ and $b$. Assuming that $f_b > f_a$, the code combination 51 | can be expressed as: 52 | 53 | $$ 54 | PI_{a,b} = P_a - P_b = 40.3 \cdot \left ( \frac{1}{f_a^2} - \frac{1}{f_b^2} \right) \cdot STEC_r^s + b_{P,a} - b_{P,b} + \sqrt{2} \cdot \varepsilon_P 55 | $$ 56 | 57 | On the other hand, the ionospheric combination of phases can be expressed as: 58 | 59 | ```{math} 60 | :label: iono_comb_phase 61 | LI_{a,b} = P_b - P_a = 40.3 \cdot \left ( \frac{1}{f_a^2} - \frac{1}{f_b^2} \right) \cdot STEC_r^s + b_{L,b} - b_{L,a} + {\lambda}_b \cdot N_b - {\lambda}_a \cdot N_a + \sqrt{2} \cdot \varepsilon_L 62 | ``` 63 | 64 | In GNSS textbooks, the term $40.3 \cdot \left ( \frac{1}{f_a^2} - \frac{1}{f_b^2} \right)$ is usually referred to the alpha constant $\alpha_{LI}$ for the given frequencies 65 | 66 | For example, the $\alpha_{LI}$ for GPS L1 and L2 frequencies can be computed as follows: 67 | 68 | ```{code-cell} 69 | 40.3 * (1 / (10.23e6*120)**2 - 1 / (10.23e6*154)**2) 70 | ``` 71 | 72 | On the other hand, the term $b_{P,a} - b_{P,b}$ is known as the Differential 73 | Code Bias (DCB) between the codes $P_a$ and $P_b$ (i.e. $DCB(P_a, P_b)$). 74 | 75 | This combination is usually employed when building models for ionospheric estimation 76 | due to the fact that the Slant Total Electron Content (STEC) is exposed. 77 | 78 | (cmc_combination)= 79 | ### Code minus carrier combination 80 | 81 | This combination (also called code/carrier divergence) is defined as follows 82 | 83 | ```{math} 84 | :label: cmc 85 | CMC_{a} = P_a - L_a = 2 \cdot \frac{40.3}{f_a^2} \cdot STEC_r^s + b_{P,a} - b_{L,a} - {\lambda}_a \cdot N_a + \varepsilon_P - \varepsilon_L 86 | ``` 87 | 88 | The combination has twice the ionospheric combination, plus several constant 89 | terms (biases, ambiguity) and the code and phase noises. Note however that 90 | the code noise is much higher than the phase one. Therefore, if the 91 | ionospheric contribution is removed (using a rolling window to detrend it or 92 | removing the STEC computed from e.g. IONEX maps) and the bias is removed, 93 | the CMC can serve as **noise estimator** of the observables. 94 | 95 | In addition, for high-rate applications (> 1Hz), large jumps in the CMC 96 | (larger than the code noise level, ~ 1 m) can indicate the presence of **cycle slips**. 97 | -------------------------------------------------------------------------------- /docs/low_cost_gnss.md: -------------------------------------------------------------------------------- 1 | # Affordable receivers 2 | 3 | With the reduction of cost in chipsets and technologies, the GNSS sector is also 4 | experiencing a change in paradigm in terms of hardware cost as well. Centimetric 5 | accuracy used to require expensive hardware (GNSS chipset/receiver and good quality 6 | antenna), but nowadays alternatives are already in the market. 7 | 8 | The trend was started by chipset manufacturers that launched System-on-Chip (SoC) 9 | at a very competitive price. Examples of such systems are: 10 | 11 | - u-blox (ZED-F9 family), dual-frequency, ~100€ per unit 12 | - Septentrio Mosaic receiver, multi-constellation, triple-frequency, ~500€ per units 13 | - Unicorecomm UM982, multi-constellation, triple-frequency, ~400€ per units 14 | 15 | |||| 16 | |:---:|:---:|:----:| 17 | | ![ZED-F9P](../assets/ZED-F9P.png) | ![Septentrio Mosaic](../assets/mosaic.jpg) | ![Unicorecomm UM982](../assets/unicorecomm_UM982.png) | 18 | 19 | 20 | A critical aspect of affordable receivers is not only the GNSS chipset itself, but the 21 | antenna as well. This will be also largely responsible for the quality of the 22 | GNSS observables/measurements and, therefore, the accuracy that can be achieved in 23 | the estimation of the position. For maximum accuracy, Choke ring antennas 24 | (see left panel of Figure below) are usually employed. However, budget antennas 25 | such as the one shown in the right panel of the following Figure, are capable 26 | of providing good quality measures, as will be shown in this section. 27 | 28 | ||| 29 | |:---:|:---:| 30 | |![Choke ring antenna](../assets/choke_ring_antenna.png) | ![Talysmann TW7972](../assets/talysmann_tw7972.jpg) | 31 | 32 | 33 | Examples of bduget GNSS antennas that can offer good measurement quality are: 34 | 35 | - Tallysman TW7972, dual frequency, ~200€ per unit 36 | - Jinchang JCA228B, dual-frequency, ~60€ per unit 37 | 38 | These hardware needs to be integrated. Evaluation kits with a Raspberry Pi might work but eventually the whole set of components needs to be integrated (which adds cost). An example of a GNSS device that integrates budget chipset and antenna is Rokubun's MEDEA computer (see Figure below), based on a u-blox ZED-F9P and an application processor to load third-party software to process GNSS measurements (as done in the [AMIC project](./amic.md)) 39 | 40 | ![MEDEA GNSS computer](../assets/MEDEA.png) 41 | -------------------------------------------------------------------------------- /docs/projects.md: -------------------------------------------------------------------------------- 1 | # Related projects 2 | 3 | This section includes projects related to the contents included in this documentation 4 | -------------------------------------------------------------------------------- /docs/references.md: -------------------------------------------------------------------------------- 1 | # References 2 | 3 | ```{bibliography} 4 | ``` 5 | -------------------------------------------------------------------------------- /docs/roti.md: -------------------------------------------------------------------------------- 1 | # Rate ot TEC Index (ROTI) 2 | 3 | The ROTI, as proxy for a measure of scintillation, was introduced in {cite:p}`pi2013observations`, and basically consists in computing the standard deviation of the rate of TEC over a certain time interval. It can be easily computed using the ionospheric (geometry-free) combination of GNSS carrier phase measurements. In short, the steps to compute it are described in {prf:ref}`roti`. 4 | 5 | ```{prf:algorithm} Satellite ROTI calculation 6 | :label: roti 7 | :nonumber: 8 | 9 | **Inputs** Carrier-phase GNSS measurements at two bands (e.g. L1 and L2) 10 | 11 | **Output** Time series of ROTI (TECU/minute) 12 | 13 | 1. For each GNSS satellite 14 | 15 | 1. Compute the ionospheric (geometry) free combination of carrier phases at frequencies $f_1$ and $f_2$: $LI = L1 - L2$ 16 | 2. Compute the time difference of $LI$: $\Delta LI = \frac{\delta LI}{\delta t}$ 17 | 3. Compute the time difference of $STEC$: $\Delta STEC = \Delta LI / \alpha_{LI}$, 18 | where $\alpha_{LI} = 40.3 \cdot (\frac{1}{f_2^2} - \frac{1}{f_1^2})$ 19 | 4. Compute the standard deviation of the $\Delta STEC$ across consecutive time intervals of e.g. 5 minutes 20 | ``` 21 | 22 | An example of the ROTI calculation for a 1Hz dataset of SEY1 station (2014, doy 58) can be found in {cite:p}`juan2017method` 23 | 24 | ![Juan et al. 2017 Figure 2b](../assets/juanetal2017_fig2b_roti_sey1.png) 25 | 26 | 27 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/logo.png -------------------------------------------------------------------------------- /notebooks/low_cost_gnss.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Measurement quality from affordable GNSS receivers\n", 8 | "\n", 9 | "In this section we will assess some basic differences in terms of GNSS observables\n", 10 | "between a geodetic-grade and an affordable receiver." 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": null, 16 | "metadata": { 17 | "tags": [ 18 | "hide-input" 19 | ] 20 | }, 21 | "outputs": [], 22 | "source": [ 23 | "import matplotlib.pyplot as plt\n", 24 | "import numpy as np\n", 25 | "import pandas as pd\n", 26 | "\n", 27 | "from roktools import rinex\n", 28 | "from roktools.time import compute_elapsed_seconds\n", 29 | "from roktools.stats import rms\n", 30 | "\n", 31 | "from roktools.gnss.types import ConstellationId, TrackingChannel, Satellite\n", 32 | "from roktools.gnss.observables import compute_code_minus_carrier, compute_geometry_free\n", 33 | "from roktools.gnss.edit import mark_time_gap, detrend, remove_mean\n", 34 | "\n", 35 | "%matplotlib widget" 36 | ] 37 | }, 38 | { 39 | "cell_type": "markdown", 40 | "metadata": {}, 41 | "source": [ 42 | "Using the [`roktools` library](https://pypi.org/project/roktools/), the RINEX data from the receivers will be stored\n", 43 | "into a `pandas` `DataFrame` (`df` for short) for manipulation." 44 | ] 45 | }, 46 | { 47 | "cell_type": "code", 48 | "execution_count": null, 49 | "metadata": {}, 50 | "outputs": [], 51 | "source": [ 52 | "# DataFrame with data from a geodetic receiver\n", 53 | "#df_geodetic = rinex.to_dataframe('../assets/SEYG00SYC_R_20140581500_05H_01S_MO.rnx')\n", 54 | "#df_geodetic.to_parquet('../assets/SEYG00SYC_R_20140581500_05H_01S_MO.rnx.parquet')\n", 55 | "df_geodetic = pd.read_parquet('../assets/SEYG00SYC_R_20140581500_05H_01S_MO.rnx.parquet')" 56 | ] 57 | }, 58 | { 59 | "cell_type": "code", 60 | "execution_count": null, 61 | "metadata": {}, 62 | "outputs": [], 63 | "source": [ 64 | "# DataFrame with an affordable receiver\n", 65 | "#df_afford = rinex.to_dataframe('../assets/MTIC00ESP_R_20191221131_05H_01S_MO.rnx')\n", 66 | "#df_afford.to_parquet('../assets/MTIC00ESP_R_20191221131_05H_01S_MO.rnx.parquet')\n", 67 | "df_afford = pd.read_parquet('../assets/MTIC00ESP_R_20191221131_05H_01S_MO.rnx.parquet')" 68 | ] 69 | }, 70 | { 71 | "cell_type": "markdown", 72 | "metadata": {}, 73 | "source": [ 74 | "The `DataFrame` can be consider a *CSV* file of sorts, where each row has a time tag, satellite, observables and other fields that will be explained and used later in the notebook. A preview of the contents can be obtained with the `head` method" 75 | ] 76 | }, 77 | { 78 | "cell_type": "code", 79 | "execution_count": null, 80 | "metadata": {}, 81 | "outputs": [], 82 | "source": [ 83 | "df_geodetic.head()" 84 | ] 85 | }, 86 | { 87 | "cell_type": "markdown", 88 | "metadata": {}, 89 | "source": [ 90 | "## Observable types\n", 91 | "\n", 92 | "Once we have loaded the RINEX files into DataFrames, we can perform some\n", 93 | "basic checks on the differences between geodetic and affordable GNSS data.\n", 94 | "\n", 95 | "The following example gives you *the channels tracked by the receiver for each GNSS constellation*.\n", 96 | "\n", 97 | "The channel corresponds to the last two characters (i.e. band and attribute) of \n", 98 | "the [RINEX observation code](https://files.igs.org/pub/data/format/rinex_4.00.pdf) (see Section 5.2.17). \n", 99 | "For instance `1C` for GPS means the observables obtained with the C/A tracking at the L1 frequency.\n", 100 | "\n", 101 | "To know the observables for constellation, we will use the `groupby` method of `pandas`." 102 | ] 103 | }, 104 | { 105 | "cell_type": "code", 106 | "execution_count": null, 107 | "metadata": {}, 108 | "outputs": [], 109 | "source": [ 110 | "# We will group the data using various criteria\n", 111 | "columns = ['constellation', 'channel']\n", 112 | "\n", 113 | "# Use groupby() to group by the two columns and apply unique()\n", 114 | "unique_combinations = df_geodetic.groupby(columns).size()\n", 115 | "\n", 116 | "# Print the unique combinations (along with the number of samples for each\n", 117 | "# tracking channel)\n", 118 | "print(unique_combinations)" 119 | ] 120 | }, 121 | { 122 | "cell_type": "markdown", 123 | "metadata": {}, 124 | "source": [ 125 | "We can now perform the same for the affordable receivers" 126 | ] 127 | }, 128 | { 129 | "cell_type": "code", 130 | "execution_count": null, 131 | "metadata": {}, 132 | "outputs": [], 133 | "source": [ 134 | "unique_combinations = df_afford.groupby(columns).size()\n", 135 | "\n", 136 | "print(unique_combinations)" 137 | ] 138 | }, 139 | { 140 | "cell_type": "markdown", 141 | "metadata": {}, 142 | "source": [ 143 | "In these examples, the following basic differences are observed:\n", 144 | "\n", 145 | "- the geodetic receiver tracks various frequencies (GPS L1/L2/L5, Galileo E1/E5a/E5b/E5, ...) whereas the affordable receiver tracks typically **two frequencies** (GPS L1/L2, Galileo E1/E5b, ...)\n", 146 | "- the affordable receiver does not attempt to track encrypted codes (i.e. GPS `P` code) by means of e.g. z-tracking loops. **Only civilian codes** (e.g. GPS L2C) are used.\n", 147 | "\n", 148 | "Some other strenghts of affordable receivers:\n", 149 | "\n", 150 | "- Availability of SNR and Doppler measurements (not always available in 30s or high rate CORS data)\n", 151 | "- High rate up to 0.1s (or even higher) available for affordable measurements\n", 152 | "\n" 153 | ] 154 | }, 155 | { 156 | "cell_type": "markdown", 157 | "metadata": {}, 158 | "source": [ 159 | "## Code noise: Detrended code-minus-carrier\n", 160 | "\n", 161 | "The observable code noise of a GNSS receiver can be estimated using the \n", 162 | "[code-minus-carrier combination](cmc_combination). This section illustrates\n", 163 | "how to estimate to check some basic differences between receiver types\n" 164 | ] 165 | }, 166 | { 167 | "cell_type": "markdown", 168 | "metadata": {}, 169 | "source": [ 170 | "The steps to be followed to compute the unbiased detrended Code minus carrier\n", 171 | "are detailed in the following steps (taking as example the Geodetic receiver\n", 172 | "and then applying it to the Affordable receiver)\n", 173 | "\n", 174 | "1. *Edit* the data and find phase breaks such as cycle\n", 175 | "slips. In this example, since receivers already provide with Loss-of-Lock Indicator\n", 176 | "(LLI), we will only mark phase breaks due to data **time gaps**" 177 | ] 178 | }, 179 | { 180 | "cell_type": "code", 181 | "execution_count": null, 182 | "metadata": {}, 183 | "outputs": [], 184 | "source": [ 185 | "df_geodetic_cmc = mark_time_gap(df_geodetic)" 186 | ] 187 | }, 188 | { 189 | "cell_type": "markdown", 190 | "metadata": {}, 191 | "source": [ 192 | "2. Proceed to **compute the code minus carrier (CMC)** observable. Each row of the `DataFrame`\n", 193 | "contains the range and phase, so there will be as many CMC observables as rows.\n", 194 | "A note of caution: the phase is usually expressed in cycles, therefore we will\n", 195 | "need to get the wavelength from the tracking channel in order to be consistent\n", 196 | "with the units" 197 | ] 198 | }, 199 | { 200 | "cell_type": "code", 201 | "execution_count": null, 202 | "metadata": {}, 203 | "outputs": [], 204 | "source": [ 205 | "df_geodetic_cmc = compute_code_minus_carrier(df_geodetic_cmc)" 206 | ] 207 | }, 208 | { 209 | "cell_type": "code", 210 | "execution_count": null, 211 | "metadata": {}, 212 | "outputs": [], 213 | "source": [ 214 | "df_geodetic_cmc.head()" 215 | ] 216 | }, 217 | { 218 | "cell_type": "code", 219 | "execution_count": null, 220 | "metadata": {}, 221 | "outputs": [], 222 | "source": [ 223 | "\n", 224 | "# Plot a CMC for a channel and satellite\n", 225 | "signal = 'E121X'\n", 226 | "df_sample = df_geodetic_cmc[df_geodetic_cmc['signal'] == signal]\n", 227 | "plt.close()\n", 228 | "plt.plot(compute_elapsed_seconds(df_sample['epoch'])/3600, df_sample['cmc'], ',k')\n", 229 | "plt.xlabel('Elapsed hours')\n", 230 | "plt.ylabel('CMC [m]')\n", 231 | "_ = plt.title(f'Code-minus-carrier for {signal}')\n" 232 | ] 233 | }, 234 | { 235 | "cell_type": "markdown", 236 | "metadata": {}, 237 | "source": [ 238 | "3. Because the CMC contains twice the ionosphere, which is a nuisance parameter\n", 239 | "for us (because we are interested in the noise). We will proceed to remove its\n", 240 | "contribution with a simple detrending, based on a rolling window" 241 | ] 242 | }, 243 | { 244 | "cell_type": "code", 245 | "execution_count": null, 246 | "metadata": {}, 247 | "outputs": [], 248 | "source": [ 249 | "n_samples = 5\n", 250 | "df_geodetic_cmc = detrend(df_geodetic_cmc, 'cmc', n_samples)" 251 | ] 252 | }, 253 | { 254 | "cell_type": "code", 255 | "execution_count": null, 256 | "metadata": {}, 257 | "outputs": [], 258 | "source": [ 259 | "# Plot the detrened CMC for a channel and satellite\n", 260 | "signal = 'E121X'\n", 261 | "df_sample = df_geodetic_cmc[df_geodetic_cmc['signal'] == signal]\n", 262 | "plt.close()\n", 263 | "plt.plot(compute_elapsed_seconds(df_sample['epoch'])/3600, df_sample['cmc_detrended'], ',k')\n", 264 | "plt.xlabel('Elapsed hours')\n", 265 | "plt.ylabel('Detrended CMC [m]')\n", 266 | "plt.ylim(-1, 1)\n", 267 | "_ = plt.title(f'Detrended Code-minus-carrier for {signal}')\n" 268 | ] 269 | }, 270 | { 271 | "cell_type": "code", 272 | "execution_count": null, 273 | "metadata": {}, 274 | "outputs": [], 275 | "source": [ 276 | "\n", 277 | "# Analysis for Ublox receiver\n", 278 | "df_afford_cmc = mark_time_gap(df_afford)\n", 279 | "df_afford_cmc = compute_code_minus_carrier(df_afford_cmc)\n", 280 | "df_afford_cmc = detrend(df_afford_cmc, 'cmc', n_samples)\n" 281 | ] 282 | }, 283 | { 284 | "cell_type": "markdown", 285 | "metadata": {}, 286 | "source": [ 287 | "Let's compute now an estimate of the code noise for the geodetic grade and\n", 288 | "the affordable receiver, for a specific band and constellation." 289 | ] 290 | }, 291 | { 292 | "cell_type": "code", 293 | "execution_count": null, 294 | "metadata": {}, 295 | "outputs": [], 296 | "source": [ 297 | "# GLONASS is excluded as the slot number to know the frequency (and hence the wavelength)\n", 298 | "# is missing\n", 299 | "condition1 = df_geodetic_cmc['constellation'] == ConstellationId.GPS.value\n", 300 | "condition2 = df_geodetic_cmc['channel'] == '2W'\n", 301 | "#condition2 = df_geodetic_cmc['channel'] == '1C'\n", 302 | "df_tmp_g = df_geodetic_cmc[condition1 & condition2]\n", 303 | "\n", 304 | "condition1 = df_afford_cmc['constellation'] == ConstellationId.GPS.value\n", 305 | "condition2 = df_afford_cmc['channel'] == '2L'\n", 306 | "#condition2 = df_afford_cmc['channel'] == '1C'\n", 307 | "df_tmp_a = df_afford_cmc[condition1 & condition2]\n", 308 | "\n", 309 | "noise_samples_geodetic = df_tmp_g['cmc_detrended']\n", 310 | "noise_samples_afford = df_tmp_a['cmc_detrended']\n", 311 | "\n", 312 | "rms_geodetic = rms(noise_samples_geodetic)\n", 313 | "rms_afford = rms(noise_samples_afford)\n", 314 | "\n", 315 | "print(f'Geodetic receiver code noise: {rms_geodetic:.2} m')\n", 316 | "print(f'Affordable receiver code noise: {rms_afford:.2} m')" 317 | ] 318 | }, 319 | { 320 | "cell_type": "markdown", 321 | "metadata": {}, 322 | "source": [ 323 | "## Observable noise: Detrended LI\n", 324 | "\n", 325 | "In addition, to the estimation of the code, for multifrequency receivers, the\n", 326 | "[geometry-free (ionospheric) combination](../docs/gnss_observables.md) \n", 327 | "can be used to estimate the noise of the phase observables" 328 | ] 329 | }, 330 | { 331 | "cell_type": "code", 332 | "execution_count": null, 333 | "metadata": {}, 334 | "outputs": [], 335 | "source": [ 336 | "constellation = ConstellationId.GPS\n", 337 | "channel_a = TrackingChannel.from_string('1C')\n", 338 | "channel_b = TrackingChannel.from_string('2W')\n", 339 | "\n", 340 | "# Let's reuse the previous dataframe that has been already edited (time gaps marked)\n", 341 | "df_geodetic_li = compute_geometry_free(df_geodetic_cmc, constellation, channel_a, channel_b)" 342 | ] 343 | }, 344 | { 345 | "cell_type": "markdown", 346 | "metadata": {}, 347 | "source": [ 348 | "The ionospheric combination (LI) contains the ionospheric delay, constant terms\n", 349 | "due to the phase ambiguities of the carrier phases used to build the combination\n", 350 | "as well as the phase hardware biases and noise of the phase observable\n", 351 | "\n", 352 | "An example of LI for a particular satellite is shown in the Figure below" 353 | ] 354 | }, 355 | { 356 | "cell_type": "code", 357 | "execution_count": null, 358 | "metadata": {}, 359 | "outputs": [], 360 | "source": [ 361 | "df_geodetic_li" 362 | ] 363 | }, 364 | { 365 | "cell_type": "code", 366 | "execution_count": null, 367 | "metadata": {}, 368 | "outputs": [], 369 | "source": [ 370 | "sat = 'G04'\n", 371 | "\n", 372 | "condition1 = df_geodetic_li['sat'] == sat\n", 373 | "df_tmp = df_geodetic_li[condition1]\n", 374 | "\n", 375 | "t = compute_elapsed_seconds(df_tmp['epoch'])\n", 376 | "li = df_tmp['li_m']\n", 377 | "\n", 378 | "plt.close()\n", 379 | "plt.plot(t, li, '.')\n", 380 | "plt.xlabel('Time [seconds]')\n", 381 | "plt.ylabel('LI [m]')\n", 382 | "plt.title(f'LI combination for {sat}, Geoetic receiver (SEY1)')\n" 383 | ] 384 | }, 385 | { 386 | "cell_type": "code", 387 | "execution_count": null, 388 | "metadata": {}, 389 | "outputs": [], 390 | "source": [ 391 | "import pandas as pd\n", 392 | "\n", 393 | "def detrend_li_combination(df:pd.DataFrame):\n", 394 | " \"\"\"\n", 395 | " Perform a detrending of the LI combination (if present in the input dataframe)\n", 396 | " \"\"\"\n", 397 | "\n", 398 | " df['slip_li'] = df['slip_a'] | df['slip_b']\n", 399 | " df['arc_id'] = df.groupby(['signal_a', 'signal_b'])['slip_li'].transform('cumsum')\n", 400 | " trend = df.groupby(['signal_a', 'signal_b', 'arc_id'])['li_m'].transform(lambda x: x.rolling(n_samples).mean())\n", 401 | " df['li_trend'] = trend\n", 402 | " df['li_detrended'] = df['li_m'] - df['li_trend']\n" 403 | ] 404 | }, 405 | { 406 | "cell_type": "code", 407 | "execution_count": null, 408 | "metadata": {}, 409 | "outputs": [], 410 | "source": [ 411 | "detrend_li_combination(df_geodetic_li)" 412 | ] 413 | }, 414 | { 415 | "cell_type": "code", 416 | "execution_count": null, 417 | "metadata": {}, 418 | "outputs": [], 419 | "source": [ 420 | "condition1 = df_geodetic_li['signal_a'] == 'G041C'\n", 421 | "condition2 = df_geodetic_li['signal_b'] == 'G042W'\n", 422 | "\n", 423 | "df_sample = df_geodetic_li[condition1 & condition2]\n", 424 | "plt.close()\n", 425 | "plt.plot(df_sample['epoch'], df_sample['li_detrended'], '.')\n", 426 | "plt.ylim(-0.05, 0.05)" 427 | ] 428 | }, 429 | { 430 | "cell_type": "markdown", 431 | "metadata": {}, 432 | "source": [ 433 | "We can proceed with the same analysis for the affordable receiver, taking into\n", 434 | "account that the observables are slightly different" 435 | ] 436 | }, 437 | { 438 | "cell_type": "code", 439 | "execution_count": null, 440 | "metadata": {}, 441 | "outputs": [], 442 | "source": [ 443 | "channel_a = TrackingChannel.from_string('1C')\n", 444 | "channel_b = TrackingChannel.from_string('2L')\n", 445 | "\n", 446 | "df_afford_li = compute_geometry_free(df_afford_cmc, constellation, channel_a, channel_b)\n", 447 | "\n", 448 | "detrend_li_combination(df_afford_li)\n" 449 | ] 450 | }, 451 | { 452 | "cell_type": "code", 453 | "execution_count": null, 454 | "metadata": {}, 455 | "outputs": [], 456 | "source": [ 457 | "condition1 = df_afford_li['signal_a'] == 'G241C'\n", 458 | "condition2 = df_afford_li['signal_b'] == 'G242L'\n", 459 | "\n", 460 | "df_sample = df_afford_li[condition1 & condition2]\n", 461 | "\n", 462 | "plt.close()\n", 463 | "plt.plot(df_sample['epoch'], df_sample['li_detrended'], '.')\n", 464 | "plt.ylim(-0.05, 0.05)" 465 | ] 466 | }, 467 | { 468 | "cell_type": "markdown", 469 | "metadata": {}, 470 | "source": [ 471 | "The detrended time series can be used to compute the noise figures of the\n", 472 | "carrier phase for both types of receivers" 473 | ] 474 | }, 475 | { 476 | "cell_type": "code", 477 | "execution_count": null, 478 | "metadata": {}, 479 | "outputs": [], 480 | "source": [ 481 | "# Remove outliers for the statistics\n", 482 | "noise_li_afford = df_afford_li['li_detrended'][(df_afford_li['li_detrended'] > -1) & (df_afford_li['li_detrended'] < 1)]\n", 483 | "noise_li_geodetic = df_geodetic_li['li_detrended'][(df_geodetic_li['li_detrended'] > -1) & (df_geodetic_li['li_detrended'] < 1)]\n", 484 | "\n", 485 | "print(f\"Phase Noise Affordable receiver: {np.std(noise_li_afford)*100:.0f} cm\")\n", 486 | "print(f\"Phase Noise Geodetic receiver: {np.std(noise_li_geodetic)*100:.0f} cm\") " 487 | ] 488 | }, 489 | { 490 | "cell_type": "markdown", 491 | "metadata": {}, 492 | "source": [ 493 | "As it can be seen, the differences between receivers is not substantial. This\n", 494 | "can be further confirmed with the actual distribution of the noise samples, which\n", 495 | "follow a very similar Gaussian pattern for both receivers" 496 | ] 497 | }, 498 | { 499 | "cell_type": "code", 500 | "execution_count": null, 501 | "metadata": {}, 502 | "outputs": [], 503 | "source": [ 504 | "plt.close()\n", 505 | "bins = np.linspace(-0.05, 0.05, 200)\n", 506 | "_ = plt.hist(df_geodetic_li['li_detrended'], bins=bins, histtype='step', label='Geodetic')\n", 507 | "_ = plt.hist(df_afford_li['li_detrended'], bins=bins, histtype='step', label='Affordable')\n", 508 | "plt.legend()\n", 509 | "plt.title('Histogram of carrier phase noise (estimated with LI)')\n", 510 | "plt.xlabel('Code phase error [m]')\n", 511 | "plt.ylabel('Count')" 512 | ] 513 | } 514 | ], 515 | "metadata": { 516 | "kernelspec": { 517 | "display_name": "Python 3 (ipykernel)", 518 | "language": "python", 519 | "name": "python3" 520 | }, 521 | "language_info": { 522 | "codemirror_mode": { 523 | "name": "ipython", 524 | "version": 3 525 | }, 526 | "file_extension": ".py", 527 | "mimetype": "text/x-python", 528 | "name": "python", 529 | "nbconvert_exporter": "python", 530 | "pygments_lexer": "ipython3", 531 | "version": "3.11.6" 532 | } 533 | }, 534 | "nbformat": 4, 535 | "nbformat_minor": 4 536 | } 537 | -------------------------------------------------------------------------------- /notebooks/pandas.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "825521f4-225f-4a90-87b6-4e4dfdd2049a", 6 | "metadata": {}, 7 | "source": [ 8 | "# Pandas\n", 9 | "\n", 10 | "In this section, you will find a basic tutorial on [Pandas](https://pandas.pydata.org/), which is *\"a fast, powerful, flexible and easy to use open source data analysis and manipulation tool\"*. \n", 11 | "\n", 12 | "Pandas is a powerful Python library for data manipulation and analysis. It provides data structures and functions needed to work with structured data seamlessly. In this tutorial, we'll cover some fundamental aspects of Pandas.\n", 13 | "\n", 14 | "## Basic installation\n", 15 | "\n", 16 | "To use pandas, make sure that you have it in your system. If not, you can use `pip` \n", 17 | "to install it:\n", 18 | "\n", 19 | "```bash\n", 20 | "pip install pandas\n", 21 | "```\n", 22 | "\n", 23 | "Then you will be able to import the `pandas` module with an `import` command:" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": null, 29 | "id": "4fa1037d-b996-4835-b896-6d5d309fce67", 30 | "metadata": { 31 | "tags": [] 32 | }, 33 | "outputs": [], 34 | "source": [ 35 | "import pandas as pd" 36 | ] 37 | }, 38 | { 39 | "cell_type": "markdown", 40 | "id": "7fa5df72", 41 | "metadata": {}, 42 | "source": [ 43 | "\n", 44 | "## Creating a DataFrame\n", 45 | "\n", 46 | "A DataFrame is the basic data storage structure in `pandas`. Is a two-dimensional labeled data structure with columns that can be of different types. You can create a DataFrame using various methods, such as from dictionaries, lists, or reading data from files.\n", 47 | "\n", 48 | "The following code creates a `DataFrame` from a dictionary and displays its contents:" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": null, 54 | "id": "e5189d54", 55 | "metadata": { 56 | "tags": [] 57 | }, 58 | "outputs": [], 59 | "source": [ 60 | "data = {'sat': ['G01', 'R24', 'E31'],\n", 61 | " 'pseudorange_m': [23364923.0, 21982625.0, 20396298.0]}\n", 62 | "df = pd.DataFrame(data)\n", 63 | "\n", 64 | "print(df)" 65 | ] 66 | }, 67 | { 68 | "cell_type": "markdown", 69 | "id": "f6d48623", 70 | "metadata": {}, 71 | "source": [ 72 | "Alternatively, `pandas` makes it easy to read data from various file formats, such as CSV, Excel, SQL databases, etc. Here's how you can read data from a CSV file." 73 | ] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "execution_count": null, 78 | "id": "2cb5e846", 79 | "metadata": { 80 | "tags": [] 81 | }, 82 | "outputs": [], 83 | "source": [ 84 | "# Read data from a CSV file\n", 85 | "data_file = '../assets/sample.csv'\n", 86 | "df = pd.read_csv(data_file)\n", 87 | "\n", 88 | "# Display the first few rows of the DataFrame\n", 89 | "print(df.head())\n" 90 | ] 91 | }, 92 | { 93 | "cell_type": "markdown", 94 | "id": "8adaa8c7", 95 | "metadata": {}, 96 | "source": [ 97 | "## Basic DataFrame Operations\n", 98 | "\n", 99 | "Pandas offers numerous functions to manipulate and analyze data.\n", 100 | "\n", 101 | "### Selection\n", 102 | "\n", 103 | "To select the rows of a specific column-value of the `DataFrame`, you can use `dict`-like indexing" 104 | ] 105 | }, 106 | { 107 | "cell_type": "code", 108 | "execution_count": null, 109 | "id": "7d20bbe3", 110 | "metadata": { 111 | "tags": [] 112 | }, 113 | "outputs": [], 114 | "source": [ 115 | "satellites = df['sat']\n", 116 | "print(satellites)" 117 | ] 118 | }, 119 | { 120 | "cell_type": "markdown", 121 | "id": "0ae2f758", 122 | "metadata": {}, 123 | "source": [ 124 | "### Filter\n", 125 | "\n", 126 | "You can also filter data for a specific value of a column. Let's for instance \n", 127 | "select all the rows that correspond to a specific satellite:" 128 | ] 129 | }, 130 | { 131 | "cell_type": "code", 132 | "execution_count": null, 133 | "id": "23c63701", 134 | "metadata": { 135 | "tags": [] 136 | }, 137 | "outputs": [], 138 | "source": [ 139 | "ranges = df[df['sat'] == 'E14']\n", 140 | "print(ranges)" 141 | ] 142 | }, 143 | { 144 | "cell_type": "markdown", 145 | "id": "ec0dc0dd", 146 | "metadata": {}, 147 | "source": [ 148 | "If you need to select those satellites whose range is lower than a threshold:" 149 | ] 150 | }, 151 | { 152 | "cell_type": "code", 153 | "execution_count": null, 154 | "id": "f1b9fcc1", 155 | "metadata": { 156 | "tags": [] 157 | }, 158 | "outputs": [], 159 | "source": [ 160 | "lower_satellites = df[df['pseudorange_m'] < 21000000]\n", 161 | "print(lower_satellites)" 162 | ] 163 | }, 164 | { 165 | "cell_type": "markdown", 166 | "id": "e32f93ad", 167 | "metadata": {}, 168 | "source": [ 169 | "### Adding new columns\n", 170 | "\n", 171 | "Sometimes you will need to add data to a `DataFrame`, in particular new columns to it.\n", 172 | "To do so, you will basically need to add an array with the same number of elements\n", 173 | "than the rows of the `DataFrame`, for instance:" 174 | ] 175 | }, 176 | { 177 | "cell_type": "code", 178 | "execution_count": null, 179 | "id": "44759194", 180 | "metadata": { 181 | "tags": [] 182 | }, 183 | "outputs": [], 184 | "source": [ 185 | "df['epoch_s'] = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4]\n", 186 | "print(df)" 187 | ] 188 | }, 189 | { 190 | "cell_type": "markdown", 191 | "id": "5e7e74c9", 192 | "metadata": {}, 193 | "source": [ 194 | "### Sorting\n", 195 | "\n", 196 | "Another basic operation is to sort based on a specific column. To sort the\n", 197 | "`DataFrame` by satellites, use the following command:" 198 | ] 199 | }, 200 | { 201 | "cell_type": "code", 202 | "execution_count": null, 203 | "id": "a3dfbca1", 204 | "metadata": { 205 | "tags": [] 206 | }, 207 | "outputs": [], 208 | "source": [ 209 | "sorted_df = df.sort_values(by='sat', ascending=False)\n", 210 | "print(sorted_df)" 211 | ] 212 | }, 213 | { 214 | "cell_type": "markdown", 215 | "id": "a5b3418a", 216 | "metadata": {}, 217 | "source": [ 218 | "### Groups\n", 219 | "\n", 220 | "An important feature of `DataFrame` is the ability to work with groups of data\n", 221 | "selected from a certain criteria. An example using our data set would be grouping\n", 222 | "by e.g. satellite, epoch,... and perform direct operations on the values of this group.\n", 223 | "\n", 224 | "For instance, if we'd like to compute the average pseudorange for each \n", 225 | "satellite, we would use the following code snippet:" 226 | ] 227 | }, 228 | { 229 | "cell_type": "code", 230 | "execution_count": null, 231 | "id": "9ce4f8b6", 232 | "metadata": { 233 | "tags": [] 234 | }, 235 | "outputs": [], 236 | "source": [ 237 | "grouped = df.groupby('sat')['pseudorange_m'].mean()\n", 238 | "print(grouped)" 239 | ] 240 | }, 241 | { 242 | "cell_type": "markdown", 243 | "id": "46df7f48", 244 | "metadata": {}, 245 | "source": [ 246 | "### Data Visualization\n", 247 | "\n", 248 | "Albeit `pandas` provides basic plotting capabilities using the `plot()` function,\n", 249 | "you can also use [`matplotlib`](https://matplotlib.org/) for this purpose, which will give you more flexibility." 250 | ] 251 | }, 252 | { 253 | "cell_type": "code", 254 | "execution_count": null, 255 | "id": "081291ba", 256 | "metadata": { 257 | "tags": [] 258 | }, 259 | "outputs": [], 260 | "source": [ 261 | "import matplotlib.pyplot as plt" 262 | ] 263 | }, 264 | { 265 | "cell_type": "markdown", 266 | "id": "85c20823", 267 | "metadata": {}, 268 | "source": [ 269 | "In our example, plotting the pseudorange data of all satellites and also for a\n", 270 | "certain satellite, can be plot as follows:" 271 | ] 272 | }, 273 | { 274 | "cell_type": "code", 275 | "execution_count": null, 276 | "id": "a51f19b6", 277 | "metadata": { 278 | "tags": [] 279 | }, 280 | "outputs": [], 281 | "source": [ 282 | "# Scatter plot for all satellites\n", 283 | "plt.scatter(df['epoch_s'], df['pseudorange_m'], label=\"all\")\n", 284 | "\n", 285 | "# Plot for a specific satellite\n", 286 | "satellite = \"E14\"\n", 287 | "df_sat = df[df['sat']== satellite]\n", 288 | "plt.scatter(df_sat['epoch_s'], df_sat['pseudorange_m'], marker='.', label=satellite)\n", 289 | "plt.legend()\n", 290 | "plt.show()\n" 291 | ] 292 | } 293 | ], 294 | "metadata": { 295 | "kernelspec": { 296 | "display_name": "Python 3 (ipykernel)", 297 | "language": "python", 298 | "name": "python3" 299 | }, 300 | "language_info": { 301 | "codemirror_mode": { 302 | "name": "ipython", 303 | "version": 3 304 | }, 305 | "file_extension": ".py", 306 | "mimetype": "text/x-python", 307 | "name": "python", 308 | "nbconvert_exporter": "python", 309 | "pygments_lexer": "ipython3", 310 | "version": "3.11.6" 311 | } 312 | }, 313 | "nbformat": 4, 314 | "nbformat_minor": 5 315 | } 316 | -------------------------------------------------------------------------------- /notebooks/roti.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Recipe: ROTI\n", 8 | "\n", 9 | "In this recipe we will replicate Figure 2b of {cite:p}`juan2017method` (for the SEY1 station and GPS PRN26) using the methodology described in {prf:ref}`roti`.\n", 10 | "\n", 11 | "This recipe includes the following steps:\n", 12 | "\n", 13 | "- Loading a Rinex file into a `pandas` DataFrame for later processing\n", 14 | "- Computing the LI for a satellite\n", 15 | "- Computing the $\\Delta STEC$ for a satellite\n", 16 | "- Computing the ROTI\n", 17 | "\n", 18 | "First we will import some necessary modules:" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "metadata": { 25 | "tags": [ 26 | "hide-input" 27 | ] 28 | }, 29 | "outputs": [], 30 | "source": [ 31 | "import matplotlib.pyplot as plt\n", 32 | "import numpy as np\n", 33 | "import pandas as pd\n", 34 | "from roktools import rinex\n", 35 | "\n", 36 | "# Import various classes that will be used to filter DataFrame\n", 37 | "from roktools.gnss.types import ConstellationId, TrackingChannel, Satellite\n", 38 | "\n", 39 | "# Add the path so that we can import the custom code of this Jupyter book\n", 40 | "import sys\n", 41 | "sys.path.append('../source/')\n", 42 | "\n", 43 | "# Import methods from the custom code of this book\n", 44 | "from gnss.observables import compute_geometry_free\n", 45 | "from gnss.edit import mark_time_gap\n", 46 | "from helpers import compute_decimal_hours\n", 47 | "\n", 48 | "%matplotlib widget" 49 | ] 50 | }, 51 | { 52 | "cell_type": "markdown", 53 | "metadata": {}, 54 | "source": [ 55 | "## Load a Rinex\n", 56 | "\n", 57 | "The `roktools` module contains a utility method that loads a RINEX file into a convenient data structure (`pandas` DataFrame) that eases data analysis. " 58 | ] 59 | }, 60 | { 61 | "cell_type": "code", 62 | "execution_count": null, 63 | "metadata": { 64 | "tags": [] 65 | }, 66 | "outputs": [], 67 | "source": [ 68 | "#rinex_file = '../assets/SEYG00SYC_R_20140581500_05H_01S_MO.rnx'\n", 69 | "#df = rinex.to_dataframe(rinex_file)\n", 70 | "df = pd.read_parquet('../assets/SEYG00SYC_R_20140581500_05H_01S_MO.rnx.parquet')" 71 | ] 72 | }, 73 | { 74 | "cell_type": "markdown", 75 | "metadata": {}, 76 | "source": [ 77 | "The contents of the `DataFrame` are layout as a data table, with various columns (pseudorange, phase, Doppler, ...).\n", 78 | "To peek the first contents of the RINEX file and check how the data is organized and which are the differents columns of the table, use the `head` function. " 79 | ] 80 | }, 81 | { 82 | "cell_type": "code", 83 | "execution_count": null, 84 | "metadata": { 85 | "tags": [] 86 | }, 87 | "outputs": [], 88 | "source": [ 89 | "df.head()" 90 | ] 91 | }, 92 | { 93 | "cell_type": "markdown", 94 | "metadata": {}, 95 | "source": [ 96 | "Now that the whole RINEX is loaded into the `DataFrame`, you can perform some basics checks such as getting the e.g. list of the satellites contained in the file:" 97 | ] 98 | }, 99 | { 100 | "cell_type": "code", 101 | "execution_count": null, 102 | "metadata": { 103 | "tags": [] 104 | }, 105 | "outputs": [], 106 | "source": [ 107 | "df['sat'].unique()" 108 | ] 109 | }, 110 | { 111 | "cell_type": "markdown", 112 | "metadata": {}, 113 | "source": [ 114 | "You can also select the data for just one satellite" 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": null, 120 | "metadata": { 121 | "tags": [] 122 | }, 123 | "outputs": [], 124 | "source": [ 125 | "\n", 126 | "# Create a Satellite object, that will be used for the DataFrame indexing\n", 127 | "sat = 'G26'\n", 128 | "\n", 129 | "# Create a \"sub-DataFrame\" with the contents for this satellite only\n", 130 | "df_sat = df[df['sat'] == sat]\n", 131 | "df_sat.head()" 132 | ] 133 | }, 134 | { 135 | "cell_type": "markdown", 136 | "metadata": { 137 | "tags": [] 138 | }, 139 | "source": [ 140 | "## Compute the ionospheric combination\n", 141 | "\n", 142 | "In this section we will compute the ionospheric (or geometry-free) combination (see equation [](iono_comb_phase)) for a specific satellite and data combination. To do this, we will use [`pandas` merge function](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.merge.html). The `merge` function allow us to join two `DataFrame`s based on the contents of a column in an efficient, vectorized manner, without cumbersome `for` loops, which typically slow down Python code (and in general should be avoided)\n", 143 | "\n", 144 | "First let's check which are the different tracking channels available for a the previous satellite:" 145 | ] 146 | }, 147 | { 148 | "cell_type": "code", 149 | "execution_count": null, 150 | "metadata": { 151 | "tags": [] 152 | }, 153 | "outputs": [], 154 | "source": [ 155 | "df_sat['channel'].unique()" 156 | ] 157 | }, 158 | { 159 | "cell_type": "markdown", 160 | "metadata": {}, 161 | "source": [ 162 | "To compute the ionospheric combination we need to pick two channels, for this example we will pick the observables generated with the C/A tracking loop at the L1 frequency (RINEX code `1C`) and the encrypted code at the L2 (RINEX code `2W`, usually obtained with proprietary techniques such as *semi-codeless* tracking)" 163 | ] 164 | }, 165 | { 166 | "cell_type": "code", 167 | "execution_count": null, 168 | "metadata": { 169 | "tags": [] 170 | }, 171 | "outputs": [], 172 | "source": [ 173 | "\n", 174 | "ch_a = TrackingChannel.from_string('1C')\n", 175 | "ch_b = TrackingChannel.from_string('2W')\n", 176 | "\n", 177 | "# Select the channels that will be used for this recipe\n", 178 | "df_sat_ch_a = df_sat[df_sat['channel'] == str(ch_a)]\n", 179 | "df_sat_ch_b = df_sat[df_sat['channel'] == str(ch_b)]\n", 180 | "\n", 181 | "# Check the results\n", 182 | "df_sat_ch_a.head()" 183 | ] 184 | }, 185 | { 186 | "cell_type": "markdown", 187 | "metadata": {}, 188 | "source": [ 189 | "Now use the `merge` method to compute the LI combination" 190 | ] 191 | }, 192 | { 193 | "cell_type": "code", 194 | "execution_count": null, 195 | "metadata": { 196 | "tags": [] 197 | }, 198 | "outputs": [], 199 | "source": [ 200 | "df_sat_li = pd.merge(df_sat_ch_a, df_sat_ch_b, on='epoch', how='inner', suffixes=('_a', '_b'))\n", 201 | "df_sat_li.tail()" 202 | ] 203 | }, 204 | { 205 | "cell_type": "markdown", 206 | "metadata": {}, 207 | "source": [ 208 | "The RINEX format specifies the carrier phase as cycles, and therefore to compute the ionospheric combination we will need to convert the data to meters. To do this we first need to compute the wavelength of the data for each tracking channel" 209 | ] 210 | }, 211 | { 212 | "cell_type": "code", 213 | "execution_count": null, 214 | "metadata": { 215 | "tags": [] 216 | }, 217 | "outputs": [], 218 | "source": [ 219 | "# Get the wavelength for each tracking channel\n", 220 | "constellation = Satellite.from_string(sat).constellation\n", 221 | "wl_a = ch_a.get_wavelength(constellation)\n", 222 | "wl_b = ch_b.get_wavelength(constellation)" 223 | ] 224 | }, 225 | { 226 | "cell_type": "markdown", 227 | "metadata": {}, 228 | "source": [ 229 | "Now computing the ionospheric combination is straightforward. We will create the LI combination as a **new column into the `DataFrame`** (to align the data with the epochs for later analysis)." 230 | ] 231 | }, 232 | { 233 | "cell_type": "code", 234 | "execution_count": null, 235 | "metadata": { 236 | "tags": [] 237 | }, 238 | "outputs": [], 239 | "source": [ 240 | "df_sat_li['li'] = df_sat_li['phase_a'] * wl_a - df_sat_li['phase_b'] * wl_b" 241 | ] 242 | }, 243 | { 244 | "cell_type": "markdown", 245 | "metadata": {}, 246 | "source": [ 247 | "We can now plot the LI combination using `matplotlib` plotting functions:" 248 | ] 249 | }, 250 | { 251 | "cell_type": "code", 252 | "execution_count": null, 253 | "metadata": { 254 | "tags": [] 255 | }, 256 | "outputs": [], 257 | "source": [ 258 | "# Close all previous figures\n", 259 | "plt.close()\n", 260 | "\n", 261 | "# Plot the LI against the time\n", 262 | "plt.plot(df_sat_li['epoch'], df_sat_li['li'], '.')\n", 263 | "plt.title(f'LI combination for {sat}')\n", 264 | "plt.xlabel('Time [hour of 2014 doy 058]')\n", 265 | "plt.ylabel('LI [m]')" 266 | ] 267 | }, 268 | { 269 | "cell_type": "markdown", 270 | "metadata": {}, 271 | "source": [ 272 | "## Compute $\\Delta STEC$\n", 273 | "\n", 274 | "We now will create another cell that contain the $\\Delta STEC$, computed as the time derivative of the geometric free combination ($LI$). As shown before (see equation [](iono_comb_phase), besides the $STEC$, the geometry free combination of phases contain the phase ambiguities and the uncalibrated phase biases, which vanish with the time differentiation thanks to its constant nature. Therefore, the only term that will accompany the $\\Delta STEC$ is the phase measurement noise ($\\sqrt{2}\\cdot \\varepsilon_L$), which can be considered in the millimeter range.\n", 275 | "\n", 276 | "To compute the time difference of the geometry free combination, we can simply use the [`numpy.diff` function](https://numpy.org/doc/stable/reference/generated/numpy.diff.html), which, again, will save us from using `for` loops thanks to its vectorized nature." 277 | ] 278 | }, 279 | { 280 | "cell_type": "code", 281 | "execution_count": null, 282 | "metadata": { 283 | "tags": [] 284 | }, 285 | "outputs": [], 286 | "source": [ 287 | "d_li = np.diff(df_sat_li['li'])" 288 | ] 289 | }, 290 | { 291 | "cell_type": "markdown", 292 | "metadata": {}, 293 | "source": [ 294 | "We could now add this new time series into the dataframe. However, we must be careful due to a mismatch in the number of values: because of the difference operator, there is **one less value** in the `d_li` array, therefore, we need to add a sample manually." 295 | ] 296 | }, 297 | { 298 | "cell_type": "code", 299 | "execution_count": null, 300 | "metadata": { 301 | "tags": [] 302 | }, 303 | "outputs": [], 304 | "source": [ 305 | "# Number of samples in the difference time series\n", 306 | "len(d_li)" 307 | ] 308 | }, 309 | { 310 | "cell_type": "code", 311 | "execution_count": null, 312 | "metadata": { 313 | "tags": [] 314 | }, 315 | "outputs": [], 316 | "source": [ 317 | "# Number of samples in the original geometry combination time series\n", 318 | "len(df_sat_li['li'])" 319 | ] 320 | }, 321 | { 322 | "cell_type": "markdown", 323 | "metadata": {}, 324 | "source": [ 325 | "We can insert (in this case prepend) a new sample in a numpy array using the insert method" 326 | ] 327 | }, 328 | { 329 | "cell_type": "code", 330 | "execution_count": null, 331 | "metadata": { 332 | "tags": [] 333 | }, 334 | "outputs": [], 335 | "source": [ 336 | "d_li = np.insert(d_li, 0, np.nan, axis=0)\n", 337 | "len(d_li)" 338 | ] 339 | }, 340 | { 341 | "cell_type": "markdown", 342 | "metadata": {}, 343 | "source": [ 344 | "Now that we have homogenized sizes, we can create the column, but first we will perform some **data editing**, discarding (setting to NaN) all those LI values larger than a threshold (due to time gaps and cycle slips in the data). We can do this first by simple array indexing, to find those values larger than a threshold and then assign those rows to NaN.\n", 345 | "\n", 346 | "In order to have an idea on which are the elements to remove, we can plot the histogram to have an idea on the distribution of samples and have an idea on where to place the thresholds\n" 347 | ] 348 | }, 349 | { 350 | "cell_type": "code", 351 | "execution_count": null, 352 | "metadata": {}, 353 | "outputs": [], 354 | "source": [ 355 | "plt.close()\n", 356 | "_ = plt.hist(d_li)" 357 | ] 358 | }, 359 | { 360 | "cell_type": "markdown", 361 | "metadata": {}, 362 | "source": [ 363 | "From the histogram, it seems that samples below 2.5 could be safely discarded" 364 | ] 365 | }, 366 | { 367 | "cell_type": "code", 368 | "execution_count": null, 369 | "metadata": { 370 | "tags": [] 371 | }, 372 | "outputs": [], 373 | "source": [ 374 | "li_thresold = 1.0\n", 375 | "\n", 376 | "d_li[d_li > +li_thresold] = np.nan\n", 377 | "d_li[d_li < -li_thresold] = np.nan" 378 | ] 379 | }, 380 | { 381 | "cell_type": "markdown", 382 | "metadata": {}, 383 | "source": [ 384 | "The $\\Delta STEC$ is simply the $\\Delta LI$ scaled by the $\\alpha$ factor, that depends on the frequencies used to compute the ionospheric combination" 385 | ] 386 | }, 387 | { 388 | "cell_type": "code", 389 | "execution_count": null, 390 | "metadata": { 391 | "tags": [] 392 | }, 393 | "outputs": [], 394 | "source": [ 395 | "f_a = ch_a.get_frequency(constellation)\n", 396 | "f_b = ch_b.get_frequency(constellation)\n", 397 | "\n", 398 | "alpha = 40.3 / (f_b * f_b) - 40.3 / (f_a * f_a)\n", 399 | "\n", 400 | "df_sat_li['d_stec_tecu_per_s'] = d_li / alpha / 1.0e16" 401 | ] 402 | }, 403 | { 404 | "cell_type": "markdown", 405 | "metadata": {}, 406 | "source": [ 407 | "Now we can plot the STEC" 408 | ] 409 | }, 410 | { 411 | "cell_type": "code", 412 | "execution_count": null, 413 | "metadata": { 414 | "tags": [] 415 | }, 416 | "outputs": [], 417 | "source": [ 418 | "plt.close()\n", 419 | "plt.plot(df_sat_li['epoch'], df_sat_li['d_stec_tecu_per_s'] * 60, marker='.')\n", 420 | "plt.ylabel('Rate of STEC (TECU/min)')\n", 421 | "plt.xlabel('Epoch')\n", 422 | "plt.title('Rate of STEC')" 423 | ] 424 | }, 425 | { 426 | "cell_type": "markdown", 427 | "metadata": {}, 428 | "source": [ 429 | "## Compute ROTI\n", 430 | "\n", 431 | "\n", 432 | "In order to compute the ROTI, we need to group the data in batches of a certain time period (e.g. 5 minutes) and compute the standard deviation for each of these batches. To do this we will use the [`resample` method](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.resample.html), but to use this we first need to set the epoch column as index of the `DataFrame`" 433 | ] 434 | }, 435 | { 436 | "cell_type": "code", 437 | "execution_count": null, 438 | "metadata": { 439 | "tags": [] 440 | }, 441 | "outputs": [], 442 | "source": [ 443 | "# Set the epoch as DataFrame time index. The resample method will use\n", 444 | "# this index as basis for computation\n", 445 | "df_sat_li.set_index('epoch', inplace=True)" 446 | ] 447 | }, 448 | { 449 | "cell_type": "code", 450 | "execution_count": null, 451 | "metadata": { 452 | "tags": [] 453 | }, 454 | "outputs": [], 455 | "source": [ 456 | "# Resample the dSTEC data every 5 minutes\n", 457 | "df_sat_dstec_sampled = df_sat_li['d_stec_tecu_per_s'].resample('5min').std()\n", 458 | "df_sat_dstec_sampled.head()" 459 | ] 460 | }, 461 | { 462 | "cell_type": "markdown", 463 | "metadata": {}, 464 | "source": [ 465 | "Now we can finally plot the data" 466 | ] 467 | }, 468 | { 469 | "cell_type": "code", 470 | "execution_count": null, 471 | "metadata": { 472 | "tags": [] 473 | }, 474 | "outputs": [], 475 | "source": [ 476 | "\n", 477 | "# Convert the pandas index (DateTimeIndex) to seconds for plotting\n", 478 | "datetime_array = df_sat_dstec_sampled.index.to_numpy()\n", 479 | "\n", 480 | "# Calculate the seconds of the day as a vectorized operation\n", 481 | "seconds_of_day = (datetime_array - datetime_array.astype('datetime64[D]')) \n", 482 | "\n", 483 | "# Convert the timedelta values to seconds\n", 484 | "seconds_of_day = seconds_of_day / np.timedelta64(1, 's')\n", 485 | "\n", 486 | "plt.close()\n", 487 | "plt.ylim(0,65)\n", 488 | "plt.xlim(0,86400)\n", 489 | "plt.xticks(np.linspace(0,86400, 7))\n", 490 | "plt.yticks(np.arange(0,65, step=5))\n", 491 | "plt.grid()\n", 492 | "plt.plot(seconds_of_day, df_sat_dstec_sampled * 60)\n", 493 | "plt.ylabel('Rate of TEC Index (TECU/min)')\n", 494 | "plt.xlabel('Seconds of day 2014, doy 58')\n", 495 | "plt.title('Rate of TEC Index for SEY1 station, GPS PRN26')" 496 | ] 497 | }, 498 | { 499 | "cell_type": "markdown", 500 | "metadata": {}, 501 | "source": [ 502 | "### ROTI with affordable receivers\n", 503 | "\n", 504 | "The process can be repeated using an affordable receiver. In this case a\n", 505 | "MEDEA computer (based on the u-blox ZED-F9P and a Talysmann antenna)" 506 | ] 507 | }, 508 | { 509 | "cell_type": "code", 510 | "execution_count": null, 511 | "metadata": {}, 512 | "outputs": [], 513 | "source": [ 514 | "#rinex_file = '../assets/MTIC00ESP_R_20191221131_05H_01S_MO.rnx'\n", 515 | "#df = rinex.to_dataframe(rinex_file)\n", 516 | "#df.to_parquet('../assets/MTIC00ESP_R_20191221131_05H_01S_MO.rnx.parquet')\n", 517 | "df = pd.read_parquet('../assets/MTIC00ESP_R_20191221131_05H_01S_MO.rnx.parquet')" 518 | ] 519 | }, 520 | { 521 | "cell_type": "markdown", 522 | "metadata": {}, 523 | "source": [ 524 | "After loading the RINEX, let's perform some **basic data editing** to flag\n", 525 | "phase breaks due to time gaps" 526 | ] 527 | }, 528 | { 529 | "cell_type": "code", 530 | "execution_count": null, 531 | "metadata": {}, 532 | "outputs": [], 533 | "source": [ 534 | "df = mark_time_gap(df)" 535 | ] 536 | }, 537 | { 538 | "cell_type": "markdown", 539 | "metadata": {}, 540 | "source": [ 541 | "Selection of the channels for which the ionospheric combination needs to be performed" 542 | ] 543 | }, 544 | { 545 | "cell_type": "code", 546 | "execution_count": null, 547 | "metadata": {}, 548 | "outputs": [], 549 | "source": [ 550 | "constellation = ConstellationId.GPS.value\n", 551 | "channel_a = TrackingChannel.from_string('1C')\n", 552 | "channel_b = TrackingChannel.from_string('2L')" 553 | ] 554 | }, 555 | { 556 | "cell_type": "markdown", 557 | "metadata": {}, 558 | "source": [ 559 | "Once the channels have been selected, the ionospheric (or geometry-free) combination\n", 560 | "can be computed" 561 | ] 562 | }, 563 | { 564 | "cell_type": "code", 565 | "execution_count": null, 566 | "metadata": {}, 567 | "outputs": [], 568 | "source": [ 569 | "df_li_gps = compute_geometry_free(df, constellation, channel_a, channel_b)\n", 570 | "\n", 571 | "# Preview the LI values\n", 572 | "df_li_gps.head()" 573 | ] 574 | }, 575 | { 576 | "cell_type": "markdown", 577 | "metadata": {}, 578 | "source": [ 579 | "To compute the Slant Total Electron Content, we will need the $\\alpha_{LI}$ coefficient (that transforms\n", 580 | "LI to Slant Total Electron Content), which can be computed using the\n", 581 | "frequency associated to the channel bands:" 582 | ] 583 | }, 584 | { 585 | "cell_type": "code", 586 | "execution_count": null, 587 | "metadata": {}, 588 | "outputs": [], 589 | "source": [ 590 | "f_a = channel_a.get_frequency(constellation)\n", 591 | "f_b = channel_b.get_frequency(constellation)\n", 592 | "\n", 593 | "alpha = 40.3 / (f_b * f_b) - 40.3 / (f_a * f_a)" 594 | ] 595 | }, 596 | { 597 | "cell_type": "code", 598 | "execution_count": null, 599 | "metadata": {}, 600 | "outputs": [], 601 | "source": [ 602 | "sat = 'G06'\n", 603 | "df_sat = df_li_gps[df_li_gps['sat'] == sat]\n", 604 | "\n", 605 | "t = compute_decimal_hours(df_sat['epoch'])\n", 606 | "plt.close()\n", 607 | "plt.title(f\"LI combination for {sat}\")\n", 608 | "plt.plot(t, df_sat['li_m'], '.')\n", 609 | "plt.xlabel(f\"Time [ hour of {df_sat.iloc[0]['epoch'].date()} ]\")\n", 610 | "plt.ylabel(\"LI [m]\")" 611 | ] 612 | }, 613 | { 614 | "cell_type": "markdown", 615 | "metadata": {}, 616 | "source": [ 617 | "Once the LI combination is obtained, we are in the position of computing the $\\Delta STEC$ as follows:" 618 | ] 619 | }, 620 | { 621 | "cell_type": "code", 622 | "execution_count": null, 623 | "metadata": {}, 624 | "outputs": [], 625 | "source": [ 626 | "df_li_gps['d_stec_tecu_per_s'] = df_li_gps.groupby('sat')['li_m'].diff() / alpha / 1.0e16" 627 | ] 628 | }, 629 | { 630 | "cell_type": "code", 631 | "execution_count": null, 632 | "metadata": {}, 633 | "outputs": [], 634 | "source": [ 635 | "df_sat = df_li_gps[df_li_gps['sat'] == sat]\n", 636 | "\n", 637 | "t = compute_decimal_hours(df_sat['epoch'])\n", 638 | "plt.close()\n", 639 | "plt.title(f\"Time difference of STEC for {sat}\")\n", 640 | "plt.plot(t, df_sat['d_stec_tecu_per_s'], '.')\n", 641 | "plt.xlabel(f\"Time [ hour of {df_sat.iloc[0]['epoch'].date()} ]\")\n", 642 | "plt.ylabel(\"DSTEC [TECU/s]\")\n", 643 | "plt.ylim(-1, 1)" 644 | ] 645 | }, 646 | { 647 | "cell_type": "markdown", 648 | "metadata": {}, 649 | "source": [ 650 | "Once the time difference of the STEC has been computed we can now proceed to\n", 651 | "compute the standard deviation $\\sigma$ for intervals of 5 minutes on a per-satellite\n", 652 | "basis (i.e. definition of ROTI)" 653 | ] 654 | }, 655 | { 656 | "cell_type": "code", 657 | "execution_count": null, 658 | "metadata": {}, 659 | "outputs": [], 660 | "source": [ 661 | "# Set 'epoch' as the DataFrame index\n", 662 | "df_li_gps.set_index('epoch', inplace=True)" 663 | ] 664 | }, 665 | { 666 | "cell_type": "code", 667 | "execution_count": null, 668 | "metadata": {}, 669 | "outputs": [], 670 | "source": [ 671 | "# Group the samples in 5 minute intervals and compute the sigma (i.e. ROTI)\n", 672 | "df_roti = df_li_gps.groupby('sat').resample('5min').std(numeric_only=True).reset_index()" 673 | ] 674 | }, 675 | { 676 | "cell_type": "markdown", 677 | "metadata": {}, 678 | "source": [ 679 | "Plot the ROTI for a satellite" 680 | ] 681 | }, 682 | { 683 | "cell_type": "code", 684 | "execution_count": null, 685 | "metadata": {}, 686 | "outputs": [], 687 | "source": [ 688 | "df_sat = df_roti[df_roti['sat'] == sat]\n", 689 | "\n", 690 | "plt.close()\n", 691 | "\n", 692 | "t = compute_decimal_hours(df_sat['epoch'])\n", 693 | "\n", 694 | "plt.close()\n", 695 | "plt.ylim(0,65)\n", 696 | "plt.yticks(np.arange(0,65, step=5))\n", 697 | "plt.grid()\n", 698 | "plt.ylabel('Rate of TEC Index (TECU/min)')\n", 699 | "plt.xlabel(f\"Time [ hour of {df_sat.iloc[0]['epoch'].date()} ]\")\n", 700 | "plt.title(f'ROTI for affordable receiver, satellite {sat}')\n", 701 | "plt.plot(t, df_sat['d_stec_tecu_per_s'] *60)\n" 702 | ] 703 | }, 704 | { 705 | "cell_type": "markdown", 706 | "metadata": {}, 707 | "source": [ 708 | "In this particular case, no scintillation event was detected (also likely due\n", 709 | "to the fact that the take was performed in mid latitude, in a quiet ionospheric\n", 710 | "period). Note the peaks observed in the data. These are artifacts due to \n", 711 | "cycle slips.\n", 712 | "\n", 713 | "Higher ROTI values have been also detected in other cases at low elevations. \n", 714 | "Care must be exercised in affordable receivers (i.e. atennas) since low\n", 715 | "elevations might include multipath. Therefore, a **conservative elevation mask**\n", 716 | "(e.g. $>15^\\circ$) is recommended when processing affordable receivers" 717 | ] 718 | } 719 | ], 720 | "metadata": { 721 | "kernelspec": { 722 | "display_name": "Python 3 (ipykernel)", 723 | "language": "python", 724 | "name": "python3" 725 | }, 726 | "language_info": { 727 | "codemirror_mode": { 728 | "name": "ipython", 729 | "version": 3 730 | }, 731 | "file_extension": ".py", 732 | "mimetype": "text/x-python", 733 | "name": "python", 734 | "nbconvert_exporter": "python", 735 | "pygments_lexer": "ipython3", 736 | "version": "3.11.6" 737 | } 738 | }, 739 | "nbformat": 4, 740 | "nbformat_minor": 4 741 | } 742 | -------------------------------------------------------------------------------- /references.bib: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | @article{juan2017method, 5 | title={A method for scintillation characterization using geodetic receivers operating at 1 Hz}, 6 | author={Juan, Jose Miguel and Aragon-Angel, A and Sanz, Jaume and Gonz{\'a}lez-Casado, Guillermo and Rovira-Garcia, Adria}, 7 | journal={Journal of Geodesy}, 8 | volume={91}, 9 | number={11}, 10 | pages={1383--1397}, 11 | year={2017}, 12 | publisher={Springer} 13 | } 14 | 15 | @inproceedings{pi2013observations, 16 | author = {X. Pi and A.J. Mannucci and B. Valant-Spaight and Y. Bar-Sever and L. J. Romans and S. Skone and L. Sparks and G. Martin Hall}, 17 | title = {Observations of Global and Regional Ionospheric Irregularities and Scintillation Using GNSS Tracking Networks}, 18 | booktitle = {Proceedings of the ION 2013 Pacific PNT Meeting}, 19 | address = {Honolulu, Hawaii}, 20 | month = {April}, 21 | year = {2013}, 22 | pages = {752-761} 23 | } 24 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ipympl 2 | jupyter-book 3 | matplotlib 4 | numpy 5 | pandas 6 | pyarrow 7 | roktools>=6.6.1 8 | sphinx-proof 9 | -------------------------------------------------------------------------------- /source/gnss/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokubun/gnss_tutorials/4087c0649706ce0b7f1bfb2b57d53e726bd040c9/source/gnss/__init__.py -------------------------------------------------------------------------------- /source/gnss/edit.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | 4 | ARC_ID_FIELD = 'arc_id' 5 | 6 | def compute_phase_arc_id(data: pd.DataFrame) -> pd.DataFrame: 7 | """ 8 | Computes the phase arc ID, that can be used later to perform operations 9 | on a per-arc basis (compute arc bias, ...) 10 | """ 11 | 12 | data[ARC_ID_FIELD] = data.groupby('signal')['slip'].transform('cumsum') 13 | 14 | return data 15 | 16 | 17 | def mark_time_gap(data: pd.DataFrame, threshold_s: float = 5) -> pd.DataFrame: 18 | """ 19 | Mark a phase cycle slip when the time series show a time gap 20 | of a given threshold 21 | """ 22 | 23 | # Function to mark epochs with a difference > threshold 24 | def mark_epochs(group): 25 | EPOCH_FIELD = 'epoch' 26 | marked = group[EPOCH_FIELD].diff() > pd.Timedelta(seconds=threshold_s) 27 | return marked 28 | 29 | # Apply the function per group using groupby 30 | marked_epochs = data.groupby('signal').apply(mark_epochs) 31 | 32 | data['slip'] = np.any([data['slip'], marked_epochs], axis=0) 33 | 34 | data = compute_phase_arc_id(data) 35 | 36 | return data 37 | 38 | def detrend(data:pd.DataFrame, observable: str, n_samples:int) -> pd.DataFrame: 39 | """ 40 | Detrend a given observable by using a rolling window of a certain number of 41 | samples 42 | """ 43 | 44 | trend_column = f'{observable}_trend' 45 | detrended_column = f'{observable}_detrended' 46 | 47 | trend = data.groupby(['signal', ARC_ID_FIELD])[observable].transform(lambda x: x.rolling(n_samples).mean()) 48 | data[trend_column] = trend 49 | data[detrended_column] = data[observable] - data[trend_column] 50 | 51 | return data 52 | 53 | def remove_mean(data: pd.DataFrame, observable: str) -> pd.DataFrame: 54 | 55 | bias_field = f'{observable}_bias' 56 | aligned_field = f'{observable}_aligned' 57 | 58 | # For each arch id, compute the median 59 | data[bias_field] = data.groupby(['signal',ARC_ID_FIELD])[observable].transform('mean') 60 | 61 | data[aligned_field] = data[observable] - data[bias_field] 62 | 63 | return data 64 | -------------------------------------------------------------------------------- /source/gnss/observables.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from roktools.gnss.types import ConstellationId, TrackingChannel 3 | 4 | def compute_geometry_free(data: pd.DataFrame, constellation: ConstellationId, channel_a: TrackingChannel, channel_b: TrackingChannel) -> pd.DataFrame: 5 | """ 6 | Compute the geometry (ionospheric free combination) 7 | """ 8 | 9 | columns = ['epoch', 'constellation', 'sat', 'channel', 'signal', 'range', 'phase', 'slip'] 10 | 11 | # Create a new dataframe 12 | df = data[columns].copy() 13 | 14 | # Create subsets of the DataFrame corresponding to the constellation and each of 15 | # the channels selected to build the ionospheric combination 16 | df_a = df[(df['constellation'] == constellation) & (df['channel'] == str(channel_a))] 17 | df_b = df[(df['constellation'] == constellation) & (df['channel'] == str(channel_b))] 18 | 19 | # Compute the wavelength of the two tracking channels 20 | wl_a = channel_a.get_wavelength(constellation) 21 | wl_b = channel_b.get_wavelength(constellation) 22 | 23 | # Use merge to join the two tables 24 | df_out = pd.merge(df_a, df_b, on=['epoch', 'sat'], how='inner', suffixes=('_a', '_b')) 25 | df_out['li_m'] = df_out['phase_a'] * wl_a - df_out['phase_b'] * wl_b 26 | df_out['pi_m'] = df_out['range_b'] - df_out['range_a'] 27 | 28 | return df_out 29 | 30 | def compute_code_minus_carrier(data: pd.DataFrame) -> pd.DataFrame: 31 | """ 32 | Compute the geometry (ionospheric free combination) 33 | """ 34 | 35 | # Create a new dataframe 36 | df = data.copy() 37 | 38 | # Compute the wavelength 39 | df['wl'] = df.apply(lambda row : TrackingChannel.from_string(row['channel']).get_wavelength(row['constellation']), axis=1) 40 | 41 | df['cmc'] = df['range'] - df['phase'] * df['wl'] 42 | 43 | return df 44 | -------------------------------------------------------------------------------- /source/helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | import numpy as np 3 | import pandas as pd 4 | 5 | 6 | def compute_elapsed_seconds(epochs:pd.Series) -> pd.Series: 7 | return (epochs - epochs.iloc[0]).dt.total_seconds() 8 | 9 | def compute_decimal_hours(epochs:pd.Series) -> pd.Series: 10 | return epochs.apply(lambda x: x.hour + x.minute / 60 + x.second / 3600) 11 | 12 | def compute_rms(values:Iterable) -> float: 13 | return np.sqrt(np.mean(np.square(values))) 14 | --------------------------------------------------------------------------------