# CesiumJS Globe Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the MapLibre/Mercator renderer with a CesiumJS globe so trajectories above 85.05° latitude render correctly, enabling verification of predictor behaviour at the poles.
**Architecture:** The app already isolates the renderer behind the `IMap` interface (`src/lib/map/core.ts`). After the wind layer is deleted, `maplibre-gl` is imported in exactly one file (`src/lib/map/maplibre.ts`) and nothing calls `getRawInstance()`. So the migration is: add `src/lib/map/cesium.ts` implementing the same `IMap`/`Scene` contract, then change one import in `Map.svelte`. MapLibre stays on disk until the Cesium path is proven, then is removed.
**Tech Stack:** SvelteKit 2 + adapter-static, Svelte 5 runes, TypeScript, Vite 6, CesiumJS 1.129.0.
## Global Constraints
- **Node is 18.19.1 and `.npmrc` sets `engine-strict=true`.** Cesium MUST be pinned to `1.129.0` — the last release declaring `node >=18.18.0`. Cesium 1.130.0 requires `node >=20.19.0`; 1.141.0+ requires `node >=22.0.0`. `npm install` will hard-fail (not warn) on a newer version.
- Vite stays at 6.x. Vite 7 requires Node 20+.
- **No Cesium Ion access token.** Do not use `Cesium.Ion`, `createWorldTerrainAsync`, or the default Ion imagery. Use `UrlTemplateImageryProvider` with the existing OSM/Esri tile URLs and `EllipsoidTerrainProvider`. Any Ion code path fails without a token.
- Existing basemap URLs must be preserved verbatim:
- osm: `https://a.tile.openstreetmap.org/{z}/{x}/{y}.png`
- satellite: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}`
- The `IMap`, `Scene`, `Marker`, `MapLayer` interfaces in `src/lib/map/core.ts` are the contract. Do not change their signatures — features depend on them.
- Coordinate order convention in this codebase: `LngLatTuple = [lng, lat]`, `LatLngTuple = [lat, lng]`. `LineOptions.coords` is `LatLngTuple[]`. Getting this backwards is the most likely bug in this migration.
- Commit after each task.
---
### Task 1: Delete the wind layer and unused dependencies
Removes the only `getRawInstance()` consumer, leaving a clean seam for the renderer swap. The user has approved dropping wind entirely.
**Files:**
- Delete: `src/lib/features/wind/ParticleField.ts`, `src/lib/features/wind/WindRenderer.svelte`, `src/lib/features/wind/index.ts`, `src/lib/features/wind/store.ts`
- Delete: `src/lib/domain/wind.ts`, `src/lib/api/wind.ts`
- Modify: `src/routes/predict/+page.svelte` (remove `WindRenderer` import + usage)
- Modify: `src/lib/domain/index.ts`, `src/lib/api/index.ts` (drop wind re-exports)
- Modify: `src/lib/features/settings/schema.ts` (remove the wind settings section, ~lines 87-130)
- Modify: `src/lib/features/settings/store.ts` (remove `wind.*` defaults if present)
- Modify: `src/lib/i18n/locales/en.json`, `src/lib/i18n/locales/ru.json` (remove `settings.wind*` keys)
- Modify: `package.json` (remove `@sakitam-gis/maplibre-wind`, `svelte5-chartjs`)
- [ ] **Step 1: Confirm the blast radius before deleting**
```bash
cd /home/anton/stratoflights/leaflet_svelte
grep -rn "features/wind\|\$features/wind\|windCache\|api/wind\|WindRenderer\|WindInterpolator\|windSettings\|domain/wind" src/
```
Expected: hits only in the files listed above. If anything else appears, add it to the list before proceeding.
- [ ] **Step 2: Delete the wind files**
```bash
rm -r src/lib/features/wind src/lib/domain/wind.ts src/lib/api/wind.ts
```
- [ ] **Step 3: Remove the references**
Edit each Modify-listed file to drop wind imports, `` usage, wind re-exports, the wind settings section, and the `settings.wind*` i18n keys. `@sakitam-gis/maplibre-wind` and `svelte5-chartjs` are already imported nowhere (verified) — remove them from `package.json` dependencies.
```bash
npm uninstall @sakitam-gis/maplibre-wind svelte5-chartjs
```
- [ ] **Step 4: Verify nothing dangles**
```bash
grep -rn "wind\|Wind" src/ | grep -viE "window|rewind"
npm run check
```
Expected: no wind references remain; `npm run check` reports 0 errors.
- [ ] **Step 5: Confirm the seam is clean**
```bash
grep -rn "from 'maplibre-gl'" src/
grep -rn "getRawInstance" src/
```
Expected: `maplibre-gl` imported ONLY in `src/lib/map/maplibre.ts`. `getRawInstance` appears only in `core.ts` (declaration) and `maplibre.ts` (implementation) — no callers.
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "refactor: remove wind particle layer and unused deps"
```
---
### Task 2: Serve Cesium's static assets
Cesium loads Workers, Assets, Widgets and ThirdParty files at runtime by URL. They must be served from a known base path. `static/` is copied verbatim by adapter-static, so copying there works in both dev and build with zero plugins.
**Files:**
- Create: `scripts/copy-cesium.js`
- Modify: `package.json` (`prepare` script)
- Modify: `.gitignore` (ignore the generated `static/cesium/`)
- Modify: `src/app.html` (set `CESIUM_BASE_URL`)
**Interfaces:**
- Produces: Cesium assets served at `/cesium/`, and `window.CESIUM_BASE_URL === '/cesium/'` set before any Cesium module loads.
- [ ] **Step 1: Write the copy script**
Create `scripts/copy-cesium.js`:
```js
// Copies Cesium's runtime assets into static/ so they are served at /cesium/.
// Cesium resolves Workers/Assets/Widgets at runtime via CESIUM_BASE_URL; they
// cannot be bundled. Regenerated on `npm install` via the prepare script.
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const src = join(root, 'node_modules', 'cesium', 'Build', 'Cesium');
const dest = join(root, 'static', 'cesium');
if (!existsSync(src)) {
console.error(`[copy-cesium] missing ${src} — is cesium installed?`);
process.exit(1);
}
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
for (const dir of ['Assets', 'ThirdParty', 'Widgets', 'Workers']) {
cpSync(join(src, dir), join(dest, dir), { recursive: true });
}
console.log('[copy-cesium] assets copied to static/cesium/');
```
- [ ] **Step 2: Wire it into `prepare` and ignore the output**
In `package.json`, change the `prepare` script to:
```json
"prepare": "svelte-kit sync || echo '' && node scripts/copy-cesium.js"
```
Append to `.gitignore`:
```
# generated from node_modules/cesium by scripts/copy-cesium.js
static/cesium/
```
- [ ] **Step 3: Set the base URL before Cesium loads**
In `src/app.html`, add inside `
` before `%sveltekit.head%`:
```html
```
- [ ] **Step 4: Run it and verify**
```bash
node scripts/copy-cesium.js
ls static/cesium/
du -sh static/cesium/
```
Expected: `Assets ThirdParty Widgets Workers` present, ~20 MB total.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "build: serve Cesium runtime assets from static/cesium"
```
---
### Task 3: Implement `IMap` on Cesium — camera, events, basemaps
**Files:**
- Create: `src/lib/map/cesium.ts`
- Test: `tests/e2e/globe.spec.ts` (added in Task 5)
**Interfaces:**
- Consumes: `IMap`, `MapInit`, `MapEvent`, `MapEventPayload`, `MapClickEvent`, `Scene` from `./core`; `LatLngTuple`, `LngLatTuple` from `$domain`.
- Produces: `export function createCesiumMap(init: MapInit): IMap` — same shape as `createMapLibreMap`, so `Map.svelte` swaps one import.
- Produces: `zoomToHeight(zoom: number): number` and `heightToZoom(height: number): number` — the camera-height ↔ zoom-level bridge, exported for tests.
**Zoom note:** Cesium has no discrete zoom levels; it has camera height. `getZoom`/`setZoom` have **zero consumers outside the map implementation** (verified), so a simple monotonic mapping is sufficient. Use the standard Web-Mercator-equivalent relation at the equator: `height = 40075017 / 2^zoom`, inverted as `zoom = log2(40075017 / height)`.
- [ ] **Step 1: Write the implementation**
```ts
import {
Cartesian2,
Cartesian3,
Color,
EllipsoidTerrainProvider,
Math as CesiumMath,
Rectangle,
ScreenSpaceEventHandler,
ScreenSpaceEventType,
UrlTemplateImageryProvider,
Viewer,
} from 'cesium';
import 'cesium/Build/Cesium/Widgets/widgets.css';
import type {
IMap,
MapEvent,
MapEventHandler,
MapInit,
MapClickEvent,
Scene as MapScene,
} from './core';
import type { LatLngTuple, LngLatTuple } from '$domain';
import { CesiumScene } from './cesium-scene';
/** Equatorial circumference in metres — the zoom<->height reference. */
const EQUATOR_M = 40075017;
export function zoomToHeight(zoom: number): number {
return EQUATOR_M / Math.pow(2, zoom);
}
export function heightToZoom(height: number): number {
return Math.log2(EQUATOR_M / Math.max(height, 1));
}
const IMAGERY: Record, string> = {
osm: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
satellite:
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{reverseY}/{x}',
};
class CesiumMap implements IMap {
readonly ready: Promise;
private viewer: Viewer;
private handler: ScreenSpaceEventHandler;
private scenes = new Map();
constructor(init: MapInit) {
this.viewer = new Viewer(init.container, {
// No Ion: explicit imagery + ellipsoid terrain, no token required.
baseLayer: false,
terrainProvider: new EllipsoidTerrainProvider(),
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
animation: false,
timeline: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
navigationInstructionsInitiallyVisible: false,
});
this.viewer.imageryLayers.addImageryProvider(
new UrlTemplateImageryProvider({ url: IMAGERY[init.baseLayer ?? 'osm'], maximumLevel: 19 }),
);
// Scale/navigation controls: Cesium has no direct equivalents, and the
// existing flags only ever hid MapLibre chrome we no longer render.
this.setCenter(init.center, init.zoom);
this.ready = Promise.resolve();
this.handler = new ScreenSpaceEventHandler(this.viewer.canvas);
}
/** Screen point -> lng/lat on the globe, or null if the click missed the globe. */
private pick(position: Cartesian2): { lat: number; lng: number } | null {
const ray = this.viewer.camera.getPickRay(position);
if (!ray) return null;
const hit = this.viewer.scene.globe.pick(ray, this.viewer.scene);
if (!hit) return null;
const c = this.viewer.scene.globe.ellipsoid.cartesianToCartographic(hit);
return {
lat: CesiumMath.toDegrees(c.latitude),
lng: CesiumMath.toDegrees(c.longitude),
};
}
on(event: E, handler: MapEventHandler): () => void {
if (event === 'load') {
(handler as MapEventHandler<'load'>)(undefined);
return () => {};
}
if (event === 'click' || event === 'mousemove') {
const type =
event === 'click' ? ScreenSpaceEventType.LEFT_CLICK : ScreenSpaceEventType.MOUSE_MOVE;
const cb = (e: { position?: Cartesian2; endPosition?: Cartesian2 }) => {
const pos = e.position ?? e.endPosition;
if (!pos) return;
const lngLat = this.pick(pos);
if (!lngLat) return; // clicked space, not the globe
(handler as MapEventHandler<'click'>)({
lngLat,
originalEvent: new MouseEvent(event === 'click' ? 'click' : 'mousemove'),
} as MapClickEvent);
};
this.handler.setInputAction(cb, type);
return () => this.handler.removeInputAction(type);
}
// 'move' | 'zoom' — both ride Cesium's camera.changed event.
const listener = () => {
const c = this.viewer.camera.positionCartographic;
const zoom = heightToZoom(c.height);
if (event === 'move') {
(handler as MapEventHandler<'move'>)({
center: [CesiumMath.toDegrees(c.longitude), CesiumMath.toDegrees(c.latitude)],
zoom,
});
} else {
(handler as MapEventHandler<'zoom'>)({ zoom });
}
};
this.viewer.camera.changed.addEventListener(listener);
return () => this.viewer.camera.changed.removeEventListener(listener);
}
setCenter(pos: LngLatTuple, zoom?: number): void {
this.viewer.camera.setView({
destination: Cartesian3.fromDegrees(
pos[0],
pos[1],
zoomToHeight(zoom ?? this.getZoom()),
),
});
}
panTo(pos: LngLatTuple, durationMs = 500): void {
this.viewer.camera.flyTo({
destination: Cartesian3.fromDegrees(pos[0], pos[1], this.viewer.camera.positionCartographic.height),
duration: durationMs / 1000,
});
}
fitBounds(coords: LatLngTuple[], paddingPx = 50): void {
if (coords.length === 0) return;
// coords are [lat, lng]; Rectangle.fromDegrees takes (west, south, east, north).
const lats = coords.map((c) => c[0]);
const lngs = coords.map((c) => c[1]);
const rect = Rectangle.fromDegrees(
Math.min(...lngs),
Math.min(...lats),
Math.max(...lngs),
Math.max(...lats),
);
// A degenerate rectangle (single point) makes Cesium fly to the centre of
// the earth; pad it so there is always area to frame.
if (rect.width === 0 || rect.height === 0) {
const pad = CesiumMath.toRadians(0.05);
rect.west -= pad;
rect.east += pad;
rect.south -= pad;
rect.north += pad;
}
this.viewer.camera.flyTo({ destination: rect, duration: 0.5 });
void paddingPx; // Cesium frames the rectangle itself; no pixel padding knob.
}
getZoom(): number {
return heightToZoom(this.viewer.camera.positionCartographic.height);
}
setZoom(zoom: number): void {
const c = this.viewer.camera.positionCartographic;
this.viewer.camera.setView({
destination: Cartesian3.fromDegrees(
CesiumMath.toDegrees(c.longitude),
CesiumMath.toDegrees(c.latitude),
zoomToHeight(zoom),
),
});
}
setCursor(cursor: string | null): void {
this.viewer.canvas.style.cursor = cursor ?? '';
}
scene(name: string): MapScene {
let s = this.scenes.get(name);
if (!s) {
s = new CesiumScene(name, this.viewer);
this.scenes.set(name, s);
}
return s;
}
disposeScene(name: string): void {
const s = this.scenes.get(name);
if (!s) return;
s.dispose();
this.scenes.delete(name);
}
getRawInstance(): unknown {
return this.viewer;
}
dispose(): void {
for (const s of this.scenes.values()) s.dispose();
this.scenes.clear();
this.handler.destroy();
this.viewer.destroy();
}
}
export function createCesiumMap(init: MapInit): IMap {
return new CesiumMap(init);
}
```
Note the Esri URL uses `{reverseY}` — Cesium's `UrlTemplateImageryProvider` template for the ArcGIS `{z}/{y}/{x}` tile order.
- [ ] **Step 2: Verify it type-checks (Scene not yet written, so expect one error)**
```bash
npm run check 2>&1 | head -20
```
Expected: errors only about the missing `./cesium-scene` module. Task 4 supplies it.
- [ ] **Step 3: Commit**
```bash
git add src/lib/map/cesium.ts && git commit -m "feat: Cesium IMap implementation — camera, events, basemaps"
```
---
### Task 4: Implement `Scene` on Cesium — lines, circles, markers
**Files:**
- Create: `src/lib/map/cesium-scene.ts`
**Interfaces:**
- Consumes: `Scene`, `MapLayer`, `Marker`, `LineOptions`, `CircleOptions`, `MarkerOptions` from `./core`.
- Produces: `export class CesiumScene implements Scene` with constructor `(name: string, viewer: Viewer)` — consumed by `cesium.ts` Task 3.
**Why this is the whole point:** these entities take `Cartesian3.fromDegrees(lng, lat)` directly. Nothing routes through a Mercator tiler, so latitude 89.99° and 90° render correctly — which is what MapLibre could not do.
- [ ] **Step 1: Write the implementation**
```ts
import {
Cartesian2,
Cartesian3,
Color,
Entity,
HorizontalOrigin,
PolylineDashMaterialProperty,
VerticalOrigin,
type Viewer,
} from 'cesium';
import type {
CircleOptions,
LineOptions,
MapLayer,
Marker,
MarkerOptions,
Scene,
} from './core';
/** '#rrggbb' + opacity -> Cesium Color. Falls back to black on a bad string. */
function toColor(css: string | undefined, opacity = 1): Color {
try {
return Color.fromCssColorString(css ?? '#000').withAlpha(opacity);
} catch {
return Color.BLACK.withAlpha(opacity);
}
}
export class CesiumScene implements Scene {
private entities = new Map();
constructor(
public readonly name: string,
private viewer: Viewer,
) {}
private scopeId(id: string): string {
return `${this.name}__${id}`;
}
private add(id: string, entity: Entity): Entity {
this.remove(id);
const added = this.viewer.entities.add(entity);
this.entities.set(id, added);
return added;
}
addLine(id: string, options: LineOptions): MapLayer {
// LineOptions.coords is [lat, lng][] — Cesium wants a flat lng,lat list.
const degrees = options.coords.flatMap((c) => [c[1], c[0]]);
const color = toColor(options.color, options.opacity ?? 1);
const entity = this.add(
id,
new Entity({
id: this.scopeId(id),
polyline: {
positions: Cartesian3.fromDegreesArray(degrees),
width: options.width ?? 3,
material: options.dashArray
? new PolylineDashMaterialProperty({ color })
: color,
// Draw on the globe surface without clamping to terrain, and
// keep the line visible when it passes behind the horizon.
clampToGround: false,
},
}),
);
void entity;
return { id, remove: () => this.remove(id) };
}
addCircle(id: string, options: CircleOptions): MapLayer {
// radiusPx is screen-space, matching the MapLibre circle-layer semantics;
// Cesium's PointGraphics.pixelSize is the direct equivalent.
this.add(
id,
new Entity({
id: this.scopeId(id),
position: Cartesian3.fromDegrees(options.center[0], options.center[1]),
point: {
pixelSize: (options.radiusPx ?? 5) * 2,
color: toColor(options.color, options.opacity ?? 1),
outlineColor: toColor(options.strokeColor, 1),
outlineWidth: options.strokeWidth ?? 0,
},
}),
);
return { id, remove: () => this.remove(id) };
}
addMarker(id: string, options: MarkerOptions): Marker {
const entity = this.add(
id,
new Entity({
id: this.scopeId(id),
position: Cartesian3.fromDegrees(options.lngLat[0], options.lngLat[1]),
...(options.iconUrl
? {
billboard: {
image: options.iconUrl,
width: options.iconSize?.[0],
height: options.iconSize?.[1],
horizontalOrigin: HorizontalOrigin.CENTER,
verticalOrigin: VerticalOrigin.BOTTOM,
},
}
: { point: { pixelSize: 10, color: Color.CRIMSON } }),
...(options.popupHtml ? { description: options.popupHtml } : {}),
}),
);
return {
setLngLat: (pos) => {
entity.position = Cartesian3.fromDegrees(pos[0], pos[1]) as never;
},
remove: () => this.remove(id),
};
}
remove(id: string): void {
const e = this.entities.get(id);
if (!e) return;
this.viewer.entities.remove(e);
this.entities.delete(id);
}
clear(): void {
for (const e of this.entities.values()) this.viewer.entities.remove(e);
this.entities.clear();
}
dispose(): void {
this.clear();
}
}
```
- [ ] **Step 2: Type-check**
```bash
npm run check 2>&1 | head -20
```
Expected: 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/lib/map/cesium-scene.ts && git commit -m "feat: Cesium Scene — geodesic lines, points, billboards"
```
---
### Task 5: Swap the renderer and get the app green
**Files:**
- Modify: `src/lib/map/Map.svelte:5,44` (import + factory call)
- Modify: `src/lib/map/index.ts` (export the Cesium factory)
- Create: `tests/e2e/globe.spec.ts`
- [ ] **Step 1: Swap the factory**
In `src/lib/map/Map.svelte`, change line 5 from `import { createMapLibreMap } from './maplibre';` to `import { createCesiumMap } from './cesium';`, and line 44 from `map = createMapLibreMap({` to `map = createCesiumMap({`.
- [ ] **Step 2: Write the pole e2e test**
Create `tests/e2e/globe.spec.ts`:
```ts
import { expect, test } from '@playwright/test';
test('globe renders a polar trajectory above the Mercator limit', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/predict');
// The dev build exposes the raw Cesium Viewer on window._lsvMap.
await page.waitForFunction(() => (window as any)._lsvMap?.scene !== undefined, { timeout: 30_000 });
// Place a polyline crossing 89.99N and read back what Cesium stored.
const readback = await page.evaluate(() => {
const viewer = (window as any)._lsvMap;
const Cesium = (window as any).Cesium;
const e = viewer.entities.add({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([0, 89.0, 30, 89.99, 60, 89.5]),
width: 3,
},
});
const carto = Cesium.Cartographic.fromCartesian(e.polyline.positions.getValue()[1]);
return Cesium.Math.toDegrees(carto.latitude);
});
// MapLibre's tiler clamped anything above 85.051129; Cesium must not.
expect(readback).toBeGreaterThan(85.051129);
expect(readback).toBeCloseTo(89.99, 2);
expect(errors).toEqual([]);
});
```
- [ ] **Step 3: Run it**
```bash
npm run check
npx playwright test tests/e2e/globe.spec.ts
```
Expected: `npm run check` clean; the globe test passes, proving latitude survives past 85.051129°.
- [ ] **Step 4: Run the existing suite and fix fallout**
```bash
npx playwright test
```
Expected: `auth`, `settings`, `saved-points` pass unchanged. `smoke`, `track`, `workspaces` may assert on MapLibre-specific DOM (`.maplibregl-*` classes) — update those selectors to the Cesium canvas. Fix each failure; do not delete assertions.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: switch map renderer to CesiumJS globe"
```
---
### Task 6: Verify predictor behaviour at the poles
The actual objective. The globe is the instrument; this task is the measurement.
**Files:**
- Create: `docs/POLAR_FINDINGS.md`
- [ ] **Step 1: Bring up the full stack**
```bash
cd /home/anton/stratoflights
# predictor already holds a complete 2026-08-03T00:00:00Z dataset in .stack-data/gfs
./run-stack.py
```
Expected: `stack up — predictor :8080 django :8000 svelte http://127.0.0.1:5173 …`. If port 8080 is already held by a standalone predictor, stop that first.
- [ ] **Step 2: Run polar predictions and record the output**
```bash
for lat in 85 89.5 89.99; do
curl -s "http://127.0.0.1:8080/api/v1/prediction?launch_latitude=$lat&launch_longitude=0.1&launch_datetime=2026-08-03T12:00:00Z&ascent_rate=5&burst_altitude=30000&descent_rate=5&launch_altitude=0"
done
```
- [ ] **Step 3: View each on the globe**
Open `http://127.0.0.1:5173/predict`, run the same three launches through the UI, and confirm the trajectory renders as a track over the polar region rather than a line pinned along the 85.05° parallel.
- [ ] **Step 4: Write up the findings**
Create `docs/POLAR_FINDINGS.md` recording, for each latitude: whether the track rendered, whether longitude behaved plausibly, and the three predictor defects listed under "Known predictor defects" below with their file:line references.
- [ ] **Step 5: Commit**
```bash
git add docs/POLAR_FINDINGS.md && git commit -m "docs: polar behaviour findings"
```
---
## Known predictor defects (out of scope — for a follow-up decision)
Verified by source reading plus a unit-level probe against the real `Axis` geometry. These live in the `predictor` repo, not this one, and are **not** fixed by this plan:
1. **`internal/engine/models.go:85-88`** — `WindTransport` swallows the wind-field error and returns `State{}`, a zero derivative. At latitude ≥ 90° the balloon silently freezes horizontally with no error and no event emitted. This is the most dangerous defect: a wrong answer that looks valid.
2. **`internal/numerics/vec.go:71-73`** — `GeoAdd` wraps longitude via `PyMod` but never bounds latitude. Latitude walks past 90° (90.05, 90.10, …) off the sphere. Correct pole-crossing behaviour is `lat → 180 − lat`, `lng → lng + 180`.
3. **`internal/numerics/vec.go:87`** — `dLng = degPerRad * u / (r * cos(lat))` diverges as `cos(lat) → 0` (0.00089 °/s at the equator → 0.513 °/s at 89.99° → 1.46e12 °/s at exactly 90°; finite, not NaN, because `math.Cos(π/2)` is 6.12e-17). Combined with the **fixed-step RK4** in `internal/numerics/ode.go:15` (no adaptive step, no error control), truncation error grows without bound near the pole. A step size scaled by `cos(lat)` would bound it.
`internal/numerics/grid.go:40` (`Axis.Locate`) is correct — it returns an explicit `lat=90 out of range` error rather than reading out of bounds. The bug is purely that the caller discards it.