warlaxx/sat-pass-predictor

Java

1

19 commits

updated Sep 30, 2026

See the code

See what people are saying

SourceMessageScoreDate

I built a free satellite pass predictor (Orekit, cross-checked against Skyfield). What would make it useful to you? (r/SideProject)

Hey everyone, I've been learning orbital mechanics for a few weeks and ended up building a pass predictor. Backend is Java with Orekit (SGP4 on CelesTrak TLEs), frontend is Angular with a sky chart and a 3D globe. Here it is:…

2

Sep 30, 2026

README

Sat Pass Predictor

CI

Computing and visualising satellite passes over a given point on Earth. Java / Spring Boot backend with Orekit, Angular frontend.

A learning project aimed at the space ecosystem: SGP4 propagation from TLEs, reference frames and time scales, visibility event detection.

Stack

PieceChoice
BackendJava 25, Spring Boot 4.1.1, Maven
DynamicsOrekit 13.1.8
FrontendAngular 22 (standalone, signals, SCSS)
TLEsCelesTrak GP API

Architecture

flowchart LR
    browser([Browser<br/>Angular: list, sky chart, globe])

    subgraph vercel [Vercel — nginx under Docker]
        static[Static bundle]
        relay["/tle-upstream<br/>rewrite"]
    end

    subgraph render [Render — Spring Boot API]
        controller["PassController<br/>GET /api/passes"]
        query[PassQueryService]
        store["TleStore<br/>in-memory, 2 h refresh"]
        chain["FallbackTleClient<br/>sources tried in order"]
        predict["PassPredictionService<br/>SGP4 + event detection"]
        orekit[("Orekit<br/>+ orekit-data")]
    end

    celestrak[(CelesTrak)]
    spacetrack[(Space-Track<br/>optional)]

    browser -- page --> static
    browser -- "/api (proxied)" --> controller
    controller --> query
    query --> store --> chain
    query --> predict --> orekit
    chain --> celestrak
    chain --> relay --> celestrak
    chain -.-> spacetrack

The browser computes nothing: every position the sky chart and the globe draw comes from the API's track, sampled by Orekit. TLEs live in memory with their age exposed, and an identical request is answered from the last computation rather than propagated again — see Not paying for a call twice. Optional PostgreSQL stores API keys, usage counters and the TLEs themselves: see API access for activation, quotas and operator commands. The default demo still starts without a database, and a restart then fetches the elements again.

Prerequisites

  • JDK 25 — the project compiles with release 25; an older JDK fails with release version 25 not supported.

    brew install openjdk@25
    # Homebrew JDKs are keg-only: without this link, /usr/libexec/java_home cannot see them.
    sudo ln -sfn /opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk \
                 /Library/Java/JavaVirtualMachines/openjdk-25.jdk
    export JAVA_HOME=$(/usr/libexec/java_home -v 25)
    
  • No Maven to install: backend/mvnw downloads the pinned version (3.9.16) on first use. It is the command the CI runs, so what passes here passes there.

  • Node 22 LTS

Running it with Docker

Nothing to install but Docker — no JDK, no Node, no Orekit data:

docker compose up --build

Then open http://localhost:4200 (Swagger UI: http://localhost:8080/docs). The first build takes a few minutes: Maven and npm dependencies, plus the Orekit data set, which is downloaded inside the API image at build time, never at runtime. API_PORT and WEB_PORT move the host ports when 8080 or 4200 are already taken by a dev server.

The two images are the two production pieces: backend/Dockerfile is the image Render deploys, and nginx (frontend/nginx.conf.template) stands in for Vercel — static files, /api forwarded server-side, the same caching rules as vercel.json.

Getting started

For development, without Docker:

# 1. Orekit data (leap seconds, EOP, ephemerides) — ~100 MB, not committed
./scripts/fetch-orekit-data.sh

# 2. Backend (http://localhost:8080)
cd backend && ./mvnw spring-boot:run

# 3. Frontend (http://localhost:4200, /api proxied to 8080)
cd frontend && npm install && npm start

Tests

cd backend  && ./mvnw verify
cd frontend && npm test

The cross-validation against Skyfield is a separate script, deliberately kept out of CI (see Validation). It runs in its own virtual environment, so that it depends neither on the machine's default python nor on a global installation:

python3 -m venv .venv-validation
.venv-validation/bin/pip install skyfield
.venv-validation/bin/python scripts/validate-against-skyfield.py

Skyfield requires Python 3. A pip install run under a still-active Python 2 — through pyenv, for instance — fails while compiling sgp4 with a SyntaxError in its setup.py: the message points at sgp4, the cause is the interpreter. python3 -m pip --version says which one is really in use.

Orekit data

Orekit needs an external data set (UTC-TAI history, IERS Earth orientation parameters, gravity models). Without it, the first call to TimeScalesFactory.getUTC() fails. Those files are large and updated regularly: they are not committed, but downloaded by scripts/fetch-orekit-data.sh and loaded at startup by OrekitConfig. The path is configurable through OREKIT_DATA_PATH.

The application refuses to start if the directory is missing: an explicit failure at startup beats an obscure error at the first computation.

TLE sources

Orbital elements come from a chain of sources, tried in order, configured by tle.base-urls and overridable in one go with TLE_BASE_URLS (comma-separated):

  1. https://celestrak.org — the origin.
  2. https://sat-pass-predictor-nine.vercel.app/tle-upstream — the same CelesTrak, reached through a rewrite on the frontend's host. It exists because CelesTrak silently drops packets coming from the shared outbound IPs of the platform the API is deployed on: a connect timeout, no refusal, no DNS error, on a host that answers other datacenters in 10 ms. Reachability is not a property of a service alone.
  3. Space-Track, optional, only when SPACETRACK_IDENTITY and SPACETRACK_PASSWORD are both set. Without them the application starts on the first two and says so in its startup log.

