DEV Community

Cover image for I built a satellite pass quality forecaster using NOAA space weather data and Skyfield — here's how it works
Nilabh Jyoti Kalita
Nilabh Jyoti Kalita

Posted on

I built a satellite pass quality forecaster using NOAA space weather data and Skyfield — here's how it works

When I first started thinking about satellite ground station operations, I assumed the hard part was knowing when a satellite passes overhead. Turns out that's the easy part. The hard part is knowing whether that pass is actually worth using.

That question led me to build OrbitGuard — a satellite pass quality forecaster that combines orbital mechanics with real-time space weather data. Here's the full story of how it works, what I built it with, and the problems I ran into along the way.


The problem: pass geometry isn't enough

Every LEO satellite has predictable pass windows over any ground station. Tools like Heavens-Above or Gpredict show you these windows — acquisition of signal (AOS), loss of signal (LOS), maximum elevation angle. Standard stuff.

But here's what those tools don't show you: whether the RF link will actually work during that pass.

Three physical phenomena can degrade or completely destroy your communication link even during a geometrically perfect pass:

1. Geomagnetic disturbances (Kp index)
When the sun ejects a coronal mass ejection or a solar flare hits Earth's magnetosphere, it compresses and distorts the field. This is measured by the planetary Kp index (0–9 scale). At Kp ≥ 5, UHF and VHF links start experiencing phase scintillation and signal fading. At Kp ≥ 7, you're looking at potential blackouts.

2. Solar radio flux (F10.7)
The F10.7 index measures solar radio emissions at 10.7 cm wavelength — a proxy for solar activity. High F10.7 (above ~200 sfu) correlates with increased ionospheric electron density, which directly affects signal propagation for frequencies below ~1 GHz.

3. Ionospheric Total Electron Content (TEC)
The ionosphere sits between 60–1000 km altitude. Electrons in this layer refract and scatter radio waves. High TEC causes group delay, phase advance, and scintillation on UHF/VHF links. A satellite pass over a ground station during elevated TEC conditions will have degraded link margin even if the geometry is perfect.

Most CubeSat teams currently look up Kp from NOAA, check F10.7 from a separate source, and maybe glance at ionospheric maps — all manually, before each contact attempt. That's the gap I wanted to close.


The architecture

Space-Track.org ──→ TLE data
NOAA SWPC API ────→ Kp index, F10.7, active alerts
NOAA SWPC API ────→ Ionospheric TEC
        │
        ▼
   FastAPI backend
   (Skyfield + scoring engine)
        │
        ▼
   React dashboard + REST API
        │
        ▼
   Supabase (auth + storage)
   Resend (email alerts)
Enter fullscreen mode Exit fullscreen mode

Backend: Python + FastAPI, deployed on Render
Frontend: React + Recharts, deployed on Render (static site)
Database: Supabase (Postgres via REST API)
Auth: Supabase Auth
Alerts: Resend


Orbital mechanics with Skyfield

The pass computation is straightforward once you have a TLE. Skyfield's find_events does the heavy lifting:

from skyfield.api import load, wgs84, EarthSatellite
from datetime import datetime, timezone, timedelta

def compute_passes(tle_name, tle_line1, tle_line2,
                   lat, lon, elevation_m=0.0,
                   min_elevation_deg=10.0, hours_ahead=48):

    ts = load.timescale()
    satellite = EarthSatellite(tle_line1, tle_line2, tle_name, ts)
    observer = wgs84.latlon(lat, lon, elevation_m=elevation_m)

    now = datetime.now(timezone.utc)
    t0 = ts.from_datetime(now)
    t1 = ts.from_datetime(now + timedelta(hours=hours_ahead))

    events_t, events_type = satellite.find_events(
        observer, t0, t1, altitude_degrees=min_elevation_deg
    )
    # events_type: 0=AOS, 1=culmination (max elevation), 2=LOS

    passes = []
    i = 0
    while i < len(events_t):
        if events_type[i] == 0:  # AOS
            aos_t = events_t[i]
            peak_el = 0.0
            los_t = None
            j = i + 1
            while j < len(events_t):
                if events_type[j] == 1:  # culmination
                    diff = satellite - observer
                    topocentric = diff.at(events_t[j])
                    alt, az, _ = topocentric.altaz()
                    peak_el = alt.degrees
                elif events_type[j] == 2:  # LOS
                    los_t = events_t[j]
                    i = j
                    break
                j += 1
            if los_t:
                aos_dt = aos_t.utc_datetime()
                los_dt = los_t.utc_datetime()
                passes.append({
                    "aos": aos_dt.isoformat(),
                    "los": los_dt.isoformat(),
                    "max_elevation_deg": round(peak_el, 2),
                    "duration_seconds": round(
                        (los_dt - aos_dt).total_seconds()
                    ),
                })
        i += 1
    return passes
