skyweaver tutorial¶
This notebook walks through a first end-to-end skyweaver workflow:
- define an artificial satellite orbit
- choose one or more observatories
- build a time grid
- compute ground and sky tracks
- visualise the results in geographic, polar, and HEALPix views
- compare how the same orbit is seen from different sites
from datetime import datetime, timezone
import cmasher as cmr
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
from skyweaver import Observatory, OrbitSpec, TimeGrid, ground_track, sky_track
from skyweaver.viz import plot_ground_track, plot_sky_track, plot_sky_track_healpix
pride = cmr.pride.copy()
pride.set_under("white")
1. Define a first test orbit¶
We start with a simple circular low-Earth orbit:
- altitude: 550 km
- inclination: 70°
- RAAN: 30°
- phase: 120°
This is not meant to represent a specific real satellite. It is just a useful artificial test case for exploring the package.
orbit = OrbitSpec.circular(
name="test_550km_i70",
epoch=datetime(2026, 1, 1, tzinfo=timezone.utc),
altitude_km=550.0,
inclination_deg=70.0,
raan_deg=30.0,
phase_deg=120.0,
)
print(orbit.summary())
print(f"Semi-major axis [km]: {orbit.semi_major_axis_km:.3f}")
print(f"Period [min]: {orbit.period_min:.3f}")
print(f"Mean motion [rev/day]: {orbit.mean_motion_rev_day:.3f}")
OrbitSpec(name='test_550km_i70', altitude_km=550.0, inclination_deg=70.000, raan_deg=30.000, mean_anomaly_deg=120.000, eccentricity=0.000000, period_min=95.65) Semi-major axis [km]: 6928.137 Period [min]: 95.650 Mean motion [rev/day]: 15.055
2. Choose an observatory¶
skyweaver includes a small registry of built-in observatories. We can inspect the available site names and then select one for our first sky-track calculation.
Observatory.names()
['ALBATROS', 'CHORD', 'GMRT', 'HERA', 'HIRAX', 'LOFAR', 'MWA', 'NENUFAR', 'OVRO_LWA', 'SKA_LOW']
site = Observatory.get("MWA")
site
Observatory(name='MWA', latitude_deg=-26.703319, longitude_deg=116.670815, elevation_m=377.83)
3. Build a time grid¶
We now define the interval over which the orbit will be sampled. For demonstration purposes, one month at 30-second cadence gives enough coverage to show repeated passes and meaningful HEALPix accumulation, while still being manageable for an example notebook.
grid = TimeGrid(
start=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
stop=datetime(2026, 2, 1, 0, 0, 0, tzinfo=timezone.utc),
cadence_s=30,
)
print(grid.summary())
TimeGrid(start=2026-01-01T00:00:00+00:00, stop=2026-02-01T00:00:00+00:00, cadence_s=30.000, n_times=89281)
4. Compute the tracks¶
The same orbit can be viewed in two complementary ways:
- a ground track, showing the sub-satellite point on Earth
- a sky track, showing the satellite in altitude/azimuth as seen from a chosen observatory
gt = ground_track(orbit, grid)
print(gt.summary())
print(gt.latitude_deg[:4])
print(gt.longitude_deg[:4])
GroundTrack(orbit='test_550km_i70', n_times=89281) [54.58639837 53.05089893 51.48860567 49.90262212] [78.73511572 80.44550071 82.02894665 83.49875505]
NOTE:¶
pass_cadence_s=1 is an optional kwarg which enables a higher temporal resolution evaluation of satellite positions when they are above the horizon. If the argument is not provided, the satellite position is evaluated at all grid times. If the argument is provided the satellite positions are only evaluated above the horizon, at a higher temporal resolution
st = sky_track(orbit, site, grid, pass_cadence_s=1)
print(st.summary())
print(st.altitude_deg[:4])
print(st.azimuth_deg[:4])
print(st.range_km[:4])
SkyTrack(orbit='test_550km_i70', observatory='MWA', n_times=86659) [9.74766537e-05 6.17023267e-02 1.23461786e-01 1.85377012e-01] [334.49271159 334.475404 334.45801124 334.44053265] [2706.99508598 2700.20922854 2693.42361361 2686.63825071]
5. Plot the ground track¶
Here we show the orbit projected onto a simple world map and overlay several observatory locations for context.
fig, ax = plt.subplots(figsize=(10, 5))
plot_ground_track(
gt,
ax=ax,
observatories=[
Observatory.get("MWA"),
Observatory.get("LOFAR"),
Observatory.get("HERA"),
Observatory.get("ALBATROS"),
Observatory.get("CHORD"),
Observatory.get("GMRT"),
],
)
plt.show()
6. Plot the sky track at MWA¶
This is the same orbit, but now shown in local altitude/azimuth as seen from the MWA. Each rise-to-set pass is split and plotted separately, and the colormap is used to assign a distinct colour to each pass.
fig, ax = plt.subplots(subplot_kw={"projection": "polar"}, figsize=(7, 7))
plot_sky_track(st, ax=ax, alpha=0.7, cmap=pride)
plt.show()
7. Accumulate the sky coverage into a HEALPix map¶
A HEALPix map is useful when you care less about the detailed sequence of individual passes and more about where on the sky the orbit repeatedly samples. With unique_per_pass=True, each pixel is counted once per pass, which avoids overweighting densely sampled points within a single pass.
healpix_map = plot_sky_track_healpix(
st,
nside=32,
unique_per_pass=True,
cmap=pride,
bgcolor="white",
)
plt.show()
8. Basic visibility diagnostics¶
A quick way to summarise how useful a given orbit is for a site is to look at how often it is above the horizon and how high it gets.
print(f"Maximum altitude [deg]: {np.nanmax(st.altitude_deg):.3f}")
print(f"Number of distinct passes: {len(st.passes())}")
Maximum altitude [deg]: 86.451 Number of distinct passes: 145
9. Altitude as a function of time¶
The polar sky plot is intuitive geometrically, but an altitude-versus-time plot is often the quickest way to identify rise, culmination, and set behaviour.
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(st.datetimes(), st.altitude_deg, lw=0.7, color=cmr.pride(0.73))
ax.axhline(0.0, ls="--", lw=0.7)
locator = mdates.AutoDateLocator(minticks=8, maxticks=16)
formatter = mdates.ConciseDateFormatter(locator)
ax.xaxis.set_major_locator(locator)
ax.xaxis.set_major_formatter(formatter)
ax.set_ylabel("Altitude [deg]")
ax.set_xlabel("Time [UTC]")
ax.set_title(f"{orbit.name} as seen from {site.name}")
ax.grid(ls=":", lw=0.7)
plt.show()
Plot first pass¶
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(st.passes()[0].datetimes(), st.passes()[0].altitude_deg, "-.", lw=0.7, color=cmr.pride(0.73))
ax.axhline(0.0, ls="--", lw=0.7)
locator = mdates.AutoDateLocator(minticks=8, maxticks=16)
formatter = mdates.ConciseDateFormatter(locator)
ax.xaxis.set_major_locator(locator)
ax.xaxis.set_major_formatter(formatter)
ax.set_ylabel("Altitude [deg]")
ax.set_xlabel("Time [UTC]")
ax.set_title(f"{orbit.name} as seen from {site.name}")
ax.grid(ls=":", lw=0.7)
plt.show()
10. Compare other observatories¶
A nice feature of this workflow is that you can reuse the same orbit and time grid, changing only the observatory. This makes it easy to compare how the same satellite shell would illuminate different instruments.
def summarise_site(site_name: str) -> None:
site_i = Observatory.get(site_name)
st_i = sky_track(orbit, site_i, grid, pass_cadence_s=1)
print(site_i.name)
print(f" Number of passes: {len(st_i.passes())}")
print(f" Max altitude [deg]: {np.nanmax(st_i.altitude_deg):.3f}")
fig, ax = plt.subplots(subplot_kw={"projection": "polar"}, figsize=(7, 7))
plot_sky_track(st_i, ax=ax, cmap=pride, alpha=0.7)
plt.show()
plot_sky_track_healpix(
st_i,
nside=32,
unique_per_pass=True,
cmap=pride,
bgcolor="white",
)
plt.show()
summarise_site("LOFAR")
LOFAR Number of passes: 261 Max altitude [deg]: 89.916
summarise_site("HERA")
HERA Number of passes: 154 Max altitude [deg]: 89.612