A source that says "I do not have this object" ends the chain; a source that cannot be reached does not. Space-Track is an availability fallback, not a coverage one — it is the catalogue CelesTrak republishes — and its calls are capped well under the published limits of 30 a minute and 300 an hour, because an account suspended is a source lost.

The line to read in the startup log is TLE sources, in order: [...]. It says exactly what this instance will try, which is the first thing worth knowing when the deployed application and the local one disagree.

Network resilience on Render

Connection establishment has a 5 s timeout and each source has its own 15 s request budget. Render logs showed connection timeouts on both endpoints; response timings alone cannot establish whether a connection or a response timed out. render.yaml prefers the Vercel relay on Render. For a manually configured service, set TLE_BASE_URLS to https://sat-pass-predictor-nine.vercel.app/tle-upstream,https://celestrak.org in its dashboard. A Blueprint setting does not automatically update a manually created service.

Failed sources remain available but move to the end for ten minutes. When nothing has been fetched, a failed attempt is remembered for 15 seconds to avoid serial network calls from queued requests. Existing snapshots retain the five-minute retry interval and the seven-day age limit. Without database activation this store is still in memory: a restart loses it. With api-access.enabled, snapshots survive restarts as described below. No timeout setting guarantees availability during an upstream outage.

Not paying for a call twice

Propagating a 48 h window and sampling it at 10 s takes about 110 ms. That is cheap and it is linear: a thousand identical calls do the work a thousand times. Two things stop that, and neither changes what the API answers.

An answer is reused when the elements have not changed. The cache is keyed on the satellite, the observer, the window and the minimum elevation, and it is invalidated by the TLE it was computed from rather than by a clock — a republished TLE is a miss, which is the physically correct rule since the elements are the only input that changes on its own. A maximum age (prediction-cache.max-age, five minutes) bounds something else: the window starts at the instant of the request, so an entry served later describes a window that starts slightly in the past, and the computedAt in the response is the one of the computation it comes from. The observer is not rounded onto a grid: serving a computation made for a nearby point would mean publishing an observer that was not the one computed for. PREDICTION_CACHE_ENABLED=false turns the whole thing off.

The elements themselves survive a restart, when a database is configured: one row per satellite in tle_snapshots. A stored row is treated exactly like elements held in memory, so what it saves depends on its age: younger than tle.refresh-after (2 h) it is served as it is, and the first call after a deploy owes nothing to CelesTrak. Older, and a refresh is attempted immediately — the row then only protects against that refresh failing, which it does by serving its own elements with their real age, up to the seven-day limit past which nothing is served at all. Nothing expires in the table itself; the row is deleted only when the catalogue no longer has the object.

Measured rather than asserted, with scripts/measure-passes.sh against a local instance (200 sequential calls, ISS over Lyon, 48 h, JDK 25 on an Apple M-series laptop):

Workloadp50p95Propagation per 1 000 calls
Same request repeated, cache off112.7 ms121.9 ms112 s
Same request repeated, cache on2.4 ms3.3 ms~0 s
200 distinct observers, cache on113.4 ms120.8 ms112 s

The third row is the point of the table: on a workload the cache cannot help, it costs nothing measurable. The two meters behind those numbers, satpass.predictions and satpass.prediction.duration, are on /actuator/metrics. The last column is elapsed time inside the propagation, not CPU time — a Micrometer timer measures a duration, and nothing here samples a thread's CPU clock. On this single-threaded run the two are close; they are not the same quantity.

Sky chart

Select a bar or table row to inspect a pass. The SVG uses the API track, with the selected minimum elevation drawn as a dashed circle. Minute dots and phase details accompany a shared clock: play/pause at 20×, scrub, stop at LOS, and reset when selecting another pass. Playback starts paused (including for reduced-motion users). All displayed times include local/UTC labels. Intermediate readouts interpolate API samples; no orbit is computed in the browser. The curve distinguishes fully sunlit samples from eclipse (including penumbra). The selected pass and table identify potential naked-eye visibility: the satellite must be fully sunlit and the observer's Sun at or below -6°. Weather and brightness are not modelled. Flags are sampled every 10 s, so short opportunities can be missed and shadow-entry times are approximate. The API exposes these conditions separately as track[].illuminated and track[].visible.

Searching by name

The satellite field accepts a name as well as a NORAD number. Digits are used as-is, with no lookup. Anything else queries GET /api/satellites?q=…&limit=…, which returns { results: [{ noradId, name }], catalogFetchedAt }, and a suggestion must be chosen before passes are computed: a half-typed name never silently keeps the previous satellite.

The index is CelesTrak's active group (gp.php?GROUP=active&FORMAT=TLE), fetched through the same tle.base-urls chain — the Vercel relay already forwards that path. It is downloaded on the first search, not at startup, held in memory, refreshed after catalog.refresh-after (24 h) and never retried more often than catalog.retry-after (5 min). A failed refresh keeps the previous index; with nothing ever downloaded, the endpoint answers 503 catalog-unavailable and NORAD numbers keep working.

Limits, stated rather than hidden: names are CelesTrak's (HST, not "Hubble"); debris and rocket bodies are not in the active group; the index is not persisted, so each restart downloads it again on its first search. The endpoint is not metered — a lookup is an in-memory scan, and the prediction it leads to is metered as before.

