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