6.2 KiB
Predictor behaviour at the poles
Measured 2026-08-03 against the full local stack (./run-stack.py): Go predictor
on :8080 with GFS dataset 2026-08-03T00:00:00Z (gfs-0p50-3h, 130/130 units,
2.05 GB), Django on :8000, the Cesium build of this SPA on :5173.
Reproduce the browser half with:
npx playwright test tests/e2e/polar.spec.ts --reporter=list
That spec requires the real stack (it overrides baseURL to http://localhost:5173
— see "CSRF" below).
1. What the predictor returns near the poles
Direct GET /api/v1/prediction, launch longitude 0.1, 2026-08-03T12:00:00Z,
ascent 5 m/s, burst 30 km, descent 5 m/s:
| launch lat | points | latitude range | longitude range | NaN/Inf | above 85.051129° | frozen |
|---|---|---|---|---|---|---|
| 62.1234 | 148 | 62.1219 … 62.3342 | 0.100 … 1.737 | 0 | no | no |
| 85.0 | 148 | 85.0000 … 85.1535 | 0.100 … 5.560 | 0 | yes | no |
| 89.5 | 148 | 88.8092 … 89.5000 | 0.100 … 55.922 | 0 | yes | no |
| 89.99 | 148 | 88.9926 … 89.9900 | 0.100 … 79.279 | 0 | yes | no |
No NaN, no Inf, no frozen tracks. Today's polar wind blows southward, so every balloon drifted away from the pole and never reached latitude 90°, where the defects below would trigger. The failure modes are latent, not constant.
Note the longitude amplification: at 89.99°N the track sweeps 79° of longitude while moving 1° of latitude. That is physically correct, not a bug — the parallel at 89.99°N is only ~7 km long, so a few km of drift is a large change in longitude.
2. What the globe now renders
tests/e2e/polar.spec.ts reads back the coordinates Cesium actually holds in
the rendered workspace polyline:
[89.5N] vertices=148 max=89.50000 min=88.80919
[89.99N] vertices=148 max=89.99000 min=88.99262
These match the predictor's API output exactly. Under the previous
MapLibre renderer every one of these vertices would have been clamped to
85.051129° by @maplibre/geojson-vt's projectY() and the track would have
rendered as a straight line along that parallel.
Screenshots: test-results/polar-89.5N.png, test-results/polar-89.99N.png.
3. Known limitation: no basemap above 85.051129°
The polar cap renders as a featureless surface. Both configured basemaps — OpenStreetMap raster and Esri World Imagery — are Web Mercator tile pyramids, which do not extend past ±85.051129°. The trajectory, markers and bounding boxes draw correctly there (they are geographic vector data, not tiles), but there is no map detail underneath. The same limitation means Antarctica can never be drawn in full from these sources; its true extent reaches −90°.
Fixing this needs a polar-projection basemap (NASA GIBS serves EPSG:3413 for the Arctic and EPSG:3031 for the Antarctic), which is a separate piece of work.
4. Predictor defects found by reading the source
These live in the predictor repo and are not fixed. Verified by source
reading plus a unit probe run against the real Axis geometry (GFS 0p50:
lat −90…90 step 0.5, N=361).
4.1 The wind error is swallowed into a zero derivative — worst of the three
internal/engine/models.go:85-88
sample, err := field.Wind(t, s.Lat, s.Lng, s.Altitude)
if err != nil {
return State{} // zero derivative; error discarded
}
internal/numerics/grid.go:40 (Axis.Locate) is correct — it returns an
explicit error rather than reading out of bounds:
lat=89 OK lat=90 ERROR: lat=90 out of range
lat=89.5 OK lat=90.05 ERROR: lat=90.05 out of range
lat=89.99 OK lat=91 ERROR: lat=91 out of range
But the caller drops it, so the propagator returns a zero derivative and the
balloon silently freezes horizontally with no error and no event in
events[]:
lat=89.99 dLat=8.95e-05 dLng=0.5128576395811134
lat=90 dLat=0 dLng=0 <-- silently zeroed
lat=90.05 dLat=0 dLng=0 <-- silently zeroed
lat=91 dLat=0 dLng=0 <-- silently zeroed
A plausible-looking wrong answer is more dangerous than a crash.
4.2 Latitude is never bounded, so there is no pole crossing
internal/numerics/vec.go:71-73 — GeoAdd wraps longitude through PyMod but
adds latitude unguarded:
step 1: lat=89.9500 step 3: lat=90.0500 step 5: lat=90.1500
step 2: lat=90.0000 step 4: lat=90.1000 step 6: lat=90.2000
The state leaves the sphere. Correct pole crossing is lat → 180 − lat,
lng → lng + 180; that logic does not exist.
4.3 1/cos(lat) divergence against a fixed integration step
internal/numerics/vec.go:87
dLng = degPerRad * u / (r * math.Cos(lat*piOver180))
For a 10 m/s eastward wind at 30 km:
| lat | cos(lat) | dLng °/s | ° per 60 s step |
|---|---|---|---|
| 0 | 1 | 8.951054359255286e-05 | 0.0054 |
| 60 | 0.5000000000000001 | 0.00017902108718510567 | 0.0107 |
| 85.051129 | 0.08626673450528127 | 0.0010376020850432562 | 0.062 |
| 89.99 | 1.745329251907294e-05 | 5.128576370030803 | 307.7 |
| 90 | 6.123233995736757e-17 | 1.461818112044611e+12 | 8.77e+13 |
| 90.0000001 | −1.7453291532541063e-09 | −51285.766599190494 | −3.08e+06 |
At exactly 90° the result is large but finite, not NaN — math.Cos(π/2) in
float64 is 6.123233995736757e-17, not zero. Past 90° the cosine goes negative
and the drift direction inverts.
internal/numerics/ode.go:15 integrates with classical fixed-step RK4 — no
adaptive step, no error control — so truncation error grows without bound as the
derivative stiffens. At 89.999° a single 60 s step advances longitude by more
than a full revolution. Scaling the step by cos(lat) would bound it.
5. Environment notes
CSRF. run-stack.py binds Vite to 127.0.0.1 but never sets
CSRF_TRUSTED_ORIGINS, whose default (stratoflights/settings.py:195) is
http://localhost:5173, http://localhost:8000. Reaching the app as
127.0.0.1:5173 therefore gets 403 on every POST, including
/api/predictions/. Use localhost:5173, or add the origin to the env block in
run-stack.py. (The default value also has a leading space in its second entry
after split(',').)
ALLOWED_HOSTS has the same shape: it defaults to localhost only, so
curl 127.0.0.1:8000 returns 400 while curl localhost:8000 returns 200.