Multi-satellite discovery

The “Next favourable window · 7 days” panel compares up to five distinct NORAD IDs, using the observer and minimum elevation from the form. Each satellite gets one existing /api/passes request with hours=168; no new backend contract is required. Results are ranked by their first favourable sample, which may occur after AOS. The displayed interval is the first consecutive run of favourable samples, not the entire pass.

Failures and empty results are reported per satellite. “Earliest” only covers successful predictions; each seven-day window starts at that satellite's server computation time. Restarting cancels pending browser requests. “Inspect pass” opens the returned prediction in the existing table, sky chart and globe without fetching it again. The panel retains the searched position and threshold so later form edits do not relabel old results.

The element age at the opportunity is shown, including the wait until the pass: a seven-day forecast can rely on substantially older elements than the current TLE age suggests. This remains a sampled geometric opportunity, not a brightness or weather forecast.

Globe

A terrestrial-frame companion to the sky chart, sharing its clock: three.js (r128, UMD, loaded from cdnjs — the one external runtime dependency in the frontend, with an explicit fallback message if it cannot load) renders the ground track, the visibility circle and the day/night terminator from the same track and subPoint data, drag to rotate. The ground track's two colours now show the satellite's illumination computed by Orekit. Earth's approximate terminator is only a visual reference and does not determine visibility. Details and the decisions behind them: ROADMAP.md.

Physical model

What the numbers mean, and what they do not. The code says the same thing where it happens — mostly in PassPredictionService.

Reference frames

FrameWhat it isRole here
TEMETrue Equator, Mean Equinox of date. Quasi-inertial, and specific to SGP4: its equinox is neither the true one nor a standard IERS one.What SGP4 outputs. A TLE is only meaningful in it.
GCRFGeocentric Celestial Reference Frame, the inertial IERS frame aligned with the ICRS.The hub of Orekit's frame tree: TEME reaches ITRF through it.
ITRFInternational Terrestrial Reference Frame, fixed to the Earth's crust and rotating with it.Where the observer lives: WGS84 ellipsoid, TopocentricFrame.

A classic mistake is to treat SGP4's output as if it were already in an inertial or terrestrial frame. The error is not subtle: the ground under Lyon turns at about 325 m/s, so ignoring the Earth's rotation puts the satellite some 100 km off within the five minutes of a pass. Here the satellite's state is expressed in TEME and converted to ITRF at every date, through precession-nutation, the Earth's rotation angle and polar motion (IERS 2010 conventions, full EOP, not the simplified model). Elevation and azimuth are then read in the observer's topocentric frame.

Time scales

ScaleWhat it isRole here
UTCCivil time, kept within 0.9 s of the Earth's rotation by leap seconds.Every instant the API reads or returns, and TLE epochs.
TAIInternational Atomic Time, continuous. UTC = TAI − 37 s since 2017.What Orekit computes in internally, so that a leap second never becomes a jump in a propagation.
UT1The Earth's actual rotation angle, irregular and measured after the fact.Orients ITRF with respect to the sky. UT1 − UTC comes from the EOP.

The leap-second table (tai-utc.dat) is part of orekit-data. Without it, UTC cannot be converted at all — which is why the application refuses to start rather than fail at its first computation.

Earth orientation parameters

The EOP, published by the IERS, describe what no formula can predict: UT1 − UTC, polar motion (the rotation axis wanders by a few metres over the surface) and small nutation corrections. For future dates — every prediction this application makes — Orekit uses the predicted values of IERS Bulletin A shipped in orekit-data.

Their weight, in orders of magnitude: one second of UT1 error is 15 arcseconds of Earth rotation, about 465 m at the equator; polar motion is a few metres. Both are far below the SGP4 error described next. They are loaded anyway, because a correct frame chain costs nothing at runtime, and because the Skyfield comparison can only be exact if the frames are.

Limitations of SGP4

  • The elements are mean elements, not a state. A TLE is fitted to observations for SGP4: fed into any other propagator, it gives a wrong orbit. Orekit never reinterprets it outside TLEPropagator.
  • The error grows with the TLE's age: about 1 km at the epoch, then roughly 1 to 3 km per day in low Earth orbit, more during a geomagnetic storm, when atmospheric drag departs from the single B* term the model has. Hence the TLE age banner, the refusal to predict past seven days of age, and the window capped at ten days.
  • Manoeuvres are invisible. An ISS reboost makes every prediction based on the previous TLE wrong until a new one is published.
  • Positions are geometric. No atmospheric refraction (about 0.1° near 5° of elevation, more below) and no light-time correction (a few milliseconds at low-orbit distances).

The second point dominates everything else in this list. Two days of age mean 2 to 6 km, which the ISS covers in under a second at 7.7 km/s: invisible to a naked-eye observer, but still a thousand times the EOP effects above. That is why the banner turns the age into a timing order of magnitude instead of hiding it.

Validation

An astrodynamics computation that is compared to nothing is not a computation, it is an opinion. The project therefore rests on two distinct checks, which do not prove the same thing and neither of which replaces the other.

A single reference file. backend/src/test/resources/validation/iss-lyon-reference.json carries the TLE, the observer, the window and the expected passes. It is read by the Java test and by the Python script. Two files would have meant two truths, one of which could have lied without a sound.

Check 1 — regression (Java, in CI). PassPredictionReferenceTest verifies that Orekit reproduces the reference. It watches for drift: a version bump, a refresh of the IERS data, a rewrite of the service. It says nothing about correctness: a wrong computation would freeze a wrong reference, which this test would then defend faithfully.

