TL;DR — I built a full-stack app that shows one place — the Sheffield Cholera Monument — from two scales at once: a satellite's-eye vegetation view from space, and a centimeter-scale 3D model from the ground. This post is about the backend: a FastAPI service that authenticates against the Copernicus Sentinel Hub, pulls the newest real Sentinel-2 scene over a bounding box, computes NDVI, and then pins a satellite marker to the exact point in orbit where Sentinel-2A passed closest to the monument for that scene. The satellite-data pipeline is the heart of it, so that's where I'll spend most of the words.
What you'll get out of it
- How to obtain live satellite imagery from Copernicus: OAuth token, then the Catalog, Process, and Statistics APIs
- Why "the newest scene" is a real query, not a timestamp — and how to pin every view to that one scene
- Turning a two-line element (TLE) into a satellite position with Skyfield + SGP4, vectorized with NumPy
- Designing endpoints that never break the frontend, even when an upstream API is down
Contents
- The idea: one place, two scales
- Getting satellite data, step one: authentication
- Step two: finding the newest real scene
- Step three: NDVI imagery and statistics
- Step four: from a TLE to a position in orbit
- Pinning the satellite to the scene
- Designing endpoints that can't break the frontend
- Keeping it light: caching
- Takeaways
The idea: one place, two scales
The project's thesis is simple: observation scale changes what you can understand about a place. From orbit, Sentinel-2 sees vegetation health across a whole neighborhood at ~10 m per pixel but knows nothing of texture. From the ground, a photogrammetric mesh captures sub-centimeter detail of a single monument but sees nothing of its surroundings. Put both on one interactive globe and the contrast becomes the point.
The frontend is a CesiumJS 3D globe; the ground model is a RealityScan .glb. Neither is what makes this a backend story. What makes it a backend story is everything that has to happen server-side to get live, honest, self-consistent satellite data onto that globe — where "self-consistent" means the imagery, the reported date, and the satellite's position all describe the same acquisition.
The stack for that:
| Concern | Tool |
|---|---|
| API server | FastAPI + Uvicorn |
| Imagery & metadata | Copernicus Sentinel Hub (Catalog / Process / Statistics APIs) |
| Orbital mechanics | Skyfield (SGP4 propagation) |
| Live orbital elements | Celestrak TLE feed |
| Math | NumPy (vectorized) |
Four endpoints: latest-scene, ndvi, ndvi-stats, and orbit-at-scene-time. Let's build the data pipeline in the order the data actually flows.
Getting satellite data, step one: authentication
Copernicus Sentinel Hub uses OAuth2 client-credentials. You register an app in the Copernicus Data Space, get a client ID and secret, and exchange them for a short-lived bearer token that every subsequent request carries. The secret never touches the frontend — it lives in backend environment variables (set in the Render dashboard in production), which is the entire reason a backend exists here rather than calling the API from the browser.
TOKEN_URL = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token"
def get_token():
res = requests.post(
TOKEN_URL,
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
timeout=30,
)
res.raise_for_status()
return res.json()["access_token"]
That's the whole handshake. Every data call below starts by getting a token and putting it in an Authorization: Bearer … header. The backend is, among other things, an auth proxy: it holds the credentials, and the browser only ever talks to the backend.
Step two: finding the newest real scene
Here's the design decision that shapes everything: the dashboard shows the newest available scene, and reports that scene's real acquisition date — not the time you happened to load the page. Satellites don't image every spot continuously; Sentinel-2 revisits a given area every few days, and cloud cover knocks out many passes. So "what's the latest usable image of this exact place?" is a genuine query.
That query goes to the Sentinel Hub Catalog API. I ask for every Sentinel-2 L2A scene intersecting my bounding box in the last 30 days, then sort client-side and pick the newest one under a cloud-cover threshold:
BBOX = [-1.4635, 53.3745, -1.4550, 53.3798] # around the monument
def get_latest_scene_meta(days=30, max_cloud=30):
now = datetime.now(timezone.utc)
start = now - timedelta(days=days)
body = {
"collections": ["sentinel-2-l2a"],
"bbox": BBOX,
"datetime": f"{start:%Y-%m-%dT%H:%M:%SZ}/{now:%Y-%m-%dT%H:%M:%SZ}",
"limit": 50,
}
token = get_token()
res = requests.post(CATALOG_URL, headers={"Authorization": f"Bearer {token}"},
json=body, timeout=30)
res.raise_for_status()
feats = res.json().get("features", [])
# newest first
feats.sort(key=lambda f: f["properties"].get("datetime", ""), reverse=True)
# newest scene under the cloud threshold, else newest of all
chosen = next(
(f for f in feats if f["properties"].get("eo:cloud_cover", 100) <= max_cloud),
None,
) or (feats[0] if feats else None)
if chosen:
return {
"datetime": chosen["properties"]["datetime"],
"cloud_cover": round(float(chosen["properties"].get("eo:cloud_cover", 0)), 1),
}
return None
Two small robustness choices worth calling out. Sorting happens client-side rather than via a sortby in the request, because not every catalog deployment supports that parameter reliably — sorting 50 features locally is free and removes a compatibility assumption. And the or feats[0] fallback means that if every recent scene is cloudy, the dashboard still shows the newest one rather than nothing; a cloudy real scene beats an empty panel. The whole function returns None on any failure so callers can degrade gracefully — a pattern that becomes a theme.
The latest-scene endpoint wraps this and returns a clean metadata object. Crucially, that scene's datetime becomes the anchor every other view is tied to.
Step three: NDVI imagery and statistics
NDVI — the Normalized Difference Vegetation Index — is the classic "how green is it" measure: (NIR − Red) / (NIR + Red), using Sentinel-2's B08 (near-infrared) and B04 (red) bands. Healthy vegetation reflects strongly in NIR and absorbs red, so it scores high.
Sentinel Hub's Process API lets you compute this server-side at the source with an evalscript — a small JS function that runs per pixel on the imagery — and hand back a finished PNG. No raw bands cross the network; you receive exactly the visualization you asked for. I bucket the NDVI values into a simple color ramp from bare-earth brown to dense-vegetation green:
evalscript = """
//VERSION=3
function setup() {
return { input: ["B04", "B08"], output: { bands: 3 } };
}
function evaluatePixel(sample) {
let ndvi = (sample.B08 - sample.B04) / (sample.B08 + sample.B04);
if (ndvi < 0.0) return [0.3, 0.2, 0.1];
if (ndvi < 0.2) return [0.8, 0.7, 0.3];
if (ndvi < 0.4) return [0.4, 0.8, 0.3];
if (ndvi < 0.6) return [0.2, 0.6, 0.2];
return [0.0, 0.4, 0.0];
}
"""
The request sends this evalscript alongside the bounding box, a 30-day time range, a 30% cloud filter, and a 512×512 output size, then streams the PNG straight back to the browser:
res = requests.post(PROCESS_URL, headers={"Authorization": f"Bearer {token}"}, json=payload)
if res.status_code != 200:
return JSONResponse(content=res.json(), status_code=res.status_code)
return Response(content=res.content, media_type="image/png")
512×512 is a deliberate balance: fine enough to show real spatial structure at Sentinel-2's resolution, small enough to stay snappy for real-time draping over the globe.
The ndvi-stats endpoint does the numerical companion via the Statistics API, aggregating NDVI daily over a 60-day window and pulling out the mean — this time with a dataMask band so cloud/no-data pixels are excluded from the average rather than dragging it down. It returns a single {"mean": 0.342} for the telemetry readout.
Step four: from a TLE to a position in orbit
This is the part I find most fun, and the part most people haven't built. I want a marker on the globe showing where Sentinel-2A actually was when it captured the scene — sitting on its real ground track, not a decorative dot.
The raw material is a TLE (two-line element set): a compact, standardized encoding of a satellite's orbit at a moment in time (its "epoch"). Celestrak publishes fresh TLEs for active satellites. I fetch the active catalog and pull out Sentinel-2A specifically:
def fetch_sentinel2a_tle():
req = urllib.request.Request(TLE_URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=20) as response:
lines = [l.strip() for l in response.read().decode().splitlines() if l.strip()]
for i in range(len(lines) - 2):
if "SENTINEL-2A" in lines[i].upper() and lines[i + 1].startswith("1 "):
return lines[i], lines[i + 1], lines[i + 2]
# …hardcoded fallback TLE if the fetch fails…
A TLE isn't a position — it's the input to a propagator. SGP4 is the standard model that turns "here's the orbit" plus "here's a time" into an actual Earth-centered coordinate. Skyfield wraps SGP4 in a friendly API and does the coordinate conversions. Feeding it the TLE and an array of times gives sub-satellite points (the latitude/longitude directly beneath the satellite):
from skyfield.api import EarthSatellite, load, wgs84
ts = load.timescale()
name, l1, l2 = fetch_sentinel2a_tle()
sat = EarthSatellite(l1, l2, name, ts)
# a whole array of times at once
sp = wgs84.subpoint(sat.at(ts.utc(y, mo, d, h, mi, s + seconds_array)))
lats, lons = sp.latitude.degrees, sp.longitude.degrees
The thing to notice: Skyfield is vectorized. You pass a NumPy array of time offsets and get back arrays of positions in one call, rather than looping second-by-second. That matters a lot for the next step, where I evaluate the orbit at thousands of instants.
Pinning the satellite to the scene
Reporting Sentinel-2A's position at some arbitrary instant would put the marker wherever the satellite happens to be — usually nowhere near Sheffield. What I actually want is its closest approach to the monument around the scene's acquisition time. I find it with a coarse-then-fine search, both fully vectorized:
def _haversine_km(lats, lons):
dlat = np.radians(lats - MONUMENT_LAT)
dlon = np.radians(lons - MONUMENT_LON)
a = (np.sin(dlat/2)**2
+ np.cos(np.radians(MONUMENT_LAT)) * np.cos(np.radians(lats)) * np.sin(dlon/2)**2)
return 6371 * 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
# Coarse scan: ±1 day around the scene, one sample per 60 s
sec = np.arange(0, 2 * 86400, 60)
sp = wgs84.subpoint(sat.at(ts.utc(y, mo, d, h, mi, s + sec)))
dist = _haversine_km(sp.latitude.degrees, sp.longitude.degrees)
coarse_s = int(sec[int(np.argmin(dist))])
# Refine: ±60 s around that minimum, one sample per second
rsec = np.arange(coarse_s - 60, coarse_s + 61, 1)
rsp = wgs84.subpoint(sat.at(ts.utc(y, mo, d, h, mi, s + rsec)))
rdist = _haversine_km(rsp.latitude.degrees, rsp.longitude.degrees)
closest_s = int(rsec[int(np.argmin(rdist))])
The haversine gives great-circle distance from each sub-satellite point to the monument. The coarse pass (2,880 samples across two days) narrows to the right minute; the refinement (121 one-second samples) pinpoints the exact second of closest approach. Because the newest scene was itself captured as Sentinel-2A flew over Sheffield, that closest approach correctly lands right over the monument — the imagery and the orbit agree because they describe the same overpass.
Finally I build a ±45-minute ground track (181 points at 30-second spacing) centered on that closest second, so the frontend can draw the arc of the orbit with the marker glued to the middle of it:
tsec = closest_s + np.arange(-2700, 2701, 30) # ±45 min, 30 s steps
tsp = wgs84.subpoint(sat.at(ts.utc(y, mo, d, h, mi, s + tsec)))
track_points = [
{"lon": float(lo), "lat": float(la), "altitude_m": SAT_ALTITUDE_M}
for la, lo in zip(tsp.latitude.degrees, tsp.longitude.degrees)
]
One honesty note I put in the README too: this design deliberately only tracks the newest scene, whose date is always recent. That keeps the current Celestrak TLE's epoch close to the scene time, so SGP4 stays accurate without hunting down epoch-matched historical TLEs. Historical-scene accuracy is intentionally out of scope — a scoped limitation is better than a silent inaccuracy.
Designing endpoints that can't break the frontend
The globe refreshes every five minutes and calls all four endpoints. Any of them depends on a third party — Copernicus, Celestrak — that can rate-limit, time out, or briefly vanish. A 500 error here isn't just an empty panel; because of how CORS works, a raised exception can arrive at the browser without the CORS headers, turning a transient upstream hiccup into a confusing cross-origin error in the console.
So the orbit endpoint is built to always return valid JSON, falling back to a safe position over the monument rather than raising:
@app.get("/api/orbit-at-scene-time")
def orbit():
try:
meta = get_latest_scene_meta()
ref_dt = parse_iso(meta["datetime"]) if meta else datetime.now(timezone.utc)
key = ref_dt.isoformat()
if _orbit_cache["key"] != key:
_orbit_cache["result"] = compute_orbit(ref_dt)
_orbit_cache["key"] = key
return _orbit_cache["result"]
except Exception as e:
print(f"orbit endpoint failed, returning fallback: {e}")
return _fallback_orbit()
_fallback_orbit() returns a well-formed response with the marker parked over Sheffield and a short straight track. The globe still renders; the user sees the site; the CORS headers are present because FastAPI returned normally. The same philosophy runs through the stack: latest-scene falls back to a null cloud cover, get_latest_scene_meta returns None instead of throwing, and the TLE fetch has a hardcoded backup. Every external dependency has a graceful degradation path, because in a live dashboard "slightly stale but rendered" always beats "broken."
Keeping it light: caching
Two small caches keep the Render instance from re-doing expensive work on every poll. Catalog metadata is cached for 30 minutes — new Sentinel-2 scenes don't appear more often than that, so hammering the Catalog API on every request buys nothing. And the orbit is recomputed only when the newest scene actually changes, keyed on the scene datetime:
if _orbit_cache["key"] != key:
_orbit_cache["result"] = compute_orbit(ref_dt)
_orbit_cache["key"] = key
Since the scene changes every few days but the frontend polls every five minutes, this turns thousands of SGP4 computations per day into a handful. The coarse+fine orbit search is cheap in isolation, but not free at 512×512-plus-thousands-of-samples on every poll from every visitor; keying the cache to the scene rather than a clock interval means the work happens exactly when the underlying data does, and never otherwise.
Takeaways
- A backend earns its place as a credential boundary. The moment you need an API secret, the browser can't be trusted with it — the backend holds the OAuth handshake and proxies the data. That's the honest reason this isn't a pure frontend app.
- "Latest" is a query, not a timestamp. For real observational data, the newest usable record — under a cloud threshold, within a lookback window — is something you look up and then anchor everything else to, so your views stay self-consistent.
- Push computation to the data. Sentinel Hub evalscripts compute NDVI at the source and return a finished PNG; you move a picture, not gigabytes of raw bands.
- Vectorize the physics. Skyfield + NumPy evaluate an orbit at thousands of times in a couple of calls. A coarse-then-fine search over those arrays finds an exact closest approach without a Python loop in sight.
- In a live dashboard, always return valid JSON. Every third-party call gets a graceful fallback, partly for UX and partly because a raised exception can strip your CORS headers and turn a hiccup into a cross-origin error.
- Cache to the data's own rhythm. Key expensive work to the thing that actually changes (the scene) rather than a fixed interval, and recompute exactly when — and only when — reality does.
The satellite-data pipeline is the part I'd point anyone to first: authenticate, find the real newest scene, compute vegetation at the source, then turn a live TLE into an exact position in orbit — each step tied to the same acquisition so the whole globe tells one true, consistent story about one small patch of Sheffield.
Top comments (0)