SSO Coverage by Latitude Band

Computes 30-day coverage statistics for a 550 km sun-synchronous orbit (LTAN 10:30, descending node) carrying a 10° half-angle nadir sensor.

Results are reported per 5° latitude band:

  • Number of sample points in the band

  • Cumulative coverage fraction (% of points seen ≥ once)

  • Mean revisit time (hours)

  • Maximum revisit time (hours)

Object API — uses Spacecraft.sunsync, Sensor, AoI.from_region, and Coverage.

[1]:
import time
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors

from missiontools import Spacecraft, Sensor, AoI, Coverage

1. Spacecraft and Sensor

A nadir-pointing 10° half-angle sensor is body-mounted along the spacecraft nadir axis (body-z = nadir, body-vector [0, 0, 1] in the sensor convention).

[2]:
EPOCH = np.datetime64('2025-01-01T00:00:00', 'us')

sc = Spacecraft.sunsync(
    altitude_km    = 550.0,
    node_solar_time = '10:30',
    node_type      = 'descending',
    epoch          = EPOCH,
)

sensor = Sensor(half_angle_deg=10.0, body_vector=[0, 0, 1])
sc.add_sensor(sensor)

period_s = 2 * np.pi * np.sqrt(sc.a**3 / sc.central_body_mu)
swath_km = 2 * (sc.a - sc.central_body_radius) * np.tan(np.radians(10.0)) / 1e3

print(f"Semi-major axis : {sc.a / 1e3:.1f} km")
print(f"Inclination     : {np.degrees(sc.i):.3f}°")
print(f"Orbital period  : {period_s / 60:.1f} min")
print(f"Propagator      : {sc.propagator_type}")
print(f"FOV half-angle  : {np.degrees(sensor.half_angle_rad):.0f}°")
print(f"Ground swath    : ~{swath_km:.0f} km")
Semi-major axis : 6928.1 km
Inclination     : 97.593°
Orbital period  : 95.6 min
Propagator      : j2
FOV half-angle  : 10°
Ground swath    : ~194 km

2. Per-Band Coverage Analysis

For each 5° latitude band we create an AoI.from_region, attach a fresh Coverage object, and compute coverage fraction and revisit time.

Note — 36 bands × 30 days takes a few minutes.

[3]:
T_START = EPOCH
T_END   = EPOCH + np.timedelta64(30 * 86_400, 's')

MAX_STEP     = np.timedelta64(20, 's')
POINT_DENSITY = 200_000   # km²/point  (~450 km resolution)

LAT_EDGES = np.arange(-90, 91, 5)   # 37 edges → 36 bands

def band_label(lo_deg, hi_deg):
    lo_s = f"{abs(lo_deg):.0f}°{'S' if lo_deg <  0 else 'N'}"
    hi_s = f"{abs(hi_deg):.0f}°{'S' if hi_deg <= 0 else 'N'}"
    return f"{lo_s}{hi_s}"

rows = []   # (label, n_pts, cov_pct, mean_rev_h, max_rev_h)

t0 = time.perf_counter()

for lo_deg, hi_deg in zip(LAT_EDGES[:-1], LAT_EDGES[1:]):
    aoi = AoI.from_region(
        lat_min_deg = float(lo_deg),
        lat_max_deg = float(hi_deg),
        point_density = POINT_DENSITY,
    )
    n = len(aoi)

    cov = Coverage(aoi, [sensor])

    cf = cov.coverage_fraction(T_START, T_END, max_step=MAX_STEP)
    rt = cov.revisit_time(T_START, T_END, max_step=MAX_STEP)

    cov_pct    = cf['final_cumulative'] * 100.0
    mean_rev_h = rt['global_mean'] / 3600.0 if not np.isnan(rt['global_mean']) else float('nan')
    max_rev_h  = rt['global_max']  / 3600.0 if not np.isnan(rt['global_max'])  else float('nan')

    rows.append((band_label(lo_deg, hi_deg), n, cov_pct, mean_rev_h, max_rev_h))

elapsed = time.perf_counter() - t0
print(f"Done in {elapsed:.1f} s")
Done in 151.3 s

3. Coverage Table

[4]:
print(f"  {'Band':>13}  {'Pts':>5}  {'Coverage':>10}  {'Mean Rev':>10}  {'Max Rev':>10}")
print(f"  {'─'*13}  {'─'*5}  {'─'*10}  {'─'*10}  {'─'*10}")