Check 2 — correctness (Python, outside CI). scripts/validate-against-skyfield.py confronts the same reference with Skyfield, an implementation of SGP4 written in Python, sharing no code with Orekit.

The naive comparison — asking Skyfield for its passes and comparing dates — gives discrepancies of up to a second, without saying which of the two is wrong: Skyfield's find_events is documented as accurate to the second. The script therefore works the other way round: it takes the dates produced by Orekit and asks Skyfield what elevation and azimuth it computes at those exact instants. If Orekit is right, Skyfield must find exactly the threshold at the boundaries of the pass.

Four checks: the boundaries, the culmination (its value and the fact that it is a local maximum), the three azimuths, and completeness — the last one being the only check able to detect a pass missed by Orekit's 60 s detection step.

Tolerances, and why

QuantityMeasured discrepancyToleranceMargin
Elevation (Orekit vs Skyfield)0.53 millidegree10 millidegreesx19
Azimuth (Orekit vs Skyfield)2.0 millidegrees20 millidegreesx10
Dates (Java regression)01 s—
Angles (Java regression)00.1 degree—

The regression tolerances are not physical error margins: they absorb an internal change in Orekit, not a model error, which would be several orders of magnitude larger. Useful landmarks: near AOS, the ISS gains about 0.1 degree of elevation per second — a one-second discrepancy and a 0.1-degree discrepancy therefore describe the same event.

What this validation does not prove

Skyfield and Orekit implement the same model, SGP4. Their agreement establishes that this project's implementation and chain of frames are correct. It says nothing about the gap to the real sky, which is dominated by the age of the TLE: in low Earth orbit, SGP4 drifts by roughly 1 to 3 km per day, more during a geomagnetic storm. That is a limitation of the model, accepted, and the reason the forecast window is bounded to a few days.

A comparison against Heavens-Above would answer the other question. It was deliberately set aside: its discrepancy mixes the TLE error, refraction and the site's own display conventions, and would therefore not be interpretable.

Why the Python script is not in CI

It would require Python and Skyfield in the workflow, to check a file that does not change. The correctness of the reference is established once; it is its drift that must be watched continuously, and the Java test takes care of that. The script is to be re-run by hand whenever the reference changes — which is exactly what the comment at the top of the JSON file asks for.

Self-serve API account

The backend's /account/ page lets a GitHub user create, regenerate and revoke their API key and inspect UTC daily usage. Self-serve preview limits are 100 calls/day and 10/minute. Rotation preserves quota usage; secrets are shown only once.

Accounts are opt-in and require PostgreSQL plus a GitHub OAuth App. Setup, security boundaries and the production release gate: self-serve accounts. The new account page is implemented and locally tested; real GitHub authentication and production activation still need verification. Opt-in Stripe Checkout, Customer Portal and signed webhook billing are implemented; a real Stripe sandbox lifecycle remains unverified. Hobby/Pro quotas use UTC calendar months, with one key per account. See billing setup, quota contract and replay.

Roadmap

Details, milestones and time budget: ROADMAP.md.

  • Orekit data loading, tested
  • Pass computation (TLEPropagator + ElevationDetector)
  • Cross-validation against an independent SGP4 implementation (Skyfield)
  • Track sampling (track, OrekitStepHandler, fixed 10 s step)
  • TLE retrieval from CelesTrak (last known TLE, age exposed)
  • REST API /api/passes (Problem Details, springdoc)
  • Frontend: shell, pass list, ribbon of nights, TLE age banner
  • Polar sky chart (SVG), shared selection and playback controls
  • 3D globe: ground track, visibility circle, terminator
  • Docker Compose, architecture diagram, physical model
  • Demo GIF
  • Potential naked-eye visibility (sunlight + observer darkness)
  • Multi-satellite discovery and next favourable window
  • Optional PostgreSQL with hashed API keys, quotas and persistent usage counters (milestone 11)
  • Answer cache invalidated by the TLE, persistent elements, measured cost per call (milestone 12)
  • GitHub self-serve accounts and key dashboard (milestone 13; production activation pending)
  • Real production GitHub login, issuance and revocation smoke test
  • Stripe Checkout/Portal, signed webhooks and monthly quotas (milestone 14; sandbox activation pending)
  • Real Stripe sandbox lifecycle and production billing activation
  • Commercial readiness and first customers (milestones 15–17)

Validated interface mockup: docs/interface-mockup.html (open it in a browser).

How this repository was written

Part of the code in this repository was written with the assistance of Claude (Anthropic): the commits concerned carry a Co-Authored-By trailer. Better said here than left for the reader to discover in git log.

What that covers, concretely:

  • Code and tests were written with assistance, then run and confronted with an independent reference before being committed. The Validation section describes the procedure; anyone can reproduce it with two commands.
  • Non-trivial technical decisions are documented in the code, with their rationale and their trade-off: the 60 s detection step instead of the 600 s default, the ContinueOnEvent handler without which only one pass would be detected, the elevation maximum as a decreasing event of the derivative, the exclusion of passes truncated by the edges of the window.
  • The limitations of the model are written down rather than passed over in silence: SGP4 drift, no refraction below 5 degrees, the real reach of the validation.

A tool that writes code does not excuse you from being able to defend it. This section exists so that the repository is judged on what it demonstrates, not on what it hides.

warlaxx/sat-pass-predictor

Java

1

19 commits

updated Sep 30, 2026

