GODSIGH/SPEC2.md
jing ac811a75fe Wave 2 spec for Opus: quakes/fires layers, shared OpenSky cache, record/replay, polish pack
Verified ground truth included: USGS + EONET both send ACAO:* (EONET mislabels
its JSON as rss+xml). Defines the ~/.cache/godverse opensky cache contract
shared with godstrument's crossover brief.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:33:57 +10:00

116 lines
14 KiB
Markdown
Raw 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.

# GODSIGH — Wave 2 Build Spec (upgrades & crossover)
**Audience:** Claude Opus 4.8, executing autonomously in this repo.
**Author:** Claude Fable 5, 2026-07-13, after reviewing the completed Wave 1 build.
**Prerequisite:** Wave 1 (SPEC.md phases 18) is COMPLETE at `7da9eea` — do not re-do it. Read SPEC.md §2 (locked decisions), §7 (verification protocol), §10 (pitfalls) before starting; every rule there still binds (relative URLs, no build step, entities-vs-primitives, `[DEMO]` labeling, verify-in-browser-then-commit-per-phase). Deployment remains SPEC.md §9, gated on John — unchanged by this document.
**Companion:** `~/Documents/godstrument/GODSIGH_CROSSOVER_BRIEF.md` — a sibling brief for the godstrument repo. Phase 3 here defines a **shared cache contract** with that repo; the contract text must stay in sync.
---
## 0. Mission
Six phases, in execution order: two new real-data layers (earthquakes, wildfires), a shared OpenSky cache (fixes a real quota collision with godstrument), historical record/replay (true time-travel for the timeline), a polish pack, and one investigate-first stretch. Each phase is independently shippable; commit + push after each.
## 1. Ground truth — verified 2026-07-13 by Fable (do not re-probe)
| Feed | URL | CORS | Quirk |
|---|---|---|---|
| USGS quakes | `https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson` | `ACAO: *` — direct browser fetch OK, no proxy | ~166 KB/day; `all_hour.geojson` also available |
| NASA EONET wildfires | `https://eonet.gsfc.nasa.gov/api/v3/events?category=wildfires&status=open&limit=500` | `ACAO: *` — direct browser fetch OK, no proxy | **Content-Type lies: `application/rss+xml` but the body is JSON.** `res.json()` still works (fetch ignores content-type) — but do NOT gate on the header. |
Also ground truth from Wave 1: OpenSky anonymous quota is ~400 credits/day per IP and **godstrument's `workers/world_planes.py` polls the same API from the same machine** — the two projects currently compete for one quota. Phase 3 fixes this.
## 2. Phase 1 — Earthquakes layer (real data, time-anchored)
New file `js/layers/quakes.js`, registered in `main.js`'s `LAYER_MODULES` (before ships). Follow the layer contract exactly (see main.js header comment).
- **Fetch** `all_day.geojson` directly (no proxy). GeoJSON features: `properties.mag` (may be null — skip), `properties.time` (ms epoch), `properties.place`, `properties.url`, `geometry.coordinates = [lon, lat, depthKm]`. Refresh every 5 min (`setTimeout` self-scheduling like aircraft.js; keep polling regardless of scrub state — quakes are historical, not live-only).
- **Entities** in a `CustomDataSource('quakes')`. For each quake:
- `availability`: if the quake time falls inside the clock window, start at its time (so scrubbing the slider back before a quake makes it vanish — the same trick as the events layer, but with REAL events); if it's older than `ctx.start`, available for the whole window.
- `point`: `pixelSize = 4 + mag * 2.2`, color ramps amber→red with magnitude (e.g. lerp `#ffcf50``#ff3b30` over mag 2..6), `outlineColor` black, `disableDepthTestDistance: Infinity`. Quakes < 1 h old pulse (CallbackProperty like events.js).
- `label` only for **mag ≥ 4.5** (declutter), `M{mag} {place}`, with the same `translucencyByDistance` pattern as infra.js.
- `description`: place, magnitude, depth km, UTC time, and the USGS event link.
- Dedupe by feature `id` across refreshes (update, don't duplicate). Cap ~400 entities (drop smallest magnitudes first).
- HUD row `Earthquakes (USGS)`, status like `128 quakes · max M5.6 · 24h`, warn/err states on fetch failure. Return `{ id: 'quakes' }`.
## 3. Phase 2 — Wildfires layer (real data)
New file `js/layers/fires.js`, in `LAYER_MODULES` after quakes.
- **Fetch** the EONET URL from §1 directly. Each event: `title`, `id`, `link`, `geometry` (array of dated geometries use the **latest** entry; it may be `Point` (`coordinates [lon,lat]`) or `Polygon` (use the first ring's centroid)). Refresh every 15 min.
- **Entities** in `CustomDataSource('fires')`: a small flame-colored glyph (shared canvas billboard draw a simple upward-teardrop/triangle in `#ff7a1a`, dark outline, ~14 px), label the event title only when zoomed in (aggressive `translucencyByDistance` fires cluster hard in California/Australia), `description` = title, category, last-geometry date, EONET link, and an "open event" note.
- HUD row `Wildfires (NASA EONET)`, status `N active fires`. Cap 500. Return `{ id: 'fires' }`.
- No time dynamics (EONET events are slow-moving; availability games would mislead more than inform).
## 4. Phase 3 — Shared OpenSky cache (the quota fix)
**The contract (mirrored verbatim in the godstrument brief — keep in sync):**
> **OpenSky shared cache v1.** Path: `~/.cache/godverse/opensky-states.json`, overridable via env `OPENSKY_CACHE_FILE`. Content: the raw, unmodified OpenSky `/states/all` JSON response body (global, no bbox). Writer: whoever successfully fetches from upstream writes the body **atomically** (write to `<path>.tmp` in the same directory, then `os.replace`). Freshness = file mtime. Readers treat the cache as fresh if `mtime` is within **120 s**; on upstream 429/failure, readers may serve/use a stale cache rather than nothing.
Implementation in `serve.py`'s proxy handler, for the `opensky` upstream only:
1. **Serve-from-cache:** if the cache file exists and is fresh (<120 s) and the request has no bbox query (global request) return the cached body with `X-Godsigh-Cache: hit` instead of hitting upstream. (Bbox requests bypass the cache; the app default is global.)
2. **Write-through:** on a successful upstream fetch of the global feed, write the body to the cache atomically (`pathlib.Path(...).expanduser()`, `mkdir(parents=True, exist_ok=True)` for the dir).
3. **Stale-on-error:** on upstream 429 or exception, if a cache file exists, serve it with 200 + `X-Godsigh-Cache: stale` (the client keeps working through quota exhaustion); otherwise behave as today.
4. Keep forwarding `X-Rate-Limit-*` headers when they exist (they won't on cache hits that's fine, the client already guards nulls).
Result: when GODSIGH and godstrument run at once, only one of them actually spends quota per 120 s window. Test: two rapid `curl 'http://127.0.0.1:8137/proxy/opensky'` calls the second must return `X-Godsigh-Cache: hit` and be near-instant.
## 5. Phase 4 — Historical record & replay (true time-travel)
Today, scrubbing off-live hides aircraft (orbits alone are computable in the past). This phase records live snapshots so the timeline replays actual traffic. Pattern borrowed from godstrument's `recorder.py`/`timewarp.py` (read them: `~/Documents/godstrument/recorder.py`, `timewarp.py` change-logged SQLite, decimation, compressed replay).
### 5a. `record.py` (new, repo root — dev-side daemon, NOT shipped to prod)
- Stdlib-only (like serve.py). Loop: every **180 s**, fetch `http://127.0.0.1:8137/proxy/opensky` (going through the proxy means the shared cache dedupes quota with the live app; if the dev server is down, sleep and retry do not fetch upstream directly).
- Reduce each snapshot to essentials: `{t: epoch_s, planes: [[icao24, callsign, lon, lat, baro_alt, track, vel], ...]}` (airborne only), then `zlib`-compress and INSERT into SQLite `data/history.db` (gitignored add `data/` to `.gitignore`), table `snapshots(ts INTEGER PRIMARY KEY, kind TEXT, body BLOB)`, WAL mode.
- **Retention:** on startup and hourly, `DELETE` snapshots older than **72 h**, then `PRAGMA wal_checkpoint`. (~70 MB steady-state.)
- **Heartbeat:** this is a long-running script invoke the `/jobs` skill and wire it into John's heartbeat convention so "is the recorder alive?" is answerable.
### 5b. Replay endpoint in `serve.py`
- `GET history/aircraft?t=<epoch_s>` nearest snapshot within ±10 min of `t`, decompressed, as JSON `{t, planes}`; 404 with a JSON error body if none. (Relative path same-origin like everything else. Read the db with a short-lived sqlite3 connection per request; WAL makes concurrent read safe.)
### 5c. Frontend: aircraft.js replay mode
- When `onClockTick` reports **not live**: instead of just hiding, fetch `history/aircraft?t=` for the clock time (debounce: only refetch when the requested time moves > 60 s from the last fetched snapshot; cache the last response). On hit: rebuild billboards from the snapshot (reuse the existing build loop — factor it out to take a plane array) and **show** them; status `replay · {snapshot time} · {n} aircraft` with state `warn` (amber = replayed, not live). On 404: hide + status `no history for this time` (this is the pre-recorder behavior, and prod-without-recorder behavior — must degrade gracefully).
- When live again: discard replay state, resume the live poll loop exactly as today.
- The LIVE/SCRUBBED chip stays as-is (SCRUBBED remains true); the amber aircraft status is what signals replay.
- **Prod note:** `record.py` + `history/` are local-dev capabilities. The prod nginx has no recorder; the graceful 404 path covers it. A VPS recorder service is possible later but is OUT OF SCOPE and would be gated on John (see SPEC.md §9 doctrine).
## 6. Phase 5 — Polish pack
Four small, independent items; verify each in the browser.
1. **Military callsign highlighting** (`aircraft.js` + `config.js`): add `CONFIG.aircraft.militaryPrefixes` (a commented list — start with `RCH, REACH, LAGR, DUKE, POLO, CNV, RRR, ASCOT, GAF, BAF, IAM, PLF, HKY, NATO, CFC, MC, SAM, EVAC`) and tint matching-callsign billboards `#ff5964` regardless of altitude band. Add a second HUD row `Military filter` (default off): when on, only matching aircraft render. Status shows `N military of M`.
2. **Satellite ground track on selection** (`satellites.js`): listen to `viewer.selectedEntityChanged`; when the selected entity is one of ours, add a single reusable entity whose `polyline` is the **sub-satellite ground track** (same SampledPositionProperty samples but at height 0, over ±half period around now — precompute per sat during the build loop and stash on the entity via `entity.properties`, or recompute on demand from the satrec, whichever is cleaner), dashed, in the sat's color, width 1.5. Deselect → hide it. One track at a time.
3. **Shareable URL state** (`main.js`): serialize to `location.hash` (via `history.replaceState`, debounced 1 s): camera (`lon,lat,height,heading,pitch` at 3 decimals), mode (`d`/`p`), layer-toggle states, and clock offset-from-now in seconds (0 = live). On boot, parse the hash and apply before/instead of the default camera + mode. Keep it human-readable: `#c=53.0,25.5,2800000,0,-90&m=d&L=sat,paths,infra,events,ships,air&t=0`. Ignore malformed hashes silently.
4. **README screenshot:** add to `serve.py` a tiny localhost-only dev endpoint `POST snap` that accepts a `data:image/png;base64,...` body and writes `docs/<name>.png` (name from a `?name=` param, sanitized to `[a-z0-9-]`, always `.png`, always inside `docs/`). From the browser console (or a small dev button you remove after): render, `canvas.toDataURL('image/png')`, POST it. **Pitfall:** Cesium's context has `preserveDrawingBuffer: false` by default — call `viewer.render()` synchronously immediately before `toDataURL` in the same task, exactly as Wave 1 verification did, or you'll capture transparent black. Capture one Data-Mode hero shot (`docs/screenshot-data.png`, oblique Gulf view with everything on) and one Photo-Mode shot, wire them into README.md, commit the PNGs. serve.py is dev-only (never deployed), so the endpoint is not an exposure — but still restrict the write path as described.
## 7. Phase 6 — STRETCH (investigate-first): GPS-jamming layer
Time-boxed investigation, then build only if the data cooperates:
1. Probe gpsjam.org's daily data (John Wiseman publishes H3-hex CSVs; look for `https://gpsjam.org/data/` patterns — check robots, CORS with an Origin header, and file format). Also check `https://github.com/wiseman/gpsjam` for documented data paths.
2. If accessible: add `gpsjam` as a proxied upstream if CORS blocks; render H3 hexes via `h3-js` (CDN global, pin a version) as red-tinted translucent polygons (Cesium `PolygonHierarchy` per hex, alpha scaled by interference %). One HUD row, dated status (`GPS interference · YYYY-MM-DD`), and a description noting the data is daily-aggregated from ADS-B, per gpsjam methodology.
3. If not accessible or ambiguous licensing: **write your findings in the README roadmap and stop.** Do not scrape around an unwilling origin.
## 8. Verification & workflow
- Per phase: the SPEC.md §7 browser protocol (console clean on a single fresh load, network 200s, screenshots, HUD statuses, toggle checks) plus the phase's own checks above. `node --check` every touched JS file.
- The subpath audit must stay clean: `grep -rnE "fetch\(['\"]/|src=['\"]/" js/` → empty. New external fetches (USGS/EONET) are full `https://` URLs in `config.js` — that's fine (they're cross-origin CDN-style feeds, not same-origin paths).
- Commit per phase (`wave2 phase N: …`), push after each. End commits with `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- Update the README per phase: layers table (quakes/fires rows), the real-vs-DEMO section (quakes/fires are REAL — say so), replay section, URL-share mention, screenshots.
## 9. Pitfalls (Wave 2 specific)
- EONET's Content-Type header lies (§1). Don't trust headers; trust the body.
- USGS `mag` can be null and `place` can be missing — guard both.
- Quake `availability` intervals: quakes happen in the past — an event newer than `ctx.stop` can't happen, but clock-window edges can; clamp intervals to `[ctx.start, ctx.stop]`.
- Atomic cache writes: `os.replace` on the same filesystem only — keep the `.tmp` beside the target, not in `/tmp`.
- sqlite from serve.py: open per-request, read-only (`file:...?mode=ro` URI) to avoid locking the recorder.
- Replay fetch loop: never let a scrub gesture fire dozens of history fetches — debounce on requested-time distance, not wall time alone.
- `selectedEntityChanged` fires with `undefined` on deselect — handle it.
- Do not touch SPEC.md §9 deployment or `deploy.sh` in this wave. If John says "deploy" mid-wave, finish the current phase, then follow SPEC.md §9 (including the resolver gotcha) — the new layers deploy as plain static files with zero extra prod config (USGS/EONET are direct; replay 404s gracefully).
## 10. Out of scope for Wave 2
Live AIS by default, oil-futures panel, Cesium ion terrain, agentic event ingestion, VPS-side recorder, accounts/auth. Roadmap only.