# 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: ```bash 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` ```go 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` ```go 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.