See the code

See what people are saying

SourceMessageScoreDate

I built a free satellite pass predictor (Orekit, cross-checked against Skyfield). What would make it useful to you? (r/SideProject)

Hey everyone, I've been learning orbital mechanics for a few weeks and ended up building a pass predictor. Backend is Java with Orekit (SGP4 on CelesTrak TLEs), frontend is Angular with a sky chart and a 3D globe. Here it is:…

2

Sep 30, 2026

README

Sat Pass Predictor

CI

Computing and visualising satellite passes over a given point on Earth. Java / Spring Boot backend with Orekit, Angular frontend.

A learning project aimed at the space ecosystem: SGP4 propagation from TLEs, reference frames and time scales, visibility event detection.

Stack

PieceChoice
BackendJava 25, Spring Boot 4.1.1, Maven
DynamicsOrekit 13.1.8
FrontendAngular 22 (standalone, signals, SCSS)
TLEsCelesTrak GP API

Architecture

flowchart LR
    browser([Browser<br/>Angular: list, sky chart, globe])

    subgraph vercel [Vercel — nginx under Docker]
        static[Static bundle]
        relay["/tle-upstream<br/>rewrite"]
    end

    subgraph render [Render — Spring Boot API]
        controller["PassController<br/>GET /api/passes"]
        query[PassQueryService]
        store["TleStore<br/>in-memory, 2 h refresh"]
        chain["FallbackTleClient<br/>sources tried in order"]
        predict["PassPredictionService<br/>SGP4 + event detection"]
        orekit[("Orekit<br/>+ orekit-data")]
    end

    celestrak[(CelesTrak)]
    spacetrack[(Space-Track<br/>optional)]

    browser -- page --> static
    browser -- "/api (proxied)" --> controller
    controller --> query
    query --> store --> chain
    query --> predict --> orekit
    chain --> celestrak
    chain --> relay --> celestrak
    chain -.-> spacetrack

The browser computes nothing: every position the sky chart and the globe draw comes from the API's track, sampled by Orekit. TLEs live in memory with their age exposed, and an identical request is answered from the last computation rather than propagated again — see Not paying for a call twice. Optional PostgreSQL stores API keys, usage counters and the TLEs themselves: see API access for activation, quotas and operator commands. The default demo still starts without a database, and a restart then fetches the elements again.

Prerequisites

  • JDK 25 — the project compiles with release 25; an older JDK fails with release version 25 not supported.

    brew install openjdk@25
    # Homebrew JDKs are keg-only: without this link, /usr/libexec/java_home cannot see them.
    sudo ln -sfn /opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk \
                 /Library/Java/JavaVirtualMachines/openjdk-25.jdk
    export JAVA_HOME=$(/usr/libexec/java_home -v 25)
    
  • No Maven to install: backend/mvnw downloads the pinned version (3.9.16) on first use. It is the command the CI runs, so what passes here passes there.

  • Node 22 LTS

Running it with Docker

Nothing to install but Docker — no JDK, no Node, no Orekit data:

docker compose up --build

Then open http://localhost:4200 (Swagger UI: http://localhost:8080/docs). The first build takes a few minutes: Maven and npm dependencies, plus the Orekit data set, which is downloaded inside the API image at build time, never at runtime. API_PORT and WEB_PORT move the host ports when 8080 or 4200 are already taken by a dev server.

The two images are the two production pieces: backend/Dockerfile is the image Render deploys, and nginx (frontend/nginx.conf.template) stands in for Vercel — static files, /api forwarded server-side, the same caching rules as vercel.json.

Getting started

For development, without Docker:

# 1. Orekit data (leap seconds, EOP, ephemerides) — ~100 MB, not committed
./scripts/fetch-orekit-data.sh

# 2. Backend (http://localhost:8080)
cd backend && ./mvnw spring-boot:run

# 3. Frontend (http://localhost:4200, /api proxied to 8080)
cd frontend && npm install && npm start

Tests

cd backend  && ./mvnw verify
cd frontend && npm test

The cross-validation against Skyfield is a separate script, deliberately kept out of CI (see Validation). It runs in its own virtual environment, so that it depends neither on the machine's default python nor on a global installation:

python3 -m venv .venv-validation
.venv-validation/bin/pip install skyfield
.venv-validation/bin/python scripts/validate-against-skyfield.py

Skyfield requires Python 3. A pip install run under a still-active Python 2 — through pyenv, for instance — fails while compiling sgp4 with a SyntaxError in its setup.py: the message points at sgp4, the cause is the interpreter. python3 -m pip --version says which one is really in use.

Orekit data

Orekit needs an external data set (UTC-TAI history, IERS Earth orientation parameters, gravity models). Without it, the first call to TimeScalesFactory.getUTC() fails. Those files are large and updated regularly: they are not committed, but downloaded by scripts/fetch-orekit-data.sh and loaded at startup by OrekitConfig. The path is configurable through OREKIT_DATA_PATH.

The application refuses to start if the directory is missing: an explicit failure at startup beats an obscure error at the first computation.

TLE sources

Orbital elements come from a chain of sources, tried in order, configured by tle.base-urls and overridable in one go with TLE_BASE_URLS (comma-separated):

  1. https://celestrak.org — the origin.
  2. https://sat-pass-predictor-nine.vercel.app/tle-upstream — the same CelesTrak, reached through a rewrite on the frontend's host. It exists because CelesTrak silently drops packets coming from the shared outbound IPs of the platform the API is deployed on: a connect timeout, no refusal, no DNS error, on a host that answers other datacenters in 10 ms. Reachability is not a property of a service alone.
  3. Space-Track, optional, only when SPACETRACK_IDENTITY and SPACETRACK_PASSWORD are both set. Without them the application starts on the first two and says so in its startup log.