Enter fullscreen mode Exit fullscreen mode

TLEs come from Space-Track.org via a fresh session per request. I originally used CelesTrak, but discovered it blocks cloud server IPs — more on that later.


The NOAA space weather API

NOAA's Space Weather Prediction Center (SWPC) publishes real-time data at services.swpc.noaa.gov. No API key required, just JSON endpoints:

import requests

NOAA_KP_URL = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json"
NOAA_FLUX_URL = "https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json"

def get_kp_index():
    resp = requests.get(NOAA_KP_URL, timeout=8)
    data = resp.json()
    latest = data[-1]
    return float(latest.get("kp_index", 2.0))

def get_solar_flux():
    resp = requests.get(NOAA_FLUX_URL, timeout=8)
    data = resp.json()
    latest = data[-1]
    return float(latest.get("f10.7", 150.0))
Enter fullscreen mode Exit fullscreen mode

The severity classification follows NOAA's official G-scale:

def classify_severity(kp: float) -> dict:
    if kp >= 7.0:
        return {"severity": "SEVERE", "g_scale": "G3+",
                "ops_impact": "Widespread HF blackout. Avoid critical uplink passes."}
    elif kp >= 6.0:
        return {"severity": "MODERATE", "g_scale": "G2",
                "ops_impact": "HF degradation at mid-latitudes. Monitor carefully."}
    elif kp >= 4.0:
        return {"severity": "MINOR", "g_scale": "G1",
                "ops_impact": "Minor HF fluctuations. Most operations nominal."}
    else:
        return {"severity": "QUIET", "g_scale": "G0",
                "ops_impact": "Nominal conditions. No significant impact expected."}
Enter fullscreen mode Exit fullscreen mode

One mistake I made early: I was labelling Kp 4.0 as "SEVERE" — completely wrong. Kp 4.0 is a G1 Minor event. The G-scale doesn't reach Severe until Kp 7+. I caught this when a user pointed out their "SEVERE" badge was showing on a perfectly ordinary afternoon.


The scoring algorithm

This is the core of the product. The goal was a deterministic, explainable formula — no ML, no black box, just physics-informed weights:

import math

def score_pass(max_elevation_deg, duration_seconds,
               kp, f107, alert_count, tec_penalty_pts=0.0):

    # Geometry: 0-60 pts
    # Linear: 10° elevation = 0 pts, 90° = 60 pts
    el_score = ((min(90, max(10, max_elevation_deg)) - 10) / 80) * 60

    # Duration: 0-40 pts
    # Sqrt scale: 0s = 0, 600s (10 min) = 40 pts
    dur_score = (math.sqrt(min(600, duration_seconds)) / math.sqrt(600)) * 40

    # Kp penalty: 0-20 pts deducted
    # Kp ≤ 2: no penalty. Kp ≥ 7: full -20
    if kp <= 2.0:
        kp_pen = 0.0
    elif kp >= 7.0:
        kp_pen = 20.0
    else:
        kp_pen = ((kp - 2.0) / 5.0) * 20.0

    # Solar flux penalty: 0-10 pts deducted
    # F10.7 ≤ 100: no penalty. ≥ 250: full -10
    if f107 <= 100:
        flux_pen = 0.0
    elif f107 >= 250:
        flux_pen = 10.0
    else:
        flux_pen = ((f107 - 100) / 150) * 10.0

    # Alert penalty: -1 pt per active NOAA alert, cap -5
    # Note: originally had -3 per alert capped at -10.
    # That was too aggressive — 5 alerts was wiping 15 pts.
    # NOAA alerts are informational; Kp already captures the physics.
    alert_pen = min(5.0, float(alert_count) * 1.0)

    # Ionospheric TEC penalty: 0-8 pts deducted
    # LOW TEC: 0, MODERATE: 3, HIGH: 6, VERY_HIGH: 8
    ionex_pen = min(8.0, max(0.0, tec_penalty_pts))

    raw = el_score + dur_score - kp_pen - flux_pen - alert_pen - ionex_pen
    score = round(max(0.0, min(100.0, raw)), 1)

    if score >= 80: grade = "EXCELLENT"
    elif score >= 60: grade = "GOOD"
    elif score >= 40: grade = "FAIR"
    elif score >= 20: grade = "POOR"
    else: grade = "AVOID"

    return {"score": score, "grade": grade}
