diff --git a/tutorials/mvp-1-geodesy/NB4-Satellite-Geometry.ipynb b/tutorials/mvp-1-geodesy/NB4-Satellite-Geometry.ipynb new file mode 100644 index 0000000..a50f80d --- /dev/null +++ b/tutorials/mvp-1-geodesy/NB4-Satellite-Geometry.ipynb @@ -0,0 +1,679 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a841e500-633b-45cb-823e-6109004f6e79", + "metadata": {}, + "source": [ + "# Satellite Geometry: Positions, Elevation, and Azimuth" + ] + }, + { + "cell_type": "markdown", + "id": "ed65b0d0-7a86-4280-8bb5-87e60b18f1bb", + "metadata": {}, + "source": [ + "**Version:** 1.0 | **Last updated:** 2026-07-31\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:** ~ 40 minutes | **Pathway:** MVP1\n", + "\n", + "**License:** CC-BY-4.0" + ] + }, + { + "cell_type": "markdown", + "id": "68319035-c381-4f0c-97bd-551ecb17e538", + "metadata": {}, + "source": [ + "## Introduction\n", + "\n", + "**What this notebook does:** It retrieves GNSS satellite positions with the EarthScope SDK, computes the elevation and azimuth of each satellite as seen from a ground station, and joins that geometry to the observations recorded at the same station.\n", + "\n", + "**Why it is useful:** An observation on its own tells us the strength of a signal but not where that signal came from. Satellite ephemeris positions supply the direction, so each measurement can be related to the satellite position in the sky. Once each measurement is tagged with an elevation and an azimuth, we can see how signal quality varies with position in the sky, which is the basis for elevation masks, multipath assessment, and GNSS reflectometry.\n", + "\n", + "**What you will accomplish:** By the end of this notebook, we will produce a sky plot for a GNSS station showing how signal strength varies across the visible hemisphere, built from a dataframe in which every observation carries the elevation and azimuth of the satellite that produced it.\n", + "\n", + "---\n", + "\n", + "### Prerequisites\n", + "\n", + "Before starting this notebook, you should:\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": "b086ef21-6182-441c-a407-a7f88cc8be2e", + "metadata": {}, + "source": [ + "## Learning Objectives\n", + "\n", + "By the end of this notebook, you will be able to:\n", + "\n", + "1. Retrieve satellite ephemeris positions for selected constellations and time ranges\n", + "2. Compute elevation and azimuth to each satellite from a reference station location\n", + "3. Join satellite geometry to GNSS observations on timestamp, satellite, and system\n", + "4. Visualize signal strength as a function of elevation and azimuth" + ] + }, + { + "cell_type": "markdown", + "id": "d904898d-0daf-48d2-9418-b455e118d975", + "metadata": {}, + "source": [ + "## Relevant Documentation & Resources\n", + "\n", + "* [EarthScope SDK documentation](https://docs.earthscope.org/sdk)\n", + "* [SDK GNSS Satellite Ephemeris Positions tutorial](https://docs.earthscope.org/sdk/gnss-eph-pos-tutorial)\n", + "* [Plotly](https://plotly.com/python/)\n", + "* [Altair](https://altair-viz.github.io/)" + ] + }, + { + "cell_type": "markdown", + "id": "c8ee8616-294f-4537-a42a-f0ddee63ea8f", + "metadata": {}, + "source": [ + "## Contents\n", + "\n", + "1. [What is Satellite Geometry?](#id-1-what-is-satellite-geometry)\n", + "2. [Setup & Imports](#id-2-setup-imports)\n", + "3. [Retrieve Satellite Positions](#id-3-retrieve-satellite-positions)\n", + "4. [Visualize Satellite Orbits](#id-4-visualize-satellite-orbits)\n", + "5. [Elevation and Azimuth from a Station](#id-5-elevation-and-azimuth-from-a-station)\n", + "6. [Joining Geometry with Observations](#id-6-joining-geometry-with-observations)\n", + "7. [SNR vs Elevation](#id-7-snr-vs-elevation)\n", + "8. [Polar Sky Plot](#id-8-polar-sky-plot)\n", + "9. [Exploration Exercises](#id-9-exploration-exercises)\n", + "10. [Troubleshooting & Support](#id-10-troubleshooting-support)" + ] + }, + { + "cell_type": "markdown", + "id": "ce572fbd-2041-45ec-b7e4-8370e7b50275", + "metadata": {}, + "source": [ + "## 1. What is Satellite Geometry?" + ] + }, + { + "cell_type": "markdown", + "id": "63425e8b-0a6c-4742-8c97-0862a42d037b", + "metadata": {}, + "source": [ + "Every GNSS observation is a measurement of a signal that travelled from a satellite to a receiver.Where that satellite was at the moment of the measurement is not part of the observation record, but it is known because the satellites broadcast their own orbital information. EarthScope provides these positions as a separate data product within the SDK.\n", + "\n", + "**Ephemeris positions** are satellite locations given in an Earth-Centered, Earth-Fixed (ECEF) frame. The origin sits at the center of the Earth, and the axes rotate with the planet, so a fixed point on the ground keeps the same coordinates all day. Positions are in meters, as `x`, `y`, and `z`.\n", + "\n", + "ECEF is convenient for computation but hard to interpret because the coordinates alone do not tell us whether a satellite was overhead or near the horizon. For that, the position has to be expressed relative to a particular place on the ground:\n", + "\n", + "1. **Elevation**: It is the angle above the local horizon, from 0 degrees at the horizon to 90 degrees directly overhead. Negative values mean the satellite is below the horizon and not visible.\n", + "2. **Azimuth**: It is the compass direction to the satellite, measured in degrees clockwise from north, so 0 is north, 90 is east, 180 is south, and 270 is west.\n", + "\n", + "Together, elevation and azimuth place a satellite on the dome of sky above a station. The SDK will compute both if you give it a reference point, so you do not have to do the coordinate conversion yourself or get separate orbit files such as when working with RINEX data.\n", + "\n", + "**Why geometry matters for the observations.** A signal from a satellite near the horizon travelsa longer path through the atmosphere and arrives at a shallower angle, where it is more easily obstructed and more likely to reach the antenna after bouncing off the ground or a nearby surface. Signals from high overhead do not have these problems to the same degree. Elevation is therefore one of the strongest predictors of signal quality, and Sections 7 and 8 show that relationship directly." + ] + }, + { + "cell_type": "markdown", + "id": "94882772-c564-4780-9328-e2d2d69bf986", + "metadata": {}, + "source": [ + "## 2. Setup & Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a241c377-5bbd-4303-a4c1-98e421fd1632", + "metadata": {}, + "outputs": [], + "source": [ + "import datetime as dt\n", + "\n", + "import altair as alt\n", + "import numpy as np\n", + "import plotly.colors as pcolors\n", + "import plotly.graph_objects as go\n", + "import plotly.io as pio\n", + "import polars as pl\n", + "\n", + "from earthscope_sdk import AsyncEarthScopeClient\n", + "from earthscope_sdk.client.data_access.models import (\n", + " FloatFilter,\n", + " GeodeticCoordinate,\n", + " SatelliteSystem,\n", + ")\n", + "\n", + "alt.data_transformers.enable(\"vegafusion\") # Enable rust backend for altair\n", + "pio.renderers.default = \"notebook\" # Required for plotly to render in the notebook\n", + "\n", + "es = AsyncEarthScopeClient()" + ] + }, + { + "cell_type": "markdown", + "id": "a56dafbd-e1d6-4eb7-a13a-5eb5615f5d68", + "metadata": {}, + "source": [ + "### Configuration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43755c3f-64ea-4805-a2df-de54de9c545f", + "metadata": {}, + "outputs": [], + "source": [ + "STATION = \"P041\" # GNSS station (4-character ID)\n", + "SESSION = \"A\" # Session name\n", + "START = dt.datetime(2025, 9, 22) # Query start (UTC)\n", + "END = dt.datetime(2025, 9, 23) # Query end (UTC)\n", + "\n", + "SYSTEM = \"G\" # Constellation: GPS\n", + "SATELLITES = [5, 7, 11, 13] # Satellite numbers to follow\n", + "OBS_CODE = \"1C\" # Signal: GPS L1 C/A\n", + "\n", + "# The reference point on the ground, used to compute elevation and azimuth.\n", + "STATION_LOCATION = GeodeticCoordinate(\n", + " latitude=39.7392358,\n", + " longitude=-104.990251,\n", + " height=1000,\n", + ")\n", + "\n", + "ORBIT_INTERVAL = dt.timedelta(minutes=5) # Sample spacing for the orbit plot\n", + "AZEL_INTERVAL = dt.timedelta(seconds=15) # Sample spacing for elevation and azimuth" + ] + }, + { + "cell_type": "markdown", + "id": "907daf8a-ed64-481e-be21-533e2fc9d69f", + "metadata": {}, + "source": [ + "> **Note:** `AZEL_INTERVAL` is 15 seconds because that is the sampling interval of the observations at this station. Section 6 joins the two datasets on their timestamps, and a join only matches rows whose timestamps agree exactly. If you change one of these intervals, the other has to follow." + ] + }, + { + "cell_type": "markdown", + "id": "1bd089b6-4e99-4772-831f-75e954bebc4c", + "metadata": {}, + "source": [ + "## 3. Retrieve Satellite Positions" + ] + }, + { + "cell_type": "markdown", + "id": "fbb267e2-0d09-400b-868c-39a8b70bf272", + "metadata": {}, + "source": [ + "**What:** Satellite positions in the ECEF frame, for every constellation, sampled every five minutes across one day, retrieved with `gnss_ephemeris_positions()`.\n", + "\n", + "**Why:** Five-minute sampling is coarse for analysis but ample for drawing orbits, and it keeps the request small. Orbits are smooth, so a satellite's path is well described by relatively few points.\n", + "\n", + "**Expected result:** A dataframe of `timestamp`, `system`, `satellite`, `x`, `y`, `z`, with 32,392 rows. Coordinates are in meters." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "764ed061-65b7-4dd3-8bff-aa486b993abc", + "metadata": {}, + "outputs": [], + "source": [ + "table = await es.data.gnss_ephemeris_positions(\n", + " start_datetime=START,\n", + " end_datetime=END,\n", + " field=[\"x\", \"y\", \"z\"],\n", + " system=[\"G\", \"R\", \"E\", \"C\", \"S\"],\n", + " sample_interval=ORBIT_INTERVAL,\n", + ").fetch()\n", + "\n", + "orbits = pl.from_arrow(table).sort(\"timestamp\")\n", + "print(f\"{len(orbits):,} rows\")\n", + "orbits.head()" + ] + }, + { + "cell_type": "markdown", + "id": "8fe7e6cb-4c09-4e32-a43a-1c11deecec46", + "metadata": {}, + "source": [ + "> **Check:** The `system` column should contain the constellation codes introduced in Notebook 1: `G` for GPS, `R` for GLONASS, `E` for Galileo, `C` for BeiDou, and `S` for SBAS. An interesting observation could be that this request has no `station_name`: satellite positions do not depend on who is observing them." + ] + }, + { + "cell_type": "markdown", + "id": "c2f967e6-413e-43bd-9941-c51ba15d6a81", + "metadata": {}, + "source": [ + "## 4. Visualize Satellite Orbits" + ] + }, + { + "cell_type": "markdown", + "id": "b702a1f1-dcbe-431f-b6ef-5a8f217915f3", + "metadata": {}, + "source": [ + "The function below plots satellite trajectories in the ECEF frame, with a wireframe ellipsoid at\n", + "the Earth's own radii for scale. Each satellite is drawn as a separate line, so one constellation\n", + "produces a set of interleaved orbital tracks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9e64c7ae-b6f6-4b75-a40b-b749941c0622", + "metadata": {}, + "outputs": [], + "source": [ + "def plot_satellite_positions(df: pl.DataFrame, system: SatelliteSystem):\n", + " \"\"\"\n", + " Plot satellite positions in ECEF reference frame with an earth-sized ellipsoid.\n", + " \"\"\"\n", + " # Group coordinates by svid\n", + " coords = df.filter(pl.col(\"system\") == system).to_dicts()\n", + " coords_by_svid = {}\n", + " for coord in coords:\n", + " svid = coord[\"satellite\"]\n", + " if svid not in coords_by_svid:\n", + " coords_by_svid[svid] = {\"x\": [], \"y\": [], \"z\": [], \"timestamp\": []}\n", + " coords_by_svid[svid][\"x\"].append(coord[\"x\"])\n", + " coords_by_svid[svid][\"y\"].append(coord[\"y\"])\n", + " coords_by_svid[svid][\"z\"].append(coord[\"z\"])\n", + " coords_by_svid[svid][\"timestamp\"].append(coord[\"timestamp\"])\n", + "\n", + " # Earth's radii in meters (WGS84)\n", + " equatorial_radius = 6378137.0\n", + " polar_radius = 6356752.3\n", + "\n", + " # Create the Earth's ellipsoid wireframe\n", + " u = np.linspace(0, 2 * np.pi, 100)\n", + " v = np.linspace(0, np.pi, 100)\n", + " x_earth = equatorial_radius * np.outer(np.cos(u), np.sin(v))\n", + " y_earth = equatorial_radius * np.outer(np.sin(u), np.sin(v))\n", + " z_earth = polar_radius * np.outer(np.ones(np.size(u)), np.cos(v))\n", + "\n", + " earth_wireframe = go.Surface(\n", + " x=x_earth,\n", + " y=y_earth,\n", + " z=z_earth,\n", + " colorscale=\"Blues\",\n", + " showscale=False,\n", + " opacity=0.2,\n", + " name=\"Earth\",\n", + " )\n", + "\n", + " # Create a 3D scatter plot for each satellite trajectory\n", + " satellite_traces = []\n", + " colors = pcolors.qualitative.Plotly\n", + " for i, (svid, coords_list) in enumerate(coords_by_svid.items()):\n", + " satellite_traces.append(\n", + " go.Scatter3d(\n", + " x=coords_list[\"x\"],\n", + " y=coords_list[\"y\"],\n", + " z=coords_list[\"z\"],\n", + " mode=\"lines\",\n", + " text=coords_list[\"timestamp\"],\n", + " hoverinfo=\"all\",\n", + " line=dict(color=colors[i % len(colors)], width=2),\n", + " name=f\"SVID {svid}\",\n", + " )\n", + " )\n", + "\n", + " fig = go.Figure(data=[earth_wireframe] + satellite_traces)\n", + "\n", + " # Update the layout for a cleaner look\n", + " fig.update_layout(\n", + " title=\"Satellite XYZ Coordinates (System: {})\".format(system),\n", + " scene=dict(\n", + " xaxis_title=\"X Coordinate\",\n", + " yaxis_title=\"Y Coordinate\",\n", + " zaxis_title=\"Z Coordinate\",\n", + " aspectmode=\"data\",\n", + " ),\n", + " margin=dict(r=20, b=10, l=10, t=40),\n", + " )\n", + "\n", + " return fig" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "068bd8f1-84b6-42e4-a822-bc890faabd5b", + "metadata": {}, + "outputs": [], + "source": [ + "fig = plot_satellite_positions(orbits, SYSTEM)\n", + "fig.show(height=800)" + ] + }, + { + "cell_type": "markdown", + "id": "eed3479a-8c36-45b0-b49c-a5f52bcf6970", + "metadata": {}, + "source": [ + "**What to look for:** The orbits form a shell well outside the Earth ellipsoid, at a radius of roughly four Earth radii. The tracks sit at several distinct inclinations rather than all in one plane, which is what keeps several satellites visible from any point on the ground at any time.\n", + "\n", + "The plot is interactive. Drag to rotate, scroll to zoom, and hover a track to read its timestamp. Change `SYSTEM` in the Configuration cell to `\"R\"`, `\"E\"`, or `\"C\"` and re-run this cell to compare constellations." + ] + }, + { + "cell_type": "markdown", + "id": "26911cdf-d021-45f8-bbf9-2df1c089a4c7", + "metadata": {}, + "source": [ + "## 5. Elevation and Azimuth from a Station" + ] + }, + { + "cell_type": "markdown", + "id": "e20dead0-a88d-41df-8180-ab766d38ceac", + "metadata": {}, + "source": [ + "**What:** The same ephemeris product, but requesting `elevation` and `azimuth` instead of `x`, `y`, and `z`, and supplying a `reference_point` on the ground.\n", + "\n", + "**Why:** Elevation and azimuth are what relate a satellite to a particular observer. Passing `reference_point` moves that computation to the server, so you receive angles directly rather than converting ECEF coordinates yourself.\n", + "\n", + "**Expected result:** A dataframe of `timestamp`, `system`, `satellite`, `elevation`, `azimuth`, sampled every 15 seconds for the four satellites named in Configuration. Elevation and azimuth are in degrees." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a89febf4-52be-4f5e-a5eb-23a1153b874a", + "metadata": {}, + "outputs": [], + "source": [ + "table = await es.data.gnss_ephemeris_positions(\n", + " start_datetime=START,\n", + " end_datetime=END,\n", + " field=[\"elevation\", \"azimuth\"],\n", + " system=SYSTEM,\n", + " satellite=SATELLITES,\n", + " reference_point=STATION_LOCATION,\n", + " sample_interval=AZEL_INTERVAL,\n", + " elevation_filter=FloatFilter(min=0),\n", + ").fetch()\n", + "\n", + "azel = pl.from_arrow(table).sort(\"timestamp\")\n", + "print(f\"{len(azel):,} rows\")\n", + "azel.head()" + ] + }, + { + "cell_type": "markdown", + "id": "e1e8bf47-8fd4-4f8e-abd4-b95cc365cd13", + "metadata": {}, + "source": [ + "`elevation_filter=FloatFilter(min=0)` discards every epoch at which a satellite was below the\n", + "horizon. Those rows describe geometry that no receiver could have observed, so filtering them\n", + "server-side keeps the response smaller. Raise the minimum to apply an elevation mask: `min=10`\n", + "returns only satellites at least 10 degrees above the horizon.\n", + "\n", + "> **Check:** Every `elevation` value should be between 0 and 90, and every `azimuth` between 0 and 360." + ] + }, + { + "cell_type": "markdown", + "id": "c8bb6961-9611-44fe-ba2d-3bdf1d9f0cfd", + "metadata": {}, + "source": [ + "## 6. Joining Geometry with Observations" + ] + }, + { + "cell_type": "markdown", + "id": "b9b7f439-c192-4fa1-9775-883f2ac06323", + "metadata": {}, + "source": [ + "The geometry and the observations are two separate products. Bringing them together is what lets\n", + "you ask how signal strength varies with position in the sky.\n", + "\n", + "First, retrieve the observations, using the same station, satellites, and window." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3bc30ec4-2617-4021-a83f-217bb0955de5", + "metadata": {}, + "outputs": [], + "source": [ + "table = await es.data.gnss_observations(\n", + " start_datetime=START,\n", + " end_datetime=END,\n", + " station_name=STATION,\n", + " session_name=SESSION,\n", + " system=SYSTEM,\n", + " satellite=SATELLITES,\n", + " obs_code=OBS_CODE,\n", + " field=\"snr\",\n", + ").fetch()\n", + "\n", + "obs = pl.from_arrow(table).sort(\"timestamp\")\n", + "print(f\"{len(obs):,} rows\")\n", + "obs.head()" + ] + }, + { + "cell_type": "markdown", + "id": "f0f2c833-ccc6-49df-a3d6-be174987300e", + "metadata": {}, + "source": [ + "The two dataframes share three columns that together identify a measurement: `timestamp`, `satellite`, and `system`. Joining on all three matches each observation to the geometry of the satellite that produced it, at the moment it was recorded.\n", + "\n", + "An inner join keeps only rows that matched on both sides. Rows that fail to match are dropped: observations at epochs where the geometry was filtered out, and geometry for epochs where the receiver recorded nothing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b7d5b776-7fbd-40fe-b766-f17de43875e7", + "metadata": {}, + "outputs": [], + "source": [ + "joined = obs.join(\n", + " azel,\n", + " on=[\"timestamp\", \"satellite\", \"system\"],\n", + " how=\"inner\",\n", + ").sort(\"timestamp\")\n", + "\n", + "print(f\"observations: {len(obs):,}\")\n", + "print(f\"geometry: {len(azel):,}\")\n", + "print(f\"joined: {len(joined):,}\")\n", + "joined.head()" + ] + }, + { + "cell_type": "markdown", + "id": "1fb3a228-0fca-4fb1-89eb-c75eb9fc1929", + "metadata": {}, + "source": [ + "> **Check:** The joined row count should be close to, but slightly below, both inputs. A joined count of zero means the timestamps never matched, which usually means `AZEL_INTERVAL` does not equal the observation sampling interval. A count far below both inputs means the two datasets overlap only partially." + ] + }, + { + "cell_type": "markdown", + "id": "dc4007dd-27c8-4a9c-ba63-ba21dc6ce6c7", + "metadata": {}, + "source": [ + "## 7. SNR vs Elevation" + ] + }, + { + "cell_type": "markdown", + "id": "6f0f16fe-8e64-4c2f-aefb-ad8feacdb7bb", + "metadata": {}, + "source": [ + "With geometry attached to every observation, signal strength can be plotted against position in the sky rather than against time. The plot below restricts to elevations under 30 degrees, where the relationship is steepest." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e827766-ee57-42e8-861f-eacf806f3406", + "metadata": {}, + "outputs": [], + "source": [ + "joined.filter(pl.col(\"elevation\") < 30).with_columns(\n", + " pl.col(\"satellite\").cast(pl.Utf8)\n", + ").plot.point(x=\"elevation\", y=\"snr\", color=\"satellite\").properties(\n", + " title=f\"{STATION}: SNR by elevation\",\n", + " width=650,\n", + " height=300,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "06b49c20-f4db-4933-bc5d-a49bb3ded921", + "metadata": {}, + "source": [ + "**What to look for:** SNR rises steeply as elevation increases from the horizon, then flattens. Most of the variation is concentrated in the lowest few degrees, which is why elevation masks are usually set low rather than high.\n", + "\n", + "The oscillation near the horizon is worth attention. Instead of climbing smoothly, SNR ripples up and down with a wavelength of a degree or two. This is interference between the signal arriving directly from the satellite and the same signal arriving after reflecting off the ground nearby. The two paths differ in length by an amount that changes as the satellite rises, so they alternate between reinforcing and canceling.\n", + "\n", + "That ripple is the signal GNSS reflectometry works from. Its frequency depends on the height of the antenna above the reflecting surface, so measuring the ripple lets you infer that height, and changes in it over time." + ] + }, + { + "cell_type": "markdown", + "id": "7ba8c49b-9f95-419d-b064-f3ea887739ca", + "metadata": {}, + "source": [ + "## 8. Polar Sky Plot" + ] + }, + { + "cell_type": "markdown", + "id": "eed3a012-13fc-45d9-8472-4b9c6007dd2a", + "metadata": {}, + "source": [ + "A sky plot maps the whole visible hemisphere onto a disc. Azimuth runs around the circle, and elevation runs along the radius with the zenith at the center and the horizon at the rim. Each satellite's pass appears as a track across the sky, colored here by mean SNR." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "885b2894-d699-4b18-9bbe-b170f4a989fd", + "metadata": {}, + "outputs": [], + "source": [ + "(\n", + " alt.Chart(joined)\n", + " .mark_arc(tooltip=True)\n", + " .encode(\n", + " alt.Theta(\"azimuth:Q\", bin=alt.Bin(maxbins=180), scale=alt.Scale(domain=[0, 360])),\n", + " alt.Radius(\"elevation:Q\", bin=alt.Bin(step=2), scale=alt.Scale(domain=[90, 0])),\n", + " alt.Color(\"mean(snr):Q\", title=\"Mean SNR\", scale=alt.Scale(scheme=\"viridis\")),\n", + " )\n", + " .properties(\n", + " title=f\"{STATION}: mean SNR by elevation and azimuth\",\n", + " width=500,\n", + " height=500,\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "b8f1fc98-b3a4-48ee-b668-880e47014df6", + "metadata": {}, + "source": [ + "**What to look for:** Read the disc as the sky seen from directly below, looking up. The center is overhead; the rim is the horizon. \n", + "\n", + "Tracks are brightest near the center and dimmest at the rim, which is the elevation relationship from Section 7 in a different projection. What the sky plot adds is direction. If some part of the rim is consistently darker than the rest at the same elevation, the sky is obstructed in that direction, and the azimuth tells you which way to look for the cause.\n", + "\n", + "At mid-northern latitudes the middle of the northern sky is usually empty. GPS orbits are inclined about 55 degrees, so from this station no satellite passes directly through the far north. That gap is expected and is a property of the orbits, not of the station." + ] + }, + { + "cell_type": "markdown", + "id": "092f6e2c-773b-4cd2-9d8e-0717a0a45914", + "metadata": {}, + "source": [ + "## 9. Exploration Exercises\n", + "\n", + "Now that you've completed the core workflow, try modifying the parameters below to explore how the results change.\n", + "\n", + "**Try these modifications:**\n", + "\n", + "1. **Compare constellations:** Change `SYSTEM` in the Configuration section to `\"R\"`, `\"E\"`, or `\"C\"` and re-run Section 4. How do the orbital inclinations differ? Try `\"S\"` as well: SBAS satellites are geostationary, and their tracks look different.\n", + "\n", + "2. **Apply an elevation mask:** Change `elevation_filter=FloatFilter(min=0)` in Section 5 to `min=10` or `min=15` and re-run Sections 5 to 8. How many observations does the join lose, and what happens to the rim of the sky plot?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d961e346-1772-48b4-a897-63c08778a3a9", + "metadata": {}, + "outputs": [], + "source": [ + "# Exploration cell — use this space to experiment" + ] + }, + { + "cell_type": "markdown", + "id": "8f9dd107-ece1-43c3-9b90-73aab045a789", + "metadata": {}, + "source": [ + "## 10. Troubleshooting & Support\n", + "\n", + "### Common Issues\n", + "\n", + "| Error | Likely cause | Fix |\n", + "|---|---|---|\n", + "| Joined dataframe has 0 rows | `AZEL_INTERVAL` does not match the observation sampling interval, so no timestamps align | Set `AZEL_INTERVAL` to the observation interval, 15 seconds at this station |\n", + "| All `elevation` values are negative or the result is empty | The reference point is on the far side of the Earth from these satellites, or latitude and longitude were swapped | Check `STATION_LOCATION`: latitude runs -90 to 90, longitude -180 to 180 |\n", + "| Sky plot is nearly empty | Only a few satellites were requested, or the elevation filter is aggressive | Add entries to `SATELLITES`, or lower the `elevation_filter` minimum |\n", + "\n", + "### Further Resources\n", + "\n", + "* [EarthScope SDK Documentation](https://docs.earthscope.org/sdk)\n", + "* [SDK GNSS Satellite Ephemeris Positions tutorial](https://docs.earthscope.org/sdk/gnss-eph-pos-tutorial)\n", + "* [SDK GNSS Observations tutorial](https://docs.earthscope.org/sdk/gnss-obs-tutorial)\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 +}