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.
| Piece | Choice |
|---|---|
| Backend | Java 25, Spring Boot 4.1.1, Maven |
| Dynamics | Orekit 13.1.8 |
| Frontend | Angular 22 (standalone, signals, SCSS) |
| TLEs | CelesTrak GP API |
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.
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
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.
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
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 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.
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):
https://celestrak.org — the origin.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.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.
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.
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):
| Workload | p50 | p95 | Propagation per 1 000 calls |
|---|---|---|---|
| Same request repeated, cache off | 112.7 ms | 121.9 ms | 112 s |
| Same request repeated, cache on | 2.4 ms | 3.3 ms | ~0 s |
| 200 distinct observers, cache on | 113.4 ms | 120.8 ms | 112 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.
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.
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.
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.
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.
What the numbers mean, and what they do not. The code says the same thing where it
happens — mostly in PassPredictionService.
| Frame | What it is | Role here |
|---|---|---|
| TEME | True 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. |
| GCRF | Geocentric Celestial Reference Frame, the inertial IERS frame aligned with the ICRS. | The hub of Orekit's frame tree: TEME reaches ITRF through it. |
| ITRF | International 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.
| Scale | What it is | Role here |
|---|---|---|
| UTC | Civil time, kept within 0.9 s of the Earth's rotation by leap seconds. | Every instant the API reads or returns, and TLE epochs. |
| TAI | International 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. |
| UT1 | The 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.
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.
TLEPropagator.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.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.
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.
| Quantity | Measured discrepancy | Tolerance | Margin |
|---|---|---|---|
| Elevation (Orekit vs Skyfield) | 0.53 millidegree | 10 millidegrees | x19 |
| Azimuth (Orekit vs Skyfield) | 2.0 millidegrees | 20 millidegrees | x10 |
| Dates (Java regression) | 0 | 1 s | — |
| Angles (Java regression) | 0 | 0.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.
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.
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.
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.
Details, milestones and time budget: ROADMAP.md.
TLEPropagator + ElevationDetector)track, OrekitStepHandler, fixed 10 s step)/api/passes (Problem Details, springdoc)Validated interface mockup: docs/interface-mockup.html (open it in a browser).
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:
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.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.
Java
62.7%
TypeScript
23.8%
SCSS
4.1%
HTML
4.0%
Shell
1.7%
JavaScript
1.7%
Python
1.1%
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.
| Piece | Choice |
|---|---|
| Backend | Java 25, Spring Boot 4.1.1, Maven |
| Dynamics | Orekit 13.1.8 |
| Frontend | Angular 22 (standalone, signals, SCSS) |
| TLEs | CelesTrak GP API |
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.
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
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.
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
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 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.
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):
https://celestrak.org — the origin.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.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.
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.
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):
| Workload | p50 | p95 | Propagation per 1 000 calls |
|---|---|---|---|
| Same request repeated, cache off | 112.7 ms | 121.9 ms | 112 s |
| Same request repeated, cache on | 2.4 ms | 3.3 ms | ~0 s |
| 200 distinct observers, cache on | 113.4 ms | 120.8 ms | 112 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.
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.
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.
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.
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.
What the numbers mean, and what they do not. The code says the same thing where it
happens — mostly in PassPredictionService.
| Frame | What it is | Role here |
|---|---|---|
| TEME | True 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. |
| GCRF | Geocentric Celestial Reference Frame, the inertial IERS frame aligned with the ICRS. | The hub of Orekit's frame tree: TEME reaches ITRF through it. |
| ITRF | International 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.
| Scale | What it is | Role here |
|---|---|---|
| UTC | Civil time, kept within 0.9 s of the Earth's rotation by leap seconds. | Every instant the API reads or returns, and TLE epochs. |
| TAI | International 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. |
| UT1 | The 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.
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.
TLEPropagator.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.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.
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.
| Quantity | Measured discrepancy | Tolerance | Margin |
|---|---|---|---|
| Elevation (Orekit vs Skyfield) | 0.53 millidegree | 10 millidegrees | x19 |
| Azimuth (Orekit vs Skyfield) | 2.0 millidegrees | 20 millidegrees | x10 |
| Dates (Java regression) | 0 | 1 s | — |
| Angles (Java regression) | 0 | 0.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.
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.
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.
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.
Details, milestones and time budget: ROADMAP.md.
TLEPropagator + ElevationDetector)track, OrekitStepHandler, fixed 10 s step)/api/passes (Problem Details, springdoc)Validated interface mockup: docs/interface-mockup.html (open it in a browser).
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:
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.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.
Java
62.7%
TypeScript
23.8%
SCSS
4.1%
HTML
4.0%
Shell
1.7%
JavaScript
1.7%
Python
1.1%