From 6b8b0fe9a1c5ccdff7d12cd24cf6208d10351c18 Mon Sep 17 00:00:00 2001 From: Eshanta Mishra Date: Fri, 17 Jul 2026 18:35:19 +0000 Subject: [PATCH 1/2] Add notebook 2 - MVP1 geodesy --- .../NB2-instantaneous-PPP-positions.ipynb | 517 ++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 tutorials/mvp-1-geodesy/NB2-instantaneous-PPP-positions.ipynb diff --git a/tutorials/mvp-1-geodesy/NB2-instantaneous-PPP-positions.ipynb b/tutorials/mvp-1-geodesy/NB2-instantaneous-PPP-positions.ipynb new file mode 100644 index 0000000..de0f986 --- /dev/null +++ b/tutorials/mvp-1-geodesy/NB2-instantaneous-PPP-positions.ipynb @@ -0,0 +1,517 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2abe5d1d-4a2f-4c96-a78c-6c3e5f9ac750", + "metadata": {}, + "source": [ + "# Accessing Instantaneous Positions" + ] + }, + { + "cell_type": "markdown", + "id": "35320cb4-aed2-4420-8f9c-b3deb365e792", + "metadata": {}, + "source": [ + "**Version:** 1.0 | **Last updated:** 2026-07-14 | \n", + "\n", + "**Author:** Eshanta Mishra | **Author institution:** EarthScope Consortium\n", + "\n", + "**License:** CC-BY-4.0" + ] + }, + { + "cell_type": "markdown", + "id": "b9b1d1c3-569f-459a-8595-e1ba758e4bc7", + "metadata": {}, + "source": [ + "## Introduction\n", + "\n", + "**What this notebook does:** The notebook retrieves Instantaneous (high-rate PPP stream) GNSS positions as dataframes for selected stations and time ranges using the EarthScope SDK, and produces visualizations of the position streams and the displacement derived from them.\n", + "\n", + "**Why it is useful:** Instantaneous positions tell us where a station is right now, epoch by epoch. While daily solutions are useful for describing slow tectonic motion, this high-rate stream can capture ground movement that happens in seconds and provides a foundation for studying transient events like earthquakes.\n", + "\n", + "**What you will accomplish:** By the end, you will have retrieved instantaneous positions for one or more stations, inspected and understood data fields that come with the instantaneous positions, derived 2D and 3D displacement magnitudes, and visualized both the position streams and displacement over time.\n", + "\n", + "---\n", + "\n", + "### Prerequisites\n", + "\n", + "Before starting this notebook, you should:\n", + "\n", + "* [ ] Have completed: [Notebook 1 - Accessing GNSS Observations with the EarthScope SDK](NB1-access-gnss-via-SDK.ipynb).\n", + "* [ ] Be familiar with basic Python.\n", + "\n", + "---\n", + "\n", + "### GeoLab Compute Resources\n", + "\n", + "| Setting | Recommended |\n", + "|---|---|\n", + "| **Image** | GeoLab (default image) |\n", + "| **Server size** | 4 GB RAM, ~0.5 CPUs (default server) |" + ] + }, + { + "cell_type": "markdown", + "id": "811fc68e-1e81-482c-82dd-5efa2a89d715", + "metadata": {}, + "source": [ + "## Learning Objectives\n", + "\n", + "By the end of this notebook, you will be able to:\n", + "\n", + "1. Retrieve instantaneous (PPP) positions for one or more stations by station name, time range, facility, and software.\n", + "2. Load the result into a dataframe and interpret every returned field, including position components and their uncertainties.\n", + "3. Derive 2D and 3D displacement magnitudes from the east/north/up components.\n", + "4. Visualize position streams and displacement over time." + ] + }, + { + "cell_type": "markdown", + "id": "1ec3d79a-ad70-4c58-b8fc-287501adb7e9", + "metadata": {}, + "source": [ + "## Relevant Documentation & Resources\n", + "\n", + "* [EarthScope SDK documentation](https://docs.earthscope.org/sdk)\n", + "* [SDK GNSS Observations tutorial](https://docs.earthscope.org/sdk/gnss-obs-tutorial)\n", + "* [Polars User Guide](https://docs.pola.rs)\n", + "* [Altair (plotting)](https://altair-viz.github.io)" + ] + }, + { + "cell_type": "markdown", + "id": "076d400d-8c88-4113-a28d-91dac8648679", + "metadata": {}, + "source": [ + "## Contents\n", + "\n", + "1. [What are Instantaneous Positions?](#id-1-what-are-instantaneous-positions)\n", + "2. [Setup & Imports](#id-2-setup-imports)\n", + "3. [Retrieve Instantaneous Positions](#id-3-retrieve-instantaneous-positions)\n", + "4. [Inspect the Returned Fields](#id-4-inspect-the-returned-fields)\n", + "5. [Derive Displacement](#id-5-derive-displacement)\n", + "6. [Visualize Position Streams & Displacement](#id-6-visualize-position-streams-displacement)\n", + "7. [Exploration Exercises](#id-7-exploration-exercises)\n", + "8. [Troubleshooting & Support](#id-8-troubleshooting-support)" + ] + }, + { + "cell_type": "markdown", + "id": "f6ad6a72-e973-41aa-80f1-7b83114a8c18", + "metadata": {}, + "source": [ + "## 1. What are Instantaneous Positions?" + ] + }, + { + "cell_type": "markdown", + "id": "2b974c85-4624-4a92-94d8-6a097580d88f", + "metadata": {}, + "source": [ + "A GNSS station's position can be estimated in different ways depending on how much data goes into each estimate.\n", + "\n", + "A very common geodetic product is a **daily position time series** which provides one position per station per day, formed by combining a full 24 hours of observations. Averaging over a day suppresses the noise down to millimeter precision. Stacking those daily positions over years yields the velocity fields that reveal slow tectonic motion such as plate drift and interseismic strain at the level of a few millimeters per year. The tradeoff of this precision is the time resolution because one point per day cannot be used to show anything that happens in seconds.\n", + "\n", + "**Instantaneous positions** make the opposite tradeoff. Using Precise Point Positioning (PPP), the station's position is estimated epoch by epoch (once per second in this case). So you can see the current position of the stations continously. Individual estimates are far noisier than daily solutions. However, the high sampling rate makes it possible to detect rapid, transient motion in real time, such as an earthquake displacing a station within seconds. A daily solution would instead combine that motion into a single averaged position.\n", + "\n", + "> **Note:** Derived daily position time series are not yet available through the SDK; this notebook focuses on the instantaneous (high-rate PPP) stream, which is already available." + ] + }, + { + "cell_type": "markdown", + "id": "cca79b91-0348-4ee2-842e-740595288820", + "metadata": {}, + "source": [ + "## 2. Setup & Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8cd2903c-1b18-4877-a421-f19374f8fcf3", + "metadata": {}, + "outputs": [], + "source": [ + "# Standard library imports\n", + "import datetime as dt\n", + "\n", + "# Third-party imports\n", + "import altair as alt\n", + "import polars as pl\n", + "from earthscope_sdk import AsyncEarthScopeClient\n", + "\n", + "# Enable the Rust (vegafusion) backend so Altair can handle larger datasets efficiently\n", + "alt.data_transformers.enable(\"vegafusion\")\n", + "\n", + "es = AsyncEarthScopeClient()" + ] + }, + { + "cell_type": "markdown", + "id": "2448136f-5067-4c1f-98eb-96ea08b06ee4", + "metadata": {}, + "source": [ + "Polars' `.plot` accessor uses Altair under the hood, with vegafusion as its fast backend." + ] + }, + { + "cell_type": "markdown", + "id": "aa3e8c77-ceaf-4f6e-be2e-b99b0b7f0125", + "metadata": {}, + "source": [ + "### Configuration\n", + "\n", + "Set your parameters here before running the rest of the notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15ecb388-d718-4949-a072-5db6cd398d65", + "metadata": {}, + "outputs": [], + "source": [ + "# Modify these values before running the notebook.\n", + "\n", + "STATIONS = [\"P146\", \"P147\", \"P148\"] # one or more station IDs\n", + "FACILITY = \"cwu\" # analysis center producing the stream\n", + "SOFTWARE = \"fastlane\" # PPP software producing the stream\n", + "META_FIELDS = [\"geosncl\"] # extra stream-metadata columns to attach\n", + "START = dt.datetime(2026, 6, 4, 10) # query start (UTC)\n", + "END = dt.datetime(2026, 6, 4, 12) # query end (UTC)" + ] + }, + { + "cell_type": "markdown", + "id": "c1c52777-0410-4a6f-b3e6-198cfeebb318", + "metadata": {}, + "source": [ + "## 3. Retrieve Instantaneous Positions\n", + "\n", + "In this step, we will retrieve high-rate PPP position stream for one or more stations, returned as an Apache Arrow table.\n", + "\n", + "As with observations on [Notebook 1](NB1-access-gnss-via-SDK.ipynb), the SDK returns Arrow, which converts into a dataframe with little or no copying. The stream is selected not just by station and time, but also by the processing pipeline that produced it such as the analysis `facility` and the `software`.\n", + "\n", + "Each argument below narrows what you get:\n", + "\n", + "* `station_name`: one station ID or a list\n", + "* `facility`: the analysis center producing the stream (here `\"cwu\"`)\n", + "* `software`: the PPP engine (here `\"fastlane\"`)\n", + "* `meta_fields`: extra stream-metadata columns to attach (here `\"geosncl\"`, the stream identifier)\n", + "\n", + "The expected result is roughly 21,600 rows, i.e. 3 stations x 2 hours x 3600 seconds, since the stream is 1 Hz." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "475dd2e2-9db8-453d-b495-d6afa1417d8b", + "metadata": {}, + "outputs": [], + "source": [ + "# Fetch a 2-hour window of 1 Hz instantaneous positions for the selected stations.\n", + "table = await es.data.gnss_instantaneous_positions(\n", + " start_datetime=START,\n", + " end_datetime=END,\n", + " station_name=STATIONS,\n", + " facility=FACILITY,\n", + " software=SOFTWARE,\n", + " meta_fields=META_FIELDS,\n", + ").fetch()\n", + "\n", + "df = pl.from_arrow(table).sort(\"timestamp\")\n", + "print(f\"{len(df):,} rows\")\n", + "df.head()" + ] + }, + { + "cell_type": "markdown", + "id": "6069a914-e056-472a-a968-516aff9acf58", + "metadata": {}, + "source": [ + "## 4. Inspect the Returned Fields\n", + "\n", + "Get to know the data before analyzing it: which columns came back, how often the stream samples, which stations are present, and where values are missing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "161efa31-b733-4d24-a5de-595b688bf9ae", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Columns:\", df.columns)\n", + "print(\"Streams:\", df[\"geosncl\"].unique().sort().to_list())" + ] + }, + { + "cell_type": "markdown", + "id": "dbacb342-cda9-4a12-9a58-b47d8e487342", + "metadata": {}, + "source": [ + "### Sampling Interval" + ] + }, + { + "cell_type": "markdown", + "id": "0d4d0465-7f19-43e8-a344-73060dc3ab28", + "metadata": {}, + "source": [ + "Look at the spacing between consecutive epochs for a single stream. It should be 1 seconds." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92c6c91f-c237-4164-98f9-bae98288a0e0", + "metadata": {}, + "outputs": [], + "source": [ + "one = df.filter(pl.col(\"geosncl\").str.starts_with(STATIONS[0])).sort(\"timestamp\")\n", + "one.select(pl.col(\"timestamp\").diff().alias(\"dt\"))[\"dt\"].drop_nulls().value_counts(sort=True).head()" + ] + }, + { + "cell_type": "markdown", + "id": "c7a92d10-ec53-4cc8-80d2-2909ad699ade", + "metadata": {}, + "source": [ + "### Missing values" + ] + }, + { + "cell_type": "markdown", + "id": "0482145d-6c24-48b4-ab4a-20439d071593", + "metadata": {}, + "source": [ + "Some epochs may lack a horizontal solution, so `east` / `north` can be null even when `up` is present. Count nulls per column. This matters when we derive displacement below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dbfeb030-1f33-4c71-bb81-2cc45ded6ffc", + "metadata": {}, + "outputs": [], + "source": [ + "df.null_count()" + ] + }, + { + "cell_type": "markdown", + "id": "e4ab8121-60a6-4f03-8474-9939434fa14f", + "metadata": {}, + "source": [ + "### What each field means" + ] + }, + { + "cell_type": "markdown", + "id": "d50450ea-b0cb-4398-9f31-30073cb18ab2", + "metadata": {}, + "source": [ + "| Column | Type | Meaning |\n", + "|---|---|---|\n", + "| `timestamp` | datetime (UTC) | Epoch of the position estimate. The stream is 1 Hz, i.e. one row per second, per station. |\n", + "| `east` | float | East displacement from the station's reference position, in meters. |\n", + "| `north` | float | North displacement, in meters. |\n", + "| `up` | float | Vertical (up) displacement, in meters. |\n", + "| `sig_ee` | float | 1-sigma uncertainty on `east`, in meters. |\n", + "| `sig_nn` | float | 1-sigma uncertainty on `north`, in meters. |\n", + "| `sig_uu` | float | 1-sigma uncertainty on `up`, in meters. |\n", + "| `q_channel` | int | Integer-encoded quality and processing status information for each position estimate. |\n", + "| `ingest_latency` | duration | Time between the observation epoch and its ingest by the server. |\n", + "| `processing_delay` | duration | Time taken to process the epoch. |\n", + "| `geosncl` | str | Compound stream identifier: station.network.channel-location (e.g. `P146.PW.LY_.00`). |" + ] + }, + { + "cell_type": "markdown", + "id": "b3f044a4-6ee3-4aba-a4c9-74793839fd72", + "metadata": {}, + "source": [ + "## 5. Derive Displacement\n", + "\n", + "The position components combine into a single displacement magnitude which tells us how far the station sits from its reference position at each epoch. Horizontal (2D) uses east and north; total (3D) adds the vertical:\n", + "\n", + "$$d_{2D} = \\sqrt{east^2 + north^2} \\qquad d_{3D} = \\sqrt{east^2 + north^2 + up^2}$$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42de7c31-9908-4286-92fb-4afa03f5f297", + "metadata": {}, + "outputs": [], + "source": [ + "disp = df.with_columns(\n", + " (pl.col(\"east\").pow(2) + pl.col(\"north\").pow(2)).sqrt().alias(\"disp_2d\"),\n", + " (pl.col(\"east\").pow(2) + pl.col(\"north\").pow(2) + pl.col(\"up\").pow(2)).sqrt().alias(\"disp_3d\"),\n", + ")\n", + "\n", + "disp.select([\"timestamp\", \"geosncl\", \"east\", \"north\", \"up\", \"disp_2d\", \"disp_3d\"]).head()" + ] + }, + { + "cell_type": "markdown", + "id": "efe69ab7-0e85-4692-a375-6dfd6a87fa48", + "metadata": {}, + "source": [ + "Note that the square root propagates nulls: if `east` or `north` is missing for an epoch, that epoch's `disp_2d` and `disp_3d` are null too. Drop them before computing statistics if needed using:\n", + "\n", + "```python\n", + "disp_clean = disp.drop_nulls(subset=[\"disp_2d\"])\n", + "```\n", + "\n", + "> **Check:** For a quiet station, horizontal displacement (`disp_2d`) should stay small and roughly steady — a few centimeters of PPP scatter around the reference. A sudden *step* in this value over time is what ground motion (e.g. an earthquake) would look like." + ] + }, + { + "cell_type": "markdown", + "id": "7a66e9a7-c8c7-4b1d-91da-0e4471397815", + "metadata": {}, + "source": [ + "## 6. Visualize Position Streams & Displacement\n", + "\n", + "### The position components over time\n", + "\n", + "Reshape one station's `east` / `north` / `up` into long form and plot them together to see the three streams at once." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "538f9587-fa9f-4e50-a746-b8d2b9c28f25", + "metadata": {}, + "outputs": [], + "source": [ + "one_long = df.filter(pl.col(\"geosncl\").str.starts_with(STATIONS[0])).unpivot(\n", + " [\"east\", \"north\", \"up\"],\n", + " index=\"timestamp\",\n", + " variable_name=\"component\",\n", + " value_name=\"meters\",\n", + ")\n", + "\n", + "one_long.plot.line(x=\"timestamp\", y=\"meters\", color=\"component\").properties(\n", + " width=800, height=300, title=f\"{STATIONS[0]}: position components over time\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "217c22a5-5f14-4bad-81c3-4a64e9bafae6", + "metadata": {}, + "source": [ + "### Displacement over time\n", + "\n", + "Plot the 2D horizontal displacement for every station together. A quiet station traces a roughly flat, noisy band; a step would signal real motion." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5c5632d-72c5-41fe-8484-37acb86f7091", + "metadata": {}, + "outputs": [], + "source": [ + "disp.plot.line(x=\"timestamp\", y=\"disp_2d\", color=\"geosncl\").properties(\n", + " width=800, height=300, title=\"2D horizontal displacement over time\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "6aa31eda-ecd7-490d-8438-82b45706b732", + "metadata": {}, + "source": [ + "### A note on stream timeliness\n", + "\n", + "The `ingest_latency` and `processing_delay` columns describe how *timely* the stream is, not how accurate the positions are. They are Polars **Duration** types — and Altair can only plot **numeric** axes, so you must convert a Duration to a number (milliseconds) before plotting. This is a common trap." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7d463152-2785-453e-97d4-6f52f5e4e380", + "metadata": {}, + "outputs": [], + "source": [ + "# Altair plots only numeric axes, so convert the Duration column to milliseconds first.\n", + "df.with_columns(\n", + " pl.col(\"ingest_latency\").dt.total_milliseconds().alias(\"ingest_latency_ms\")\n", + ").plot.line(x=\"timestamp\", y=\"ingest_latency_ms\", color=\"geosncl\").properties(\n", + " width=800, height=300, title=\"Ingest latency (ms)\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "c5008800-7811-4fbc-9721-9869da927fc2", + "metadata": {}, + "source": [ + "## 7. Exploration Exercises\n", + "\n", + "Try modifying the parameters to explore how the results change.\n", + "\n", + "1. **Different stations or window:** Change `STATIONS` and the `START`/`END` window in Configuration and re-run. Do all stations return a stream for your window?\n", + "2. **Show the uncertainty:** Plot `up` for one station with a shaded band of +/- `sig_uu` around it (hint: Altair's `mark_area` or `mark_errorband`). How wide is the vertical uncertainty compared to the signal?\n", + "3. **Noisiest station:** Compute the per-station standard deviation of `disp_2d` and identify which station is noisiest." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "edf9b94a-b045-4fcf-be34-f2e2f958f124", + "metadata": {}, + "outputs": [], + "source": [ + "# Exploration cell — use this space to experiment" + ] + }, + { + "cell_type": "markdown", + "id": "1e147f5a-2352-4a16-b28b-3f5c1dbea902", + "metadata": {}, + "source": [ + "## 8. Troubleshooting & Support\n", + "\n", + "### Common Issues\n", + "\n", + "| Error | Likely cause | Fix |\n", + "|---|---|---|\n", + "| Plotting error on a duration column | Altair cannot plot Duration types directly | Convert with `.dt.total_milliseconds()` before plotting |\n", + "| Null `disp_2d` / `disp_3d` values | `east` or `north` was null for that epoch | Use `drop_nulls(subset=[\"disp_2d\"])` before computing statistics |\n", + "\n", + "### Further Resources\n", + "\n", + "* [EarthScope SDK Documentation](https://docs.earthscope.org/sdk)\n", + "* [GeoLab Documentation](https://docs.earthscope.org/geolab)\n", + "* [GeoLab Community Forum](https://earthscope.discourse.group/latest)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From c0a3cf5445a81853dc7d5d81578a8646f9181a8b Mon Sep 17 00:00:00 2001 From: Eshanta Mishra Date: Mon, 27 Jul 2026 20:38:25 +0000 Subject: [PATCH 2/2] Modify to match new GeoLab template --- .../NB2-instantaneous-PPP-positions.ipynb | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tutorials/mvp-1-geodesy/NB2-instantaneous-PPP-positions.ipynb b/tutorials/mvp-1-geodesy/NB2-instantaneous-PPP-positions.ipynb index de0f986..c1fcd3c 100644 --- a/tutorials/mvp-1-geodesy/NB2-instantaneous-PPP-positions.ipynb +++ b/tutorials/mvp-1-geodesy/NB2-instantaneous-PPP-positions.ipynb @@ -13,10 +13,14 @@ "id": "35320cb4-aed2-4420-8f9c-b3deb365e792", "metadata": {}, "source": [ - "**Version:** 1.0 | **Last updated:** 2026-07-14 | \n", + "**Version:** 1.0 | **Last updated:** 2026-07-14 \n", "\n", "**Author:** Eshanta Mishra | **Author institution:** EarthScope Consortium\n", "\n", + "**Maintainer:** EarthScope OnRamp Team | **Maintainer's contact :** help@earthscope.org\n", + "\n", + "**Estimated Time:** ~ 45 minutes | **Pathway:** MVP1\n", + "\n", "**License:** CC-BY-4.0" ] }, @@ -153,7 +157,7 @@ "id": "2448136f-5067-4c1f-98eb-96ea08b06ee4", "metadata": {}, "source": [ - "Polars' `.plot` accessor uses Altair under the hood, with vegafusion as its fast backend." + "Polars' `.plot` uses Altair under the hood, with vegafusion as its fast backend." ] }, { @@ -274,6 +278,14 @@ "one.select(pl.col(\"timestamp\").diff().alias(\"dt\"))[\"dt\"].drop_nulls().value_counts(sort=True).head()" ] }, + { + "cell_type": "markdown", + "id": "7f9d7fa5-13ba-42fd-b1ba-8d357547460c", + "metadata": {}, + "source": [ + "> Note: For a 1 Hz stream, any gap larger than 1s means epochs are missing from the table. For example, 2s is one dropped epoch, 3s is two, and so on. A few of these is normal for a real-time stream." + ] + }, { "cell_type": "markdown", "id": "c7a92d10-ec53-4cc8-80d2-2909ad699ade", @@ -287,7 +299,7 @@ "id": "0482145d-6c24-48b4-ab4a-20439d071593", "metadata": {}, "source": [ - "Some epochs may lack a horizontal solution, so `east` / `north` can be null even when `up` is present. Count nulls per column. This matters when we derive displacement below." + "`east` and `north` are occasionally null while `up` is populated. Count nulls per column before deriving anything below. The square root propagates nulls, so those epochs drop out of displacements in the subsequent section too." ] }, { @@ -360,13 +372,13 @@ "id": "efe69ab7-0e85-4692-a375-6dfd6a87fa48", "metadata": {}, "source": [ - "Note that the square root propagates nulls: if `east` or `north` is missing for an epoch, that epoch's `disp_2d` and `disp_3d` are null too. Drop them before computing statistics if needed using:\n", + "Note that the square root propagates nulls in polar: if `east` or `north` is missing for an epoch, that epoch's `disp_2d` and `disp_3d` are null too. Drop them before computing statistics if needed using:\n", "\n", "```python\n", "disp_clean = disp.drop_nulls(subset=[\"disp_2d\"])\n", "```\n", "\n", - "> **Check:** For a quiet station, horizontal displacement (`disp_2d`) should stay small and roughly steady — a few centimeters of PPP scatter around the reference. A sudden *step* in this value over time is what ground motion (e.g. an earthquake) would look like." + "> **Check:** For a quiet station, horizontal displacement (`disp_2d`) should stay small and roughly steady i.e. a few centimeters of PPP scatter around the reference. A sudden *step* in this value over time is what ground motion (e.g. an earthquake) would look like." ] }, { @@ -429,7 +441,7 @@ "source": [ "### A note on stream timeliness\n", "\n", - "The `ingest_latency` and `processing_delay` columns describe how *timely* the stream is, not how accurate the positions are. They are Polars **Duration** types — and Altair can only plot **numeric** axes, so you must convert a Duration to a number (milliseconds) before plotting. This is a common trap." + "The `ingest_latency` and `processing_delay` columns describe how *timely* the stream is, not how accurate the positions are. They are Polars `Duration` types and Altair can only plot numeric axes, so you must convert a Duration to a number (milliseconds) before plotting." ] }, {