Enter fullscreen mode Exit fullscreen mode

Why sqrt scaling for duration?
A 60-second pass and a 120-second pass feel very different operationally. But a 540-second pass and 600-second pass are basically the same — you've already got plenty of contact time. The sqrt function captures this diminishing returns relationship naturally.

Why separate alert penalty from Kp?
Early versions over-penalized alerts. 5 active NOAA alerts would deduct 15 points, which is too aggressive — NOAA alerts are informational text, while Kp directly captures the physical geomagnetic effect. I now use -1 per alert capped at -5, keeping Kp as the primary space weather signal.


The problems I actually ran into

1. CelesTrak blocks cloud server IPs

I originally fetched TLEs from CelesTrak. Worked perfectly locally. On Render, every request returned "Host not in allowlist". CelesTrak restricts access to known browsers and registered applications — cloud server IPs are blocked.

Fix: Switch to Space-Track.org, which explicitly permits programmatic server-to-server access. Free account, fresh session per request to handle server restarts.

2. Python 3.14 breaks everything that compiles C extensions

Render's free tier defaulted to Python 3.14. pydantic-core requires Rust to compile and fails on 3.14. psycopg2 fails. asyncpg fails. Even packages that claim to be "pure Python" were pulling in compiled dependencies.

Fix: Remove pydantic models entirely from the API layer (use plain dict for request bodies). Use Supabase's REST API via httpx instead of any Postgres driver. Zero compiled dependencies — everything is pure Python HTTP calls.

3. Scoring was too conservative

Early users saw scores clustering in the 25-35 range for typical Mumbai ISS passes. Looked wrong. After debugging, the issue was the alert penalty: 5 active NOAA alerts (common during solar maximum) was deducting 15 points. A Kp of 0.0 combined with 5 alerts was scoring passes as POOR when they should have been FAIR.

Fix: Recalibrate alert penalty to -1 per alert, cap -5. Now typical passes score in the 40-55 range under normal conditions, which is accurate — ISS passes over Mumbai rarely exceed 65° elevation, so EXCELLENT is genuinely rare and meaningful when it appears.


What's live

Features currently live:

  • 48-hour pass forecast for any of 20,000+ LEO satellites
  • Real-time NOAA space weather scoring (Kp, F10.7, TEC)
  • Email alerts via Resend when a high-quality pass is within 2 hours
  • User accounts with saved ground station profiles
  • 14-day free trial, full API access

What I'd do differently

Use a message queue for the alert scheduler. Right now alerts run as an asyncio background task inside uvicorn. This works on a single instance but won't scale. A proper implementation would use a separate worker process with Redis or a cron job.

IONEX data directly instead of estimated TEC. I tried fetching NASA CDDIS IONEX files (actual global TEC maps) but the gzip decompression + IONEX parsing was fragile and slow. Currently using a Kp-based TEC estimate. Real IONEX would make the ionospheric scoring much more accurate.

User-specific ground station lat/lon in alerts. Right now the alert scheduler uses the lat/lon stored at subscription time. Ideally it would re-fetch from the user's saved stations in case they updated them.


If you're building something in the space data or orbital mechanics space and have thoughts on the scoring approach, I'd genuinely love to hear them. Particularly interested in whether the TEC penalty weights feel physically reasonable to anyone with RF propagation experience.

Top comments (0)