for label, n, cov_pct, mean_rev_h, max_rev_h in rows:
    mean_s = f"{mean_rev_h:8.2f} h" if not np.isnan(mean_rev_h) else "       —  "
    max_s  = f"{max_rev_h:8.2f} h" if not np.isnan(max_rev_h)  else "       —  "
    print(f"  {label:>13}  {n:>5}  {cov_pct:>9.1f}%  {mean_s}  {max_s}")
           Band    Pts    Coverage    Mean Rev     Max Rev
  ─────────────  ─────  ──────────  ──────────  ──────────
    90°S – 85°S      9        0.0%         —           —
    85°S – 80°S     28       75.0%     15.49 h    113.24 h
    80°S – 75°S     48      100.0%     24.58 h    588.37 h
    75°S – 70°S     65      100.0%     38.04 h    533.89 h
    70°S – 65°S     84      100.0%     29.01 h    629.53 h
    65°S – 60°S    100      100.0%     55.47 h    515.14 h
    60°S – 55°S    118      100.0%     52.80 h    583.17 h
    55°S – 50°S    132       98.5%     36.87 h    702.71 h
    50°S – 45°S    148       95.9%     22.69 h    705.03 h
    45°S – 40°S    160      100.0%     49.80 h    705.04 h
    40°S – 35°S    173      100.0%     71.24 h    609.43 h
    35°S – 30°S    184      100.0%     83.34 h    513.82 h
    30°S – 25°S    194      100.0%     89.96 h    464.93 h
    25°S – 20°S    201      100.0%     91.86 h    536.63 h
    20°S – 15°S    208      100.0%     81.06 h    608.33 h
    15°S – 10°S    213      100.0%     68.57 h    680.03 h
     10°S – 5°S    216       98.6%     38.60 h    703.92 h
      5°S – 0°S    218       90.8%     18.79 h     59.74 h
      0°N – 5°N    218       90.8%     18.74 h     59.74 h
     5°N – 10°N    216       97.7%     38.58 h    703.92 h
    10°N – 15°N    213      100.0%     63.73 h    680.02 h
    15°N – 20°N    208      100.0%     84.18 h    608.33 h
    20°N – 25°N    201      100.0%     90.64 h    536.63 h
    25°N – 30°N    194      100.0%     94.49 h    464.93 h
    30°N – 35°N    184      100.0%     87.71 h    513.82 h
    35°N – 40°N    173      100.0%     69.86 h    609.42 h
    40°N – 45°N    160      100.0%     53.66 h    681.12 h
    45°N – 50°N    148       95.3%     17.60 h     58.58 h
    50°N – 55°N    132       97.7%     36.29 h    702.71 h
    55°N – 60°N    118      100.0%     54.78 h    583.18 h
    60°N – 65°N    100      100.0%     53.87 h    515.14 h
    65°N – 70°N     84      100.0%     34.27 h    658.59 h
    70°N – 75°N     65      100.0%     37.55 h    533.89 h
    75°N – 80°N     48      100.0%     24.46 h    580.18 h
    80°N – 85°N     28       75.0%     15.33 h    170.54 h
    85°N – 90°N      9        0.0%         —           —

4. Visualisation

[5]:
labels    = [r[0] for r in rows]
lat_mids  = [(lo + hi) / 2 for lo, hi in zip(LAT_EDGES[:-1], LAT_EDGES[1:])]
cov_pcts  = np.array([r[2] for r in rows])
mean_revs = np.array([r[3] for r in rows])
max_revs  = np.array([r[4] for r in rows])

# Colour bars by coverage fraction
norm    = mcolors.Normalize(vmin=0, vmax=100)
cmap    = plt.cm.RdYlGn
colours = [cmap(norm(v)) for v in cov_pcts]

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 10), sharey=True)

# --- Coverage fraction ---
ax1.barh(lat_mids, cov_pcts, height=4.5, color=colours, edgecolor='white', linewidth=0.4)
ax1.axvline(100, color='tab:green', linestyle='--', linewidth=1.0, alpha=0.7, label='100% coverage')
ax1.set_xlabel('Cumulative coverage fraction (%)')
ax1.set_ylabel('Latitude (°)')
ax1.set_title('30-day coverage fraction')
ax1.set_xlim(0, 105)
ax1.set_yticks(lat_mids[::2])
ax1.set_yticklabels([f"{int(l):+d}°" for l in lat_mids[::2]])
ax1.grid(True, axis='x', alpha=0.3)
ax1.legend(fontsize=9)

# Add value labels
for lat, v in zip(lat_mids, cov_pcts):
    if v > 0:
        ax1.text(min(v + 1, 103), lat, f"{v:.0f}%", va='center', fontsize=7)

# --- Mean revisit time ---
valid = ~np.isnan(mean_revs)
ax2.barh(np.array(lat_mids)[valid], mean_revs[valid], height=4.5,
         color='tab:blue', alpha=0.7, edgecolor='white', linewidth=0.4)
ax2.set_xlabel('Mean revisit time (hours)')
ax2.set_title('30-day mean revisit time')
ax2.grid(True, axis='x', alpha=0.3)

# Inclination limit annotation
ax2.axhline(np.degrees(sc.i) - 90, color='grey', linestyle=':', linewidth=1.0,
            label=f'Inclination limit ({np.degrees(sc.i):.1f}°)')
ax2.axhline(-(np.degrees(sc.i) - 90), color='grey', linestyle=':', linewidth=1.0)
ax2.legend(fontsize=9)

fig.suptitle(
    f'550 km SSO  |  LTAN 10:30  |  10° half-angle nadir sensor  |  30-day window',
    fontsize=11, y=1.01
)
plt.tight_layout()
plt.show()
../_images/examples_sso_coverage_by_latitude_9_0.png