A source that says "I do not have this object" ends the chain; a source that cannot be reached does not. Space-Track is an availability fallback, not a coverage one — it is the catalogue CelesTrak republishes — and its calls are capped well under the published limits of 30 a minute and 300 an hour, because an account suspended is a source lost.

The line to read in the startup log is TLE sources, in order: [...]. It says exactly what this instance will try, which is the first thing worth knowing when the deployed application and the local one disagree.

Network resilience on Render

Connection establishment has a 5 s timeout and each source has its own 15 s request budget. Render logs showed connection timeouts on both endpoints; response timings alone cannot establish whether a connection or a response timed out. render.yaml prefers the Vercel relay on Render. For a manually configured service, set TLE_BASE_URLS to https://sat-pass-predictor-nine.vercel.app/tle-upstream,https://celestrak.org in its dashboard. A Blueprint setting does not automatically update a manually created service.

Failed sources remain available but move to the end for ten minutes. When nothing has been fetched, a failed attempt is remembered for 15 seconds to avoid serial network calls from queued requests. Existing snapshots retain the five-minute retry interval and the seven-day age limit. Without database activation this store is still in memory: a restart loses it. With api-access.enabled, snapshots survive restarts as described below. No timeout setting guarantees availability during an upstream outage.

Not paying for a call twice

Propagating a 48 h window and sampling it at 10 s takes about 110 ms. That is cheap and it is linear: a thousand identical calls do the work a thousand times. Two things stop that, and neither changes what the API answers.

An answer is reused when the elements have not changed. The cache is keyed on the satellite, the observer, the window and the minimum elevation, and it is invalidated by the TLE it was computed from rather than by a clock — a republished TLE is a miss, which is the physically correct rule since the elements are the only input that changes on its own. A maximum age (prediction-cache.max-age, five minutes) bounds something else: the window starts at the instant of the request, so an entry served later describes a window that starts slightly in the past, and the computedAt in the response is the one of the computation it comes from. The observer is not rounded onto a grid: serving a computation made for a nearby point would mean publishing an observer that was not the one computed for. PREDICTION_CACHE_ENABLED=false turns the whole thing off.

The elements themselves survive a restart, when a database is configured: one row per satellite in tle_snapshots. A stored row is treated exactly like elements held in memory, so what it saves depends on its age: younger than tle.refresh-after (2 h) it is served as it is, and the first call after a deploy owes nothing to CelesTrak. Older, and a refresh is attempted immediately — the row then only protects against that refresh failing, which it does by serving its own elements with their real age, up to the seven-day limit past which nothing is served at all. Nothing expires in the table itself; the row is deleted only when the catalogue no longer has the object.

Measured rather than asserted, with scripts/measure-passes.sh against a local instance (200 sequential calls, ISS over Lyon, 48 h, JDK 25 on an Apple M-series laptop):

Workloadp50p95Propagation per 1 000 calls
Same request repeated, cache off112.7 ms121.9 ms112 s
Same request repeated, cache on2.4 ms3.3 ms~0 s
200 distinct observers, cache on113.4 ms120.8 ms112 s

The third row is the point of the table: on a workload the cache cannot help, it costs nothing measurable. The two meters behind those numbers, satpass.predictions and satpass.prediction.duration, are on /actuator/metrics. The last column is elapsed time inside the propagation, not CPU time — a Micrometer timer measures a duration, and nothing here samples a thread's CPU clock. On this single-threaded run the two are close; they are not the same quantity.

Sky chart

Select a bar or table row to inspect a pass. The SVG uses the API track, with the selected minimum elevation drawn as a dashed circle. Minute dots and phase details accompany a shared clock: play/pause at 20×, scrub, stop at LOS, and reset when selecting another pass. Playback starts paused (including for reduced-motion users). All displayed times include local/UTC labels. Intermediate readouts interpolate API samples; no orbit is computed in the browser. The curve distinguishes fully sunlit samples from eclipse (including penumbra). The selected pass and table identify potential naked-eye visibility: the satellite must be fully sunlit and the observer's Sun at or below -6°. Weather and brightness are not modelled. Flags are sampled every 10 s, so short opportunities can be missed and shadow-entry times are approximate. The API exposes these conditions separately as track[].illuminated and track[].visible.

Searching by name

The satellite field accepts a name as well as a NORAD number. Digits are used as-is, with no lookup. Anything else queries GET /api/satellites?q=…&limit=…, which returns { results: [{ noradId, name }], catalogFetchedAt }, and a suggestion must be chosen before passes are computed: a half-typed name never silently keeps the previous satellite.

The index is CelesTrak's active group (gp.php?GROUP=active&FORMAT=TLE), fetched through the same tle.base-urls chain — the Vercel relay already forwards that path. It is downloaded on the first search, not at startup, held in memory, refreshed after catalog.refresh-after (24 h) and never retried more often than catalog.retry-after (5 min). A failed refresh keeps the previous index; with nothing ever downloaded, the endpoint answers 503 catalog-unavailable and NORAD numbers keep working.

Limits, stated rather than hidden: names are CelesTrak's (HST, not "Hubble"); debris and rocket bodies are not in the active group; the index is not persisted, so each restart downloads it again on its first search. The endpoint is not metered — a lookup is an in-memory scan, and the prediction it leads to is metered as before.

