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

157 lines
6.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.