feat(globe): port to CeliumJS

This commit is contained in:
gili8420 2026-08-03 22:12:32 +09:00
parent ec03425067
commit eb7698e034
51 changed files with 3521 additions and 1992 deletions

157
docs/POLAR_FINDINGS.md Normal file
View file

@ -0,0 +1,157 @@
# 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.