Multi-satellite discovery

The “Next favourable window · 7 days” panel compares up to five distinct NORAD IDs, using the observer and minimum elevation from the form. Each satellite gets one existing /api/passes request with hours=168; no new backend contract is required. Results are ranked by their first favourable sample, which may occur after AOS. The displayed interval is the first consecutive run of favourable samples, not the entire pass.

Failures and empty results are reported per satellite. “Earliest” only covers successful predictions; each seven-day window starts at that satellite's server computation time. Restarting cancels pending browser requests. “Inspect pass” opens the returned prediction in the existing table, sky chart and globe without fetching it again. The panel retains the searched position and threshold so later form edits do not relabel old results.

The element age at the opportunity is shown, including the wait until the pass: a seven-day forecast can rely on substantially older elements than the current TLE age suggests. This remains a sampled geometric opportunity, not a brightness or weather forecast.

Globe

A terrestrial-frame companion to the sky chart, sharing its clock: three.js (r128, UMD, loaded from cdnjs — the one external runtime dependency in the frontend, with an explicit fallback message if it cannot load) renders the ground track, the visibility circle and the day/night terminator from the same track and subPoint data, drag to rotate. The ground track's two colours now show the satellite's illumination computed by Orekit. Earth's approximate terminator is only a visual reference and does not determine visibility. Details and the decisions behind them: ROADMAP.md.

Physical model

What the numbers mean, and what they do not. The code says the same thing where it happens — mostly in PassPredictionService.

Reference frames

FrameWhat it isRole here
TEMETrue Equator, Mean Equinox of date. Quasi-inertial, and specific to SGP4: its equinox is neither the true one nor a standard IERS one.What SGP4 outputs. A TLE is only meaningful in it.
GCRFGeocentric Celestial Reference Frame, the inertial IERS frame aligned with the ICRS.The hub of Orekit's frame tree: TEME reaches ITRF through it.
ITRFInternational Terrestrial Reference Frame, fixed to the Earth's crust and rotating with it.Where the observer lives: WGS84 ellipsoid, TopocentricFrame.

A classic mistake is to treat SGP4's output as if it were already in an inertial or terrestrial frame. The error is not subtle: the ground under Lyon turns at about 325 m/s, so ignoring the Earth's rotation puts the satellite some 100 km off within the five minutes of a pass. Here the satellite's state is expressed in TEME and converted to ITRF at every date, through precession-nutation, the Earth's rotation angle and polar motion (IERS 2010 conventions, full EOP, not the simplified model). Elevation and azimuth are then read in the observer's topocentric frame.

Time scales

ScaleWhat it isRole here
UTCCivil time, kept within 0.9 s of the Earth's rotation by leap seconds.Every instant the API reads or returns, and TLE epochs.
TAIInternational Atomic Time, continuous. UTC = TAI − 37 s since 2017.What Orekit computes in internally, so that a leap second never becomes a jump in a propagation.
UT1The Earth's actual rotation angle, irregular and measured after the fact.Orients ITRF with respect to the sky. UT1 − UTC comes from the EOP.

The leap-second table (tai-utc.dat) is part of orekit-data. Without it, UTC cannot be converted at all — which is why the application refuses to start rather than fail at its first computation.

Earth orientation parameters

The EOP, published by the IERS, describe what no formula can predict: UT1 − UTC, polar motion (the rotation axis wanders by a few metres over the surface) and small nutation corrections. For future dates — every prediction this application makes — Orekit uses the predicted values of IERS Bulletin A shipped in orekit-data.

Their weight, in orders of magnitude: one second of UT1 error is 15 arcseconds of Earth rotation, about 465 m at the equator; polar motion is a few metres. Both are far below the SGP4 error described next. They are loaded anyway, because a correct frame chain costs nothing at runtime, and because the Skyfield comparison can only be exact if the frames are.

Limitations of SGP4

  • The elements are mean elements, not a state. A TLE is fitted to observations for SGP4: fed into any other propagator, it gives a wrong orbit. Orekit never reinterprets it outside TLEPropagator.
  • The error grows with the TLE's age: about 1 km at the epoch, then roughly 1 to 3 km per day in low Earth orbit, more during a geomagnetic storm, when atmospheric drag departs from the single B* term the model has. Hence the TLE age banner, the refusal to predict past seven days of age, and the window capped at ten days.
  • Manoeuvres are invisible. An ISS reboost makes every prediction based on the previous TLE wrong until a new one is published.
  • Positions are geometric. No atmospheric refraction (about 0.1° near 5° of elevation, more below) and no light-time correction (a few milliseconds at low-orbit distances).

The second point dominates everything else in this list. Two days of age mean 2 to 6 km, which the ISS covers in under a second at 7.7 km/s: invisible to a naked-eye observer, but still a thousand times the EOP effects above. That is why the banner turns the age into a timing order of magnitude instead of hiding it.

Validation

An astrodynamics computation that is compared to nothing is not a computation, it is an opinion. The project therefore rests on two distinct checks, which do not prove the same thing and neither of which replaces the other.

A single reference file. backend/src/test/resources/validation/iss-lyon-reference.json carries the TLE, the observer, the window and the expected passes. It is read by the Java test and by the Python script. Two files would have meant two truths, one of which could have lied without a sound.

Check 1 — regression (Java, in CI). PassPredictionReferenceTest verifies that Orekit reproduces the reference. It watches for drift: a version bump, a refresh of the IERS data, a rewrite of the service. It says nothing about correctness: a wrong computation would freeze a wrong reference, which this test would then defend faithfully.

Check 2 — correctness (Python, outside CI). scripts/validate-against-skyfield.py confronts the same reference with Skyfield, an implementation of SGP4 written in Python, sharing no code with Orekit.

The naive comparison — asking Skyfield for its passes and comparing dates — gives discrepancies of up to a second, without saying which of the two is wrong: Skyfield's find_events is documented as accurate to the second. The script therefore works the other way round: it takes the dates produced by Orekit and asks Skyfield what elevation and azimuth it computes at those exact instants. If Orekit is right, Skyfield must find exactly the threshold at the boundaries of the pass.

Four checks: the boundaries, the culmination (its value and the fact that it is a local maximum), the three azimuths, and completeness — the last one being the only check able to detect a pass missed by Orekit's 60 s detection step.

Tolerances, and why

QuantityMeasured discrepancyToleranceMargin
Elevation (Orekit vs Skyfield)0.53 millidegree10 millidegreesx19
Azimuth (Orekit vs Skyfield)2.0 millidegrees20 millidegreesx10
Dates (Java regression)01 s—
Angles (Java regression)00.1 degree—

The regression tolerances are not physical error margins: they absorb an internal change in Orekit, not a model error, which would be several orders of magnitude larger. Useful landmarks: near AOS, the ISS gains about 0.1 degree of elevation per second — a one-second discrepancy and a 0.1-degree discrepancy therefore describe the same event.

What this validation does not prove

Skyfield and Orekit implement the same model, SGP4. Their agreement establishes that this project's implementation and chain of frames are correct. It says nothing about the gap to the real sky, which is dominated by the age of the TLE: in low Earth orbit, SGP4 drifts by roughly 1 to 3 km per day, more during a geomagnetic storm. That is a limitation of the model, accepted, and the reason the forecast window is bounded to a few days.

A comparison against Heavens-Above would answer the other question. It was deliberately set aside: its discrepancy mixes the TLE error, refraction and the site's own display conventions, and would therefore not be interpretable.

Why the Python script is not in CI

It would require Python and Skyfield in the workflow, to check a file that does not change. The correctness of the reference is established once; it is its drift that must be watched continuously, and the Java test takes care of that. The script is to be re-run by hand whenever the reference changes — which is exactly what the comment at the top of the JSON file asks for.

Self-serve API account

The backend's /account/ page lets a GitHub user create, regenerate and revoke their API key and inspect UTC daily usage. Self-serve preview limits are 100 calls/day and 10/minute. Rotation preserves quota usage; secrets are shown only once.

Accounts are opt-in and require PostgreSQL plus a GitHub OAuth App. Setup, security boundaries and the production release gate: self-serve accounts. The new account page is implemented and locally tested; real GitHub authentication and production activation still need verification. Opt-in Stripe Checkout, Customer Portal and signed webhook billing are implemented; a real Stripe sandbox lifecycle remains unverified. Hobby/Pro quotas use UTC calendar months, with one key per account. See billing setup, quota contract and replay.

Roadmap

Details, milestones and time budget: ROADMAP.md.

  • Orekit data loading, tested
  • Pass computation (TLEPropagator + ElevationDetector)
  • Cross-validation against an independent SGP4 implementation (Skyfield)
  • Track sampling (track, OrekitStepHandler, fixed 10 s step)
  • TLE retrieval from CelesTrak (last known TLE, age exposed)
  • REST API /api/passes (Problem Details, springdoc)
  • Frontend: shell, pass list, ribbon of nights, TLE age banner
  • Polar sky chart (SVG), shared selection and playback controls
  • 3D globe: ground track, visibility circle, terminator
  • Docker Compose, architecture diagram, physical model
  • Demo GIF
  • Potential naked-eye visibility (sunlight + observer darkness)
  • Multi-satellite discovery and next favourable window
  • Optional PostgreSQL with hashed API keys, quotas and persistent usage counters (milestone 11)
  • Answer cache invalidated by the TLE, persistent elements, measured cost per call (milestone 12)
  • GitHub self-serve accounts and key dashboard (milestone 13; production activation pending)
  • Real production GitHub login, issuance and revocation smoke test
  • Stripe Checkout/Portal, signed webhooks and monthly quotas (milestone 14; sandbox activation pending)
  • Real Stripe sandbox lifecycle and production billing activation
  • Commercial readiness and first customers (milestones 15–17)

Validated interface mockup: docs/interface-mockup.html (open it in a browser).

How this repository was written

Part of the code in this repository was written with the assistance of Claude (Anthropic): the commits concerned carry a Co-Authored-By trailer. Better said here than left for the reader to discover in git log.

What that covers, concretely:

  • Code and tests were written with assistance, then run and confronted with an independent reference before being committed. The Validation section describes the procedure; anyone can reproduce it with two commands.
  • Non-trivial technical decisions are documented in the code, with their rationale and their trade-off: the 60 s detection step instead of the 600 s default, the ContinueOnEvent handler without which only one pass would be detected, the elevation maximum as a decreasing event of the derivative, the exclusion of passes truncated by the edges of the window.
  • The limitations of the model are written down rather than passed over in silence: SGP4 drift, no refraction below 5 degrees, the real reach of the validation.

A tool that writes code does not excuse you from being able to defend it. This section exists so that the repository is judged on what it demonstrates, not on what it hides.

Languages

Java

62.7%

TypeScript

23.8%

SCSS

4.1%

HTML

4.0%

Shell

1.7%

JavaScript

1.7%

Python

1.1%