leaflet_svelte/docs/POLAR_FINDINGS.md
2026-08-03 22:12:32 +09:00

6.2 KiB
Raw Blame History

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-73GeoAdd 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 NaNmath.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.