Compare commits

..

No commits in common. "main" and "pantheon/integrate" have entirely different histories.

18 changed files with 80 additions and 3207 deletions

View File

@ -1,78 +0,0 @@
# GODSIGH ✕ GODSTRUMENT — Crossover Build Brief
**Audience:** Claude Opus 4.8, executing autonomously in this repo (`godstrument`).
**Author:** Claude Fable 5, 2026-07-13, after mapping both codebases.
**Companion:** `~/Documents/GODSIGH/SPEC2.md` (the sibling wave in the GODSIGH repo). Task 3 below shares a **cache contract** with it — the contract text must stay in sync.
**Before anything:** skim `README.md`, `run.py` (worker supervision + profiles), one worker (`workers/world_iss.py` is the cleanest contract example), `config.json` (signals/routes/groups shape), and the Spirits shelf region of `viz/index.html` (~line 5111 on, "one Source, many Spirits, one Matter"). Match house style: workers are argparse + pythonosc standalone processes emitting `/gs/<group>/<name>`; commits carry an emoji prefix (`🛰`, `🥁`, `📖`…); shipped features get a grimoire/manual entry (see the `📖` commits and `GODSTRUMENT_MANUAL_SOURCE.md`); `*_BRIEF.md` files are deploy-excluded. Verify in the browser (`python3 run.py --profile minimal` + your new worker) before each commit.
Execute the four tasks in order; commit + push per task.
---
## Task 1 — 🎹 Land the local "keyroll" work (groove editor) safely
**Situation.** Branch `local/groove-editor` (commit `c0c3a98`, single commit on top of pre-godrum `3ff105d`) holds ~637 lines of real local work on the sequencer: a piano-roll editor with **draggable note lengths** (`.seqnote` overlay blocks with an `ew-resize` handle), **velocity + chance lanes** (`.explane` draggable bar-per-step lanes), a `seqedit` wrapper with lane toggles/selectors, **Euclidean tools** (`.euc`, `.tnum`), groove **knobs** (`.aknob`), bar-line markers, and a scroll container. Meanwhile `main` merged the whole **godrum** line, which reworked the same sequencer UI area and independently built per-step velocity+chance, Euclidean fills, swing/humanize, and Follow Actions.
**Do NOT `git merge local/groove-editor` blind** — the branches rewrote the same regions and an auto-merge will produce soup. Instead:
1. **Feature-gap analysis first.** Read the branch diff (`git diff 3ff105d..local/groove-editor -- viz/index.html`) and the current main sequencer ("Groove" region, ~`viz/index.html:202`/`:238`, plus godrum's beat panel). Build an explicit table: for each keyroll feature — note lengths / velocity lane / chance lane / lane toggles / euclid tools / groove knobs / bar lines / scroll container / big-grid variant — does current main already have an equivalent (however differently spelled)?
2. **Port only the gaps, natively.** Reimplement each missing feature on top of *current* main's structures and naming (the branch is the reference spec, not a patch to apply). Likeliest genuine gaps, from marker analysis: the **note-length piano roll** (`seqnote` spanning blocks with drag-resize — main has none) and possibly the **explane-style aligned drag-lanes UI**; likeliest already-covered: euclid, velocity/chance *data* (godrum has the data model — the gap may be only the lane *editor UI*).
3. Keep godrum's behavior intact — its choke anchors, euclid guard, kick-duck etc. were carefully review-hardened (`fa5aa43`, `b0fad08`); regressions there are worse than missing a keyroll nicety.
4. Verify by playing: place notes with lengths, drag velocities/chances, euclid-fill, confirm godrum voices still behave, MIDI export still valid.
5. When everything valuable is landed on main, delete the branch (`git branch -D local/groove-editor`) and say so in the commit body — that branch is the only copy of the reference work, so delete it **only after** the port is committed and pushed.
6. Grimoire/manual entry for the new editor capabilities, per house convention.
## Task 2 — 🛰 `workers/world_sats.py`: real satellites as a source (SGP4, no polling APIs)
GODSIGH proved the pattern: TLEs from Celestrak + SGP4 gives *any* satellite's position locally at any rate — no per-fix HTTP like `world_iss.py` needs. Bring that superpower here as a worker:
- **Dependency:** `sgp4` (pure-python, pip). Add to `requirements.txt`.
- **TLEs:** fetch at startup from `https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle` plus `NAME=GAOFEN&FORMAT=tle` and `NAME=COSMOS%202486` / `NAME=COSMOS%202506` (the recon birds — thematic gold). Cache the TLE text to `~/.cache/godverse/tles.txt` and refresh only if the cache is >24 h old (Celestrak politeness; TLEs age slowly). Tolerate fetch failure by using a stale cache; tolerate no-cache-no-net by exiting politely like other workers.
- **Compute** (default `--interval 2`): propagate each satrec to now (`sgp4` gives ECI; convert to lat/lon/alt via the standard GMST rotation — port the math from GODSIGH `js/layers/satellites.js` / satellite.js's `eciToGeodetic`, it's ~15 lines), then derive **elevation angle** above the venue city's horizon (city lat/lon from `cities.json` via the same `--lat/--lon` retargeting `run.py` applies to other geo workers — accept those flags).
- **Emit** (the contract: raw floats, hub normalizes):
- `/gs/sats/best_elev` — highest elevation (degrees, can be negative) among tracked sats: the "something is overhead" continuous signal.
- `/gs/sats/overhead_count` — count with elevation > 10°.
- `/gs/sats/iss_lat`, `/gs/sats/iss_lon` — keep the ISS sweep available even when `world_iss.py` isn't running.
- `/gs/sats/pass.event` — impulse when any sat **crosses rising 30°** (a recon bird crests the sky and a bell rings). Include which sat in the printed log line.
- `/gs/sats/recon_elev` — best elevation among only the GAOFEN/COSMOS set (the watchers' own channel).
- **config.json:** declare the new signals with sensible `norm` ranges (elev `lo:-30, hi:90`; count `lo:0, hi:8`) + labels; add 23 starter routes (suggest: `sats.best_elev → pad.brightness` gentle; `sats.pass → delay.feedback` impulse; `sats.recon_elev → lfo.rate` — the sky watching back). Add the worker to the `WORLD` profile in `run.py`.
- Grimoire/manual entry: "the sky's schedule joins the orchestra."
## Task 3 — 🤝 Shared OpenSky cache (reader side)
**The contract (mirrored verbatim in GODSIGH SPEC2.md §4 — 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.
GODSIGH's dev proxy implements the writer side. Here, make `workers/world_planes.py` cache-aware:
1. Each poll cycle: if the cache file exists and is **fresh**, read it and skip the HTTP call entirely (log `[world_planes] cache hit`). GODSIGH fetches globally; if this worker uses a bbox, filter the cached global states client-side by lat/lon — same fields, indices 5/6.
2. On a real HTTP poll that succeeds **with a global scope**, write the cache per the contract (atomic tmp+rename, `mkdir -p` the directory). If this worker polls a bbox, do NOT write (never write partial data into a global-contract file).
3. On 429 or network failure: fall back to a stale cache if present (log it), else current behavior.
4. Keep the existing ≥450 s cadence — the cache makes the *effective* shared cost whatever the most aggressive consumer pays, once, per window.
Result: GODSIGH (90 s cadence) + godstrument running together spend ~one poller's quota instead of two.
## Task 4 — 👁 "GOD'S EYE" Spirit (a skin for the shelf)
A new Spirit in the existing shelf system (register exactly like the others — bagua `~:5791`, Kandinsky `~:5689` are good templates): the world's sensor picture as the cosmology. 2D canvas, house style — no new libraries.
- **Stage:** a minimal equirectangular world (a sparse hand-drawn continent outline is enough — store a small `[[lon,lat],...]` polyline set inline; do NOT ship a heavy geo dataset into the one-file viz), graticule every 30°, dark ground, the venue city marked with a ring.
- **Seat the feeds geographically, not symbolically** (this Spirit's whole cosmology is *literal coordinates*): quakes flash at their true lat/lon (the quake worker's events carry location — check what reaches the WS feed and use what's available; if only intensity arrives, flash at the venue antipode and note it), ISS/satellites sweep as moving points with short trails (from Task 2's `sats.*` lat/lon signals), planes as a shimmer band whose brightness = `planes.count`-ish signal, market as a ticker pulse at the financial capitals (NY/London/Tokyo dots), solar wind as an aurora wash at the poles, wiki edits as brief sparks at random civilizational coordinates.
- **Tuning:** like every Spirit, choose a scale + palette identity — suggest a cold surveillance palette (near-black, cyan `#35e0ff`, warning amber) and a minor-pentatonic retune, so entering the Spirit *sounds* like a watch floor.
- Register it on the shelf, respect performance mode (`P`) and zen (`Z`), grimoire/manual entry ("the eye that hears").
## Verification & workflow
- Per task: run `python3 run.py --profile minimal` plus the touched worker(s) with explicit flags; watch the hub log for your signals arriving; open the viz and confirm in the browser (Spirit renders, sequencer plays, no console errors). `python3 -m py_compile` every touched .py; keep workers import-safe when optional deps are missing (house rule).
- Commit per task with the emoji house style; push after each. End commit messages with `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- Do not deploy anything from this brief; deployment of godstrument follows its own conventions and is not part of this work.
## Pitfalls
- Task 1: the keyroll branch predates godrum — never trust an auto-merge; port by intent. Delete the reference branch only after the port is pushed.
- `sgp4`'s `Satrec.sgp4()` returns position in **km, TEME frame** — the GMST rotation to geodetic is mandatory, and `satrec.no_kozai` is rad/min (mirror GODSIGH's math, it's verified).
- Celestrak NAME queries can return `No GP data found` plain text — detect (first data line must start with `1`) and treat as empty, not fatal (verified behavior, same as GODSIGH).
- Atomic cache writes: keep the `.tmp` beside the target file (same filesystem), not in `/tmp`.
- The viz is one 9,256-line file — make surgical, well-commented insertions in the established regions; don't reorganize it.

View File

@ -1,105 +0,0 @@
# RESONANCE — build brief · 🪐 music of the spheres
> **You are an execution agent (Opus).** Single lane. Read Part 0 of
> `GODRUM_BRIEF.md` (process root: worktrees, commit style, §0.7 regression,
> §0.8 report, never-deploy) and the REVELATION amendments — then this file.
> Fable reviews before merge.
## ⚠ HARD GATE
**Do not branch until the full REVELATION wave (lanes T, W, P, C AND the ω
closer) has merged to `main`.** This lane edits app-scope `viz/index.html`
(mod sources, a panel, godspeak grammar) and WOULD COLLIDE with all of them.
Confirm: `git log --oneline` shows the ω/closer merge; `grep -c wlen
viz/index.html` ≥ 1 (WHEELS landed); `grep -c testament viz/index.html` ≥ 1.
If not, STOP and report. All anchors below are names, never line numbers.
Branch: `resonance/spheres` in a worktree (`../godstrument-resonance`).
## R.0 The idea
The Godstrument gains the solar system as a modulation source. SOLARGOD
(`/Users/m3ultra/Documents/SOLARGOD`, read-only sibling) proved a ~250-line
dependency-free ephemeris (JPL Standish elements → heliocentric positions,
verified against Horizons to ~3e-4 AU). Planets become LFOs with periods of
months to centuries, compressed into musical time. No network, no server
bridge, no sockets — **the design is vendoring, not streaming**: godstrument
computes the sky itself, deterministically, at its own clock.
## R.1 The vendored engine — `viz/spheres.js`
Create ONE new file by merging, from SOLARGOD **pinned @ `6391273`**:
`js/ephem.js` (whole: tables, solver, `helioEcl`) + the minimal `lib.js`
subset it imports (`centuriesSinceJ2000`, `normDegPM180`, `DEG`, `J2000_JD`,
`jdFromUnixMs`). Header comment: `// vendored from SOLARGOD@6391273 — do not
edit; re-vendor to update`. Keep it an ES module; load it from index.html the
same way the app loads (if the app is one inline IIFE with no module imports —
it is — add a `<script type="module">` that imports spheres.js and hangs a
frozen `window.SPHERES = { helioEcl, … }` for the IIFE to consume; guard every
consumer with `window.SPHERES && …` so the app still boots if the file is
missing). **Self-check on load** (house style, like `migrateSeq`'s): Mars @ JD
2461236.5 must be within 0.005 AU of `(1.05351, 1.00863, -0.00470)` — the
wave-1 Horizons ground truth — else log one console error and disable the
feature flag.
## R.2 The sources — planets as modulators
New computed dests (study `computeDests` and the existing S&H/mod plumbing
FIRST; mirror its conventions exactly). For each planet p of the 8 (+ configurable
Earth-relative pairs), at the CURRENT musical time (see R.3), expose normalized
0..1 signals:
- `sphere.<p>.phase` — heliocentric longitude λp / 360 (a sawtooth with the
planet's year as its period).
- `sphere.<p>.dist` — (r r_min)/(r_max r_min) over that planet's own
perihelion..aphelion (eccentricity breathing; Mercury and Mars breathe hard,
Venus barely).
- `sphere.<p>.beat` — 0.5 + 0.5·sin(2π·(λp λe)/360) — the synodic beat
against Earth (VenusEarth: the 583.9-day pentagram cycle).
Routable exactly like existing mod sources — whatever mechanism computeDests
uses for its S&H-style sources, spheres are more of the same, not a new
subsystem.
## R.3 The great-year clock
Real planets are too slow for music. One knob, `spheres.rate`: **1 bar = N
days** of sky time, log-scaled N ∈ [1, 3650], default 30. Sky JD =
`J2000_JD + (godtime bars elapsed) · N`, derived ONLY from the master clock
(`godtime`/`seqPhase` machinery), so scrubbing/tempo changes stay deterministic
and patch reload reproduces the same sky. At default: Venus' year ≈ 7.5 bars,
Jupiter's ≈ 144 bars, the VenusEarth beat ≈ 19.5 bars — slow-arc territory,
exactly right.
## R.4 The wheel of heaven — WHEELS tie-in
One button in the panel: **"orbital ratios"** — sets the drum lanes'
`wlen`s to the nearest-integer resonance set 4 : 8 : 16 (Io:Europa:Ganymede's
1:2:4) and, if ≥5 lanes exist, 8 : 13 (the Venus:Earth orbit ratio — the
pentagram, in wheel form). Uses WHEELS' shipped API/fields (`wlen`, clamped
116) — pure data pokes + `ungodlyMark` before mutating (UNGODLY convention).
## R.5 The panel & the voice
- Panel `🪐 spheres` (ctxItem + keyboard key if any letter is still free —
check the handler; if none, ctxItem only): per-planet row (phase · dist ·
beat as live meters), the rate knob, the ratios button, on/off. House style:
study `openBeat`/arranger before building.
- godspeak grammar (post-ε `handleMessage`): `spheres on/off`,
`spheres faster/slower`, `<planet> drive <dest>` → route that planet's
`.phase` to a dest by the existing routing verb conventions. Follow ε's
grammar-table pattern; do not restructure it.
- Grimoire: ONE short chapter (the manual regenerates via `build_manual.py`
run it, commit both).
## R.6 Verify (§0.7 regression + these)
- Self-check gate green (R.1's Mars check) in console on load.
- Determinism: set rate N=365, note `sphere.jupiter.phase` at bar 16, reload
the patch, re-run → identical to 1e-6.
- Pentagram: with N=365, measure the `sphere.venus.beat` period in bars —
must be 583.9/365 ≈ 1.6 bars ±2%.
- The ratios button sets the exact `wlen`s and survives `migrateSeq`
round-trip; ⌘Z (UNGODLY) reverts it.
- Zero console errors; `♪ T O B G` + ⌘Z all still work; a patch saved before
this feature loads clean (spheres default off).

View File

@ -4,56 +4,6 @@
---
## ⚠ AMENDMENTS — read BEFORE your lane (Fable, 2026-07-14, post-convergence)
This brief was drafted at `main@b8b1188`, **before** the pantheon merged with the
crossover wave (keyroll note-length · world_sats · OpenSky cache · the god's-eye
Spirit · 18 post-merge review fixes). The convergence landed as `19f1770`. These
amendments override anything they contradict below.
1. **GATE (replaces §0.2's):** branch from `main``19f1770`. Confirm your base
has BOTH waves: `grep -c godspeakCommand viz/index.html` ≥ 1 (ε) AND
`grep -c attachNoteLengths viz/index.html` ≥ 1 (keyroll) AND
`git log --oneline -3` includes the ⚭ convergence commit. If not, STOP.
2. **WHEELS — the `len` name is TAKEN.** Keyroll landed `seq.len` as a
**per-step ARRAY of note lengths** (see `seqFor`: `len: new Array(16).fill(1)`,
plus `migrateSeq`'s `len-default`/`len-badlen` self-checks). WHEELS's per-lane
pattern length must be a **different field: `wlen` (int 116, default 16)**.
Everywhere W.1W.5 below says `len`, read `wlen`. Your `migrateSeq` additions
must coexist with the existing len-array migration (add `wlen` clamping beside
it; new self-check assertions for `wlen`, don't touch the len ones).
3. **WHEELS × keyroll indexing:** the note branch of `seqTick` now reads
`sq.len[cur]` (per-step note length) — with wheels, `cur` becomes
`wheelStep(sq)` for BOTH the `steps[]` gate and the `len[]` lookup (one `cur`,
used twice — do not let them diverge).
4. **WHEELS × exportMid:** the drum block now has an `owned(v, sq)` gate (lanes
export only if playing or edited off `DRUM_SEED`) — your absolute-loop rewrite
in W.4 must keep that gate exactly. The note track is length-aware with a
same-pitch overlap clamp; don't disturb it.
5. **TESTAMENT:** the master chain gained nodes since your anchor was written
(squash from β; the Schumann layer predates). Grep the actual tail of
`build()` for the final limiter node and its name — do not trust `N.lim` as a
name, verify it. Also `Synth.pluck` now takes an optional `durMs` — irrelevant
to your tap, but don't be surprised by the signature.
6. **PSALM:** `Synth.pluck(midi, vel, durMs)` exists — sustained sung notes may
pass a duration instead of relying on the fixed decay; treat that as an
allowed (flag-it-in-report) enhancement, not scope creep. The mic-analysis
AudioContext isolation rule stands.
7. **CANON:** factory patches must survive `migrateSeq` with the NEW fields
(len arrays, `wlen` once WHEELS lands, `locked`, `kit`) — author them by
exporting real patches from a post-WHEELS build, never by hand-writing JSON.
8. **Regression additions to §0.7:** the groove self-check must stay green
(it now asserts len-array migration), and the god's-eye skin must still cycle
(right-click → skin) with zero console errors — it shares the overlay/render
path some of you will be near.
9. **Deploy note for ω (the closer):** the production box is currently TWO waves
behind and its venv still lacks `sgp4` — the deploy checklist you prepare must
start with `/home/humanjing/godstrument/.venv/bin/pip install sgp4`, then the
CLAUDE.md filtered rsync, then restart. GODSPEAK stays local-rig-only (inert
on the VPS); `mlx-whisper` must never enter `requirements.txt`.
---
## Part 0 — shared context
### 0.1 The four revelations

46
auth.py
View File

@ -30,8 +30,6 @@ import time
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_DB = os.path.join(HERE, "godstrument_users.db")
FACTORY_DIR = os.path.join(HERE, "factory") # ✦ CANON — git-tracked, read-only patches that ship with the instrument
FACTORY_PREFIX = "" # "✦ " — the mark of a factory dimension (also the read-only guard key)
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,24}$")
MIN_PW = 8
@ -405,42 +403,6 @@ def delete_preset(user_id: int, name: str, db: str = DEFAULT_DB) -> bool:
return cur.rowcount > 0
# ---- ✦ CANON — factory dimensions (git-tracked, read-only, shared by everyone) ---
def list_factory() -> list[dict]:
"""The shipped ✦ patches, newest-name-order stable. Silent if factory/ is absent."""
out = []
try:
for fn in sorted(os.listdir(FACTORY_DIR)):
if not fn.endswith(".json"):
continue
try:
with open(os.path.join(FACTORY_DIR, fn), encoding="utf-8") as f:
name = (json.load(f).get("name") or "").strip()
if name.startswith(FACTORY_PREFIX):
out.append({"name": name, "updated": 0, "factory": True})
except (OSError, ValueError):
continue
except OSError:
pass
return out
def load_factory(name: str):
"""Load a factory patch by its ✦-prefixed name. Guards against path traversal
(the name never touches the filesystem we match on the JSON's own name field)."""
for fn in os.listdir(FACTORY_DIR) if os.path.isdir(FACTORY_DIR) else []:
if not fn.endswith(".json"):
continue
try:
with open(os.path.join(FACTORY_DIR, fn), encoding="utf-8") as f:
obj = json.load(f)
if (obj.get("name") or "").strip() == name:
return obj.get("data")
except (OSError, ValueError):
continue
return None
# ---- HTTP dispatch (called by hub.py's request handler) ---------------------
COOKIE = "gs_session"
@ -536,17 +498,13 @@ def handle_api(method: str, path: str, body: bytes, cookie_header: str,
return 404, {"error": "not found"}, None
if path == "/api/presets" and method == "GET":
# ✦ CANON — the factory shelf first, then the player's own saved dimensions
return 200, {"presets": list_factory() + list_presets(uid, db)}, None
return 200, {"presets": list_presets(uid, db)}, None
if path.startswith("/api/presets/"):
name = _unquote(path[len("/api/presets/"):])
is_factory = name.startswith(FACTORY_PREFIX)
if method == "GET":
data = load_factory(name) if is_factory else load_preset(uid, name, db)
data = load_preset(uid, name, db)
return (200, {"name": name, "data": data}, None) if data is not None else (404, {"error": "no such preset"}, None)
if is_factory: # ✦ factory dimensions are read-only — never write or delete them
return 403, {"error": "factory dimensions are read-only — save it under a new name"}, None
if method in ("PUT", "POST"):
return (200, {"ok": True}, None) if save_preset(uid, name, payload.get("data"), db) else (400, {"error": "bad preset"}, None)
if method == "DELETE":

View File

@ -30,7 +30,7 @@
"groups": {
"planet": {"label": "the local world", "orbit": "earth", "members": ["weather.temp", "weather.wind", "weather.pressure", "air.pm25", "sky.day", "sky.elev"]},
"cosmos": {"label": "the shared sky", "orbit": "saturn", "members": ["sun.speed", "iss.vel", "planes.count", "crypto.vel", "sats.best_elev"]},
"cosmos": {"label": "the shared sky", "orbit": "saturn", "members": ["sun.speed", "iss.vel", "planes.count", "crypto.vel"]},
"human": {"label": "humanity", "orbit": "mars", "members": ["wiki.rate", "clock.popvel", "clock.births"]},
"market": {"label": "your market", "orbit": "mercury", "members": ["crypto.vel", "market.velocity", "market.turnover"]},
"heavens": {"label": "the sky", "orbit": "neptune", "members": ["astro.moon", "astro.tension", "astro.harmony", "astro.saturn", "astro.mars"]},
@ -96,20 +96,12 @@
"sport.kickoff": {"type": "event", "norm": {"lo": 0, "hi": 5}, "decay": 3.0, "label": "kickoff!"},
"quake.event": {"type": "event", "norm": {"lo": 0, "hi": 7}, "decay": 3.5, "label": "earthquake"},
"quake.depth": {"norm": {"lo": 0, "hi": 300}, "label": "quake depth"},
"quake.lat": {"norm": {"lo": -90, "hi": 90}, "label": "last quake latitude"},
"quake.lon": {"norm": {"lo": -180, "hi": 180}, "label": "last quake longitude"},
"wiki.edit.event": {"type": "event", "norm": {"lo": 0, "hi": 3000}, "attack": 0.005, "decay": 0.25, "label": "wiki edit"},
"wiki.rate": {"norm": {"mode": "minmax", "window": 200}, "filter": {"min_cutoff": 0.8, "beta": 0.03}, "label": "global edit rate"},
"planes.count": {"norm": {"mode": "minmax", "window": 100}, "label": "aircraft aloft"},
"planes.avgalt": {"norm": {"lo": 0, "hi": 13000}, "label": "avg altitude"},
"planes.avgspeed": {"norm": {"lo": 0, "hi": 300}, "label": "avg airspeed"},
"iss.vel": {"norm": {"mode": "minmax", "window": 60}, "label": "ISS ground speed"},
"sats.best_elev": {"norm": {"lo": -30, "hi": 90}, "filter": {"min_cutoff": 0.3, "beta": 0.005}, "label": "highest satellite elevation"},
"sats.overhead_count": {"norm": {"lo": 0, "hi": 8}, "label": "satellites overhead (>10°)"},
"sats.iss_lat": {"norm": {"lo": -90, "hi": 90}, "label": "ISS latitude"},
"sats.iss_lon": {"norm": {"lo": -180, "hi": 180}, "label": "ISS longitude"},
"sats.recon_elev": {"norm": {"lo": -30, "hi": 90}, "filter": {"min_cutoff": 0.3, "beta": 0.005}, "label": "recon-satellite elevation"},
"sats.pass.event": {"type": "event", "norm": {"lo": 0, "hi": 90}, "decay": 3.0, "label": "satellite pass (rising 30°)"},
"tof.cx": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand X (ToF)"},
"tof.cy": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand Y (ToF)"},
"tof.near": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand proximity"},
@ -120,8 +112,6 @@
"audio.centroid": {"norm": {"lo": 200, "hi": 6000}, "label": "room brightness"},
"sky.elev": {"norm": {"lo": -30, "hi": 75}, "filter": {"min_cutoff": 0.15, "beta": 0.002}, "label": "local sun elevation"},
"sky.day": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 0.15, "beta": 0.002}, "label": "local day/night"},
"sky.lat": {"norm": {"lo": -90, "hi": 90}, "label": "venue latitude"},
"sky.lon": {"norm": {"lo": -180, "hi": 180}, "label": "venue longitude"},
"clock.births": {"norm": {"lo": 0, "hi": 8}, "label": "births / sec"},
"clock.deaths": {"norm": {"lo": 0, "hi": 5}, "label": "deaths / sec"},
"clock.popvel": {"norm": {"lo": 0, "hi": 4}, "label": "population growth / sec"},
@ -179,9 +169,6 @@
{"source": "weather.wind", "dest": "lfo.rate", "amount": 0.55, "curve": "lin", "label": "wind speed = modulation rate"},
{"source": "weather.pressure","dest": "drone.voices", "amount": 0.4, "curve": "lin", "label": "barometric pressure = drone weight"},
{"source": "iss.vel", "dest": "wavetable.morph", "amount": 0.4, "curve": "lin", "label": "the ISS drifts the timbre"},
{"source": "sats.best_elev", "dest": "pad.brightness", "amount": 0.4, "curve": "scurve", "label": "something overhead brightens the pads"},
{"source": "sats.pass.event", "dest": "delay.feedback", "amount": 0.7, "label": "a satellite pass throws the delay"},
{"source": "sats.recon_elev", "dest": "lfo.rate", "amount": 0.35, "curve": "lin", "label": "the sky watching back sets the LFO"},
{"source": "tof.cx", "dest": "wavetable.morph", "amount": 0.9, "curve": "lin", "label": "your hand X morphs the wavetable"},
{"source": "tof.cy", "dest": "delay.feedback", "amount": 0.7, "curve": "scurve", "label": "your hand Y feeds the delay"},
{"source": "light.lux", "dest": "reverb.size", "amount": 0.5, "curve": "lin", "label": "room light sizes the reverb"},

View File

@ -1,57 +0,0 @@
{
"name": "✦ quiet arrival",
"data": {
"routes": [
{
"source": "astro.moon",
"dest": "echo.wet",
"amount": 0.6,
"curve": "lin",
"gate": null,
"quantize": null,
"label": "the moon opens the echo"
},
{
"source": "weather.wind",
"dest": "echo.time",
"amount": 0.5,
"curve": "scurve",
"gate": null,
"quantize": null,
"label": "the wind whips the tape speed"
},
{
"source": "sky.elev",
"dest": "pad.brightness",
"amount": 0.7,
"curve": "scurve",
"gate": null,
"quantize": null,
"label": "the sun brightens the pads"
},
{
"source": "sun.speed",
"dest": "reverb.size",
"amount": 0.5,
"curve": "lin",
"gate": null,
"quantize": null,
"label": "the solar wind sizes the room"
}
],
"tweaks": {},
"seqs": {},
"bpm": 64,
"groove": {
"amount": 1,
"preset": "straight",
"swing": 0,
"humanize": 0,
"velo": 0
},
"scale": {
"root": 5,
"name": "major"
}
}
}

View File

@ -1,479 +0,0 @@
{
"name": "✦ the heartbeat",
"data": {
"routes": [],
"tweaks": {},
"seqs": {
"drum.kick": {
"on": true,
"steps": [
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0,
0,
0
],
"vel": [
0.95,
0.8,
0.8,
0.8,
0.8,
0.8,
0.7,
0.8,
0.8,
0.8,
0.75,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.snare": {
"on": true,
"steps": [
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
1
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.hat": {
"on": true,
"steps": [
1,
0,
1,
0,
1,
0,
1,
0,
1,
0,
1,
0,
1,
0,
1,
0
],
"vel": [
0.7,
0.4,
0.6,
0.4,
0.7,
0.4,
0.6,
0.4,
0.7,
0.4,
0.6,
0.4,
0.7,
0.4,
0.6,
0.5
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.ohat": {
"on": true,
"steps": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
}
},
"bpm": 88,
"groove": {
"amount": 1,
"preset": "mpc 8-4",
"swing": 0.62,
"humanize": 0.12,
"velo": 0.18
},
"scale": {
"root": 0,
"name": "minor_pent"
},
"kit": {
"kick": {
"tune": 0.44,
"punch": 0.6,
"decay": 0.55,
"duck": 0.5,
"level": 0.9,
"pan": 0.5
}
}
}
}

View File

@ -1,376 +0,0 @@
{
"name": "✦ the squeeze",
"data": {
"routes": [
{
"source": "crypto.vel",
"dest": "squash",
"amount": 0.9,
"curve": "lin",
"gate": null,
"quantize": null,
"label": "the market pumps the master"
},
{
"source": "quake.event",
"dest": "squash",
"amount": 0.7,
"curve": "exp3",
"gate": null,
"quantize": null,
"label": "a quake heaves the whole mix"
}
],
"tweaks": {},
"seqs": {
"drum.kick": {
"on": true,
"steps": [
1,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.hat": {
"on": true,
"steps": [
0,
0,
1,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0,
1,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.snare": {
"on": true,
"steps": [
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
}
},
"bpm": 120,
"groove": {
"amount": 1,
"preset": "straight",
"swing": 0,
"humanize": 0.03,
"velo": 0.12
},
"scale": {
"root": 0,
"name": "minor_pent"
}
}
}

View File

@ -1,357 +0,0 @@
{
"name": "✦ the wheel within the wheel",
"data": {
"routes": [],
"tweaks": {},
"seqs": {
"drum.kick": {
"on": true,
"steps": [
1,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.hat": {
"on": true,
"steps": [
1,
0,
0,
1,
0,
0,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 12
},
"drum.clap": {
"on": true,
"steps": [
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 10
}
},
"bpm": 96,
"groove": {
"amount": 1,
"preset": "swing 16",
"swing": 0.55,
"humanize": 0.05,
"velo": 0.1
},
"scale": {
"root": 0,
"name": "minor_pent"
}
}
}

View File

@ -2,9 +2,6 @@
python-osc>=1.9
websockets>=12.0
# Satellites worker (workers/world_sats.py) — SGP4 propagation from Celestrak TLEs
# pip install sgp4 # pure-python, no build; worker is import-safe without it
# Optional: MIDI output from the hub to Ableton/TouchDesigner via a virtual port
# pip install python-rtmidi
# Optional: microphone FFT worker (workers/audio_worker.py)

8
run.py
View File

@ -33,7 +33,6 @@ WORLD = [
"workers/world_air.py",
"workers/world_sun.py",
"workers/world_iss.py",
"workers/world_sats.py",
"workers/world_crypto.py",
"workers/world_fx.py",
"workers/world_planes.py",
@ -50,7 +49,7 @@ WORLD = [
]
# workers that retarget to the venue city
LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py", "world_sats.py"}
LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py"}
BBOX_WORKERS = {"world_planes.py"}
@ -204,11 +203,6 @@ def main():
now = time.time()
for w in workers: # revive any worker that died
if w["proc"].poll() is not None and now >= w["next_ok"]:
if w["proc"].returncode == 0: # exit 0 = a deliberate, polite goodbye
if not w.get("retired"): # (missing dep etc.) — don't churn it
w["retired"] = True
print(f" ({os.path.basename(w['path'])} exited cleanly; retired)")
continue
print(f" ({os.path.basename(w['path'])} died; restarting)")
np = launch(w["path"], w["extra"])
if np:

View File

@ -1,91 +0,0 @@
#!/usr/bin/env python3
"""test_factory.py — validate the ✦ CANON factory patches' SHAPE.
Not a taste check (the human re-voices by ear) this asserts every factory
patch loads cleanly through the client's migrateSeq expectations: 16-step seq
arrays, wlen in 1..16, sane bpm, valid route/groove/scale shape, and a
-prefixed name. Stdlib only; excluded from the deploy by the test_*.py filter.
python3 test_factory.py
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
FACTORY = os.path.join(HERE, "factory")
SCALES = {"chromatic", "major", "minor", "dorian", "phrygian", "lydian",
"mixolydian", "pentatonic", "minor_pent", "wholetone", "hirajoshi"}
SEQ_ARRAYS = ("steps", "vel", "chance", "rat", "lock", "len")
def check_seq(key, s, errs):
for a in SEQ_ARRAYS:
if a in s:
if not isinstance(s[a], list) or len(s[a]) != 16:
errs.append(f"{key}.{a} must be a 16-length array")
if "steps" not in s or not isinstance(s.get("steps"), list) or len(s["steps"]) != 16:
errs.append(f"{key}.steps missing/not-16 (migrateSeq would reject → dropped)")
if "wlen" in s and not (isinstance(s["wlen"], int) and 1 <= s["wlen"] <= 16):
errs.append(f"{key}.wlen must be an int 1..16, got {s.get('wlen')!r}")
if "on" in s and not isinstance(s["on"], (bool, int)):
errs.append(f"{key}.on must be boolean-ish")
def check_patch(name, p, errs):
if not isinstance(p.get("bpm"), (int, float)) or not (40 <= p["bpm"] <= 180):
errs.append(f"bpm must be 40..180, got {p.get('bpm')!r}")
for r in p.get("routes", []):
if not r.get("source") or not r.get("dest"):
errs.append("route missing source/dest")
if not isinstance(r.get("amount"), (int, float)):
errs.append(f"route {r.get('source')}{r.get('dest')} amount not numeric")
for key, s in (p.get("seqs") or {}).items():
check_seq(key, s, errs)
sc = p.get("scale") or {}
if sc and sc.get("name") not in SCALES:
errs.append(f"scale.name {sc.get('name')!r} not a known scale")
gr = p.get("groove") or {}
for k in ("swing", "humanize", "velo"):
if k in gr and not (0 <= gr[k] <= 1):
errs.append(f"groove.{k} out of 0..1")
def main():
if not os.path.isdir(FACTORY):
print("FAIL: no factory/ dir"); sys.exit(1)
files = sorted(f for f in os.listdir(FACTORY) if f.endswith(".json"))
if len(files) < 4:
print(f"FAIL: expected ≥4 factory patches, found {len(files)}"); sys.exit(1)
total_errs = 0
names = []
for fn in files:
with open(os.path.join(FACTORY, fn), encoding="utf-8") as f:
obj = json.load(f)
name, data = obj.get("name", ""), obj.get("data")
errs = []
if not name.startswith(""):
errs.append(f"name {name!r} must start with ''")
if not isinstance(data, dict):
errs.append("missing 'data' object")
else:
check_patch(name, data, errs)
names.append(name)
if errs:
total_errs += len(errs)
print(f"{fn}: " + "; ".join(errs))
else:
nseq = len(data.get("seqs") or {})
nroute = len(data.get("routes") or [])
print(f"{name} (bpm {data['bpm']}, {nseq} seqs, {nroute} routes)")
# the four the brief names
for want in ("✦ the heartbeat", "✦ the wheel within the wheel", "✦ the squeeze", "✦ quiet arrival"):
if want not in names:
print(f" ✗ missing canonical patch: {want}"); total_errs += 1
if total_errs:
print(f"\nFAIL: {total_errs} shape error(s)"); sys.exit(1)
print(f"\nALL {len(files)} FACTORY PATCHES VALID (shape only — beauty is the human's)")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,203 +0,0 @@
// vendored from SOLARGOD@6391273 — do not edit; re-vendor to update.
// This is js/ephem.js (whole) with the minimal js/lib.js subset it needs inlined
// (centuriesSinceJ2000, normDegPM180, DEG, J2000_JD, jdFromUnixMs) so the file
// stands alone with no import of Three.js or the rest of SOLARGOD. The IIFE in
// index.html imports from here via a <script type="module"> bridge that hangs a
// frozen window.SPHERES. Godstrument computes the sky itself, deterministically,
// at its own musical clock — no network, no server, no sockets.
// ---- lib.js subset (SOLARGOD@6391273/js/lib.js — pure, dependency-free) ----
export const MS_PER_DAY = 86400000;
export const J2000_JD = 2451545.0; // 2000-Jan-01 12:00 TT
export const UNIX_EPOCH_JD = 2440587.5; // JD of 1970-Jan-01 00:00 UTC
export const DEG = Math.PI / 180;
// JD from unix-ms: divide by ms/day, add the unix-epoch JD.
export function jdFromUnixMs(ms) { return ms / MS_PER_DAY + UNIX_EPOCH_JD; }
// Julian centuries since J2000.0.
export function centuriesSinceJ2000(jd) { return (jd - J2000_JD) / 36525; }
// Normalize degrees to (180, +180]. Must run in float64 AFTER any large
// subtraction (Mercury's L rate is 149473°/Cy → M can be tens of thousands of
// degrees before reduction).
export function normDegPM180(d) {
d = d % 360;
if (d <= -180) d += 360;
else if (d > 180) d -= 360;
return d;
}
// ===========================================================================
// js/ephem.js — JPL SSD "Approximate Positions of the Planets" (Standish).
// Tables extracted 2026-07-15 from https://ssd.jpl.nasa.gov/planets/approx_pos.html
// — authoritative, verbatim. Angles in degrees, rates per Julian century; `e` is
// dimensionless (the page's "rad" header for e is its own typo). No n-body
// integration — this IS the spec.
// Row layout: el = [a(au), e, I(deg), L(deg), ϖ long.peri(deg), Ω long.node(deg)]
// rate = same order, per century.
const TABLE1 = {
mercury: { el: [0.38709927, 0.20563593, 7.00497902, 252.25032350, 77.45779628, 48.33076593],
rate: [0.00000037, 0.00001906, -0.00594749, 149472.67411175, 0.16047689, -0.12534081] },
venus: { el: [0.72333566, 0.00677672, 3.39467605, 181.97909950, 131.60246718, 76.67984255],
rate: [0.00000390, -0.00004107, -0.00078890, 58517.81538729, 0.00268329, -0.27769418] },
earth: { el: [1.00000261, 0.01671123, -0.00001531, 100.46457166, 102.93768193, 0.0],
rate: [0.00000562, -0.00004392, -0.01294668, 35999.37244981, 0.32327364, 0.0] },
mars: { el: [1.52371034, 0.09339410, 1.84969142, -4.55343205, -23.94362959, 49.55953891],
rate: [0.00001847, 0.00007882, -0.00813131, 19140.30268499, 0.44441088, -0.29257343] },
jupiter: { el: [5.20288700, 0.04838624, 1.30439695, 34.39644051, 14.72847983, 100.47390909],
rate: [-0.00011607, -0.00013253, -0.00183714, 3034.74612775, 0.21252668, 0.20469106] },
saturn: { el: [9.53667594, 0.05386179, 2.48599187, 49.95424423, 92.59887831, 113.66242448],
rate: [-0.00125060, -0.00050991, 0.00193609, 1222.49362201, -0.41897216, -0.28867794] },
uranus: { el: [19.18916464, 0.04725744, 0.77263783, 313.23810451, 170.95427630, 74.01692503],
rate: [-0.00196176, -0.00004397, -0.00242939, 428.48202785, 0.40805281, 0.04240589] },
neptune: { el: [30.06992276, 0.00859048, 1.77004347, -55.12002969, 44.96476227, 131.78422574],
rate: [0.00026291, 0.00005105, 0.00035372, 218.45945325, -0.32241464, -0.00508664] },
};
// Table 2a — valid 3000 BC3000 AD. Shipped for the extended-range config swap
// (CONFIG.useExtendedRange); unused by v1 UI.
const TABLE2A = {
mercury: { el: [0.38709843, 0.20563661, 7.00559432, 252.25166724, 77.45771895, 48.33961819],
rate: [0.00000000, 0.00002123, -0.00590158, 149472.67486623, 0.15940013, -0.12214182] },
venus: { el: [0.72332102, 0.00676399, 3.39777545, 181.97970850, 131.76755713, 76.67261496],
rate: [-0.00000026, -0.00005107, 0.00043494, 58517.81560260, 0.05679648, -0.27274174] },
earth: { el: [1.00000018, 0.01673163, -0.00054346, 100.46691572, 102.93005885, -5.11260389],
rate: [-0.00000003, -0.00003661, -0.01337178, 35999.37306329, 0.31795260, -0.24123856] },
mars: { el: [1.52371243, 0.09336511, 1.85181869, -4.56813164, -23.91744784, 49.71320984],
rate: [0.00000097, 0.00009149, -0.00724757, 19140.29934243, 0.45223625, -0.26852431] },
jupiter: { el: [5.20248019, 0.04853590, 1.29861416, 34.33479152, 14.27495244, 100.29282654],
rate: [-0.00002864, 0.00018026, -0.00322699, 3034.90371757, 0.18199196, 0.13024619] },
saturn: { el: [9.54149883, 0.05550825, 2.49424102, 50.07571329, 92.86136063, 113.63998702],
rate: [-0.00003065, -0.00032044, 0.00451969, 1222.11494724, 0.54179478, -0.25015002] },
uranus: { el: [19.18797948, 0.04685740, 0.77298127, 314.20276625, 172.43404441, 73.96250215],
rate: [-0.00020455, -0.00001550, -0.00180155, 428.49512595, 0.09266985, 0.05739699] },
neptune: { el: [30.06952752, 0.00895439, 1.77005520, 304.22289287, 46.68158724, 131.78635853],
rate: [0.00006447, 0.00000818, 0.00022400, 218.46515314, 0.01009938, -0.00606302] },
};
// Table 2b — extra terms added to M (deg) for JupiterNeptune, Table 2a only:
// M += b·T² + c·cos(f·T) + s·sin(f·T). [b, c, s, f]
const TABLE2B = {
jupiter: [-0.00012452, 0.06064060, -0.35635438, 38.35125000],
saturn: [0.00025899, -0.13434469, 0.87320147, 38.35125000],
uranus: [0.00058331, -0.97731848, 0.17689245, 7.67025000],
neptune: [-0.00041348, 0.68346318, -0.10162547, 7.67025000],
};
export const PLANET_IDS = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
let useExtended = false;
export function setExtendedRange(on) { useExtended = !!on; }
// Solve Kepler's equation E e·sinE = M (radians) by NewtonRaphson.
// Seed E₀ = M + e·sinM; converges in a few iters even for e→0.97 (verify gate).
export function solveKepler(M, e, tol = 1e-9, maxIter = 20) {
let E = M + e * Math.sin(M);
for (let i = 0; i < maxIter; i++) {
const dE = (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
E -= dE;
if (Math.abs(dE) < tol) break;
}
return E;
}
// The reusable core: resolved classical elements → heliocentric J2000 ecliptic
// {x,y,z} in AU. a[au], e, and iDeg/OmDeg(Ω)/wDeg(ω arg.peri)/Mdeg all in degrees.
// Shared by planets (element+rate) and small bodies (epoch elements) — brief §3.
export function eclFromElements(a, e, iDeg, OmDeg, wDeg, Mdeg, out = {}) {
const M = normDegPM180(Mdeg) * DEG;
const E = solveKepler(M, e);
// Orbital-plane coords (perifocal), AU.
const xp = a * (Math.cos(E) - e);
const yp = a * Math.sqrt(1 - e * e) * Math.sin(E);
const w = wDeg * DEG, Om = OmDeg * DEG, I = iDeg * DEG;
const cw = Math.cos(w), sw = Math.sin(w);
const cO = Math.cos(Om), sO = Math.sin(Om);
const cI = Math.cos(I), sI = Math.sin(I);
// Perifocal → ecliptic rotation (brief §3 step 5 = standard R_z(Ω)R_x(I)R_z(ω)).
out.x = (cw * cO - sw * sO * cI) * xp + (-sw * cO - cw * sO * cI) * yp;
out.y = (cw * sO + sw * cO * cI) * xp + (-sw * sO + cw * cO * cI) * yp;
out.z = (sw * sI) * xp + (cw * sI) * yp;
return out;
}
// Resolve a planet's elements at jd (Table 1, 18002050) → {a,e,I,Om,w,M} deg.
function resolvePlanet(bodyId, jd) {
const T = centuriesSinceJ2000(jd);
const row = TABLE1[bodyId];
if (!row) throw new Error(`ephem: unknown planet ${bodyId}`);
const a = row.el[0] + row.rate[0] * T;
const e = row.el[1] + row.rate[1] * T;
const I = row.el[2] + row.rate[2] * T;
const L = row.el[3] + row.rate[3] * T;
const wb = row.el[4] + row.rate[4] * T; // ϖ longitude of perihelion
const Om = row.el[5] + row.rate[5] * T; // Ω longitude of ascending node
const w = wb - Om; // ω argument of perihelion
const M = L - wb; // mean anomaly (normalize AFTER subtract)
return { a, e, I, Om, w, M };
}
// Extended-range resolver (Table 2a + the 2b M-correction for JupiterNeptune,
// valid 3000 BC3000 AD). f·T uses T in centuries; f is deg-per-century so f·T
// is degrees → convert to rad for the trig. Only used when useExtended is set.
function resolvePlanetExt(bodyId, jd) {
const T = centuriesSinceJ2000(jd);
const row = TABLE2A[bodyId];
const a = row.el[0] + row.rate[0] * T;
const e = row.el[1] + row.rate[1] * T;
const I = row.el[2] + row.rate[2] * T;
const L = row.el[3] + row.rate[3] * T;
const wb = row.el[4] + row.rate[4] * T;
const Om = row.el[5] + row.rate[5] * T;
const w = wb - Om;
let M = L - wb;
const b2 = TABLE2B[bodyId];
if (b2) {
const [b, c, s, f] = b2;
M += b * T * T + c * Math.cos(f * T * DEG) + s * Math.sin(f * T * DEG);
}
return { a, e, I, Om, w, M };
}
// Public: heliocentric J2000 ecliptic position of a planet at jd, AU.
export function helioEcl(bodyId, jd, out = {}) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return eclFromElements(p.a, p.e, p.I, p.Om, p.w, p.M, out);
}
// Mean motion [deg/day] from semi-major axis via Kepler's third law (Gaussian).
export function meanMotionDegPerDay(a) { return 0.9856076686 / Math.pow(a, 1.5); }
// Resolved semi-major axis [AU] of a planet at jd (for vis-viva speed, year length).
export function semiMajor(bodyId, jd) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return p.a;
}
// Small-body position (asteroids/comets/Pluto): epoch elements propagated by
// M = M0 + n·(jd epoch). el = {a,e,i,om(Ω),w(ω),ma(M0),epoch}.
export function smallBodyEcl(el, jd, out = {}) {
const n = meanMotionDegPerDay(el.a);
const M = el.ma + n * (jd - el.epoch);
return eclFromElements(el.a, el.e, el.i, el.om, el.w, M, out);
}
// One full orbit sampled in mean anomaly, as a Float64Array of xyz triples
// (nSamples+1 points; last == first so a LineLoop/LineSegments closes cleanly —
// verify gate "orbitPath endpoints join"). Elements frozen at jd (they drift
// slowly; caller rebuilds only when |ΔT| > 0.1 Cy, brief §11).
export function orbitPath(bodyId, jd, nSamples = 256) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return samplePath(p.a, p.e, p.I, p.Om, p.w, nSamples);
}
export function orbitPathFromElements(el, nSamples = 256) {
return samplePath(el.a, el.e, el.i, el.om, el.w, nSamples);
}
function samplePath(a, e, I, Om, w, nSamples) {
const out = new Float64Array((nSamples + 1) * 3);
const tmp = {};
for (let k = 0; k <= nSamples; k++) {
const M = (360 * k) / nSamples; // 0..360; k=nSamples reproduces k=0
eclFromElements(a, e, I, Om, w, M, tmp);
out[k * 3] = tmp.x; out[k * 3 + 1] = tmp.y; out[k * 3 + 2] = tmp.z;
}
return out;
}

View File

@ -5,7 +5,6 @@ and mean groundspeed (intensity) — the sky's traffic becomes a slow chord.
"""
import argparse
import json
import os
import sys
import time
import urllib.error
@ -24,57 +23,6 @@ POLL_OK = 450.0 # normal poll interval (s)
POLL_429 = 600.0 # backoff poll interval after HTTP 429
REEMIT = 10.0 # re-emit last values every 10s
# --- OpenSky shared cache v1 (reader side) --------------------------------
# Path: ~/.cache/godverse/opensky-states.json, override via OPENSKY_CACHE_FILE.
# Content: the raw, unmodified OpenSky /states/all body (global, no bbox), written
# atomically by WHOEVER fetched it (GODSIGH's dev proxy is the writer). Freshness =
# file mtime. We treat the cache as fresh within 120 s; on a fresh hit we skip our
# own HTTP entirely and just filter the global states to our bbox, so a godstrument
# + GODSIGH running together spend ~one poller's quota instead of two. This worker
# only ever polls a bbox, so it never WRITES the cache (never partial data into a
# global-contract file) — it is a pure reader, and degrades to its old bbox fetch
# when no writer is around (i.e. prod).
CACHE_FILE = os.environ.get("OPENSKY_CACHE_FILE") or \
os.path.expanduser("~/.cache/godverse/opensky-states.json")
CACHE_FRESH = 120.0 # a cache within this many seconds of its mtime is "fresh"
def read_cache(max_age):
"""Return (data, age_seconds) from the shared cache, or None. If max_age is not
None, returns None when the cache is older than that (i.e. not fresh)."""
try:
mtime = os.path.getmtime(CACHE_FILE)
except OSError:
return None
age = time.time() - mtime
if max_age is not None and age > max_age:
return None
try:
with open(CACHE_FILE, "r", encoding="utf-8") as f:
return json.load(f), age
except (OSError, json.JSONDecodeError, ValueError):
return None
def filter_states_bbox(data, bbox):
"""Filter a global states payload to our bbox client-side (the cache is global).
OpenSky state vectors: index 5 = longitude, index 6 = latitude."""
s, w, n, e = bbox
states = (data or {}).get("states") or []
out = []
for row in states:
if not row or len(row) < 7:
continue
lon, lat = row[5], row[6]
if lon is None or lat is None:
continue
try:
if s <= float(lat) <= n and w <= float(lon) <= e:
out.append(row)
except (TypeError, ValueError):
continue
return {"states": out}
def fetch(bbox, timeout=25):
"""Fetch OpenSky states for the bbox. Returns parsed JSON dict.
@ -147,42 +95,25 @@ def main():
while True:
now = time.monotonic()
if now >= next_poll:
cached = read_cache(CACHE_FRESH) # a fresh global cache -> skip HTTP entirely
if cached is not None:
data, age = cached
last = reduce_states(filter_states_bbox(data, args.bbox))
try:
data = fetch(args.bbox)
last = reduce_states(data)
if poll != POLL_OK:
print(f"[{NAME}] recovered; back to {POLL_OK:.0f}s poll")
poll = POLL_OK
print(f"[{NAME}] cache hit ({age:.0f}s old) -> {int(last[0])} aircraft in bbox")
else:
try:
data = fetch(args.bbox)
last = reduce_states(data)
if poll != POLL_OK:
print(f"[{NAME}] recovered; back to {POLL_OK:.0f}s poll")
poll = POLL_OK
except urllib.error.HTTPError as ex:
if ex.code == 429:
retry_after = ex.headers.get("X-Rate-Limit-Retry-After-Seconds")
try:
poll = max(POLL_429, float(retry_after))
except (TypeError, ValueError):
poll = POLL_429
print(f"[{NAME}] HTTP 429 rate limited; backing off to {poll:.0f}s")
else:
print(f"[{NAME}] HTTP {ex.code}; retrying in {poll:.0f}s")
_stale = read_cache(3600) # 429 -> a stale cache beats nothing,
if _stale is not None: # but a day-old sky beats neither
last = reduce_states(filter_states_bbox(_stale[0], args.bbox))
print(f"[{NAME}] serving stale cache ({_stale[1]:.0f}s old)")
except (urllib.error.URLError, TimeoutError, OSError,
json.JSONDecodeError, ValueError) as ex:
print(f"[{NAME}] fetch error: {ex}; retrying in {poll:.0f}s")
_stale = read_cache(3600) # network down -> fall back, bounded 1h
if _stale is not None:
last = reduce_states(filter_states_bbox(_stale[0], args.bbox))
print(f"[{NAME}] serving stale cache ({_stale[1]:.0f}s old)")
except urllib.error.HTTPError as ex:
if ex.code == 429:
retry_after = ex.headers.get("X-Rate-Limit-Retry-After-Seconds")
try:
poll = max(POLL_429, float(retry_after))
except (TypeError, ValueError):
poll = POLL_429
print(f"[{NAME}] HTTP 429 rate limited; backing off to {poll:.0f}s")
else:
print(f"[{NAME}] HTTP {ex.code}; retrying in {poll:.0f}s")
except (urllib.error.URLError, TimeoutError, OSError,
json.JSONDecodeError, ValueError) as ex:
print(f"[{NAME}] fetch error: {ex}; retrying in {poll:.0f}s")
next_poll = time.monotonic() + poll
# Emit (or re-emit) the last known values.

View File

@ -1,329 +0,0 @@
#!/usr/bin/env python3
"""world_sats.py — the sky's own schedule, as control signals (SGP4, no polling APIs).
Real satellites, propagated locally. TLEs (two-line element sets) are fetched once
from Celestrak and cached; from them SGP4 gives *any* satellite's position at *any*
rate with no per-fix HTTP the opposite of world_iss.py, which begs a web API for
every point. We track the space stations plus a few named recon birds (GAOFEN,
COSMOS), turn each into a lat/lon/altitude, and derive its **elevation above the
venue city's horizon** — so "something is passing overhead" becomes a continuous,
playable signal, and a recon satellite cresting the sky rings a bell.
Emits (raw floats; the hub normalizes):
/gs/sats/best_elev highest elevation among tracked sats, degrees (can be < 0)
/gs/sats/overhead_count how many are above 10 degrees right now
/gs/sats/iss_lat ISS geodetic latitude (the sweep, even without world_iss)
/gs/sats/iss_lon ISS geodetic longitude
/gs/sats/pass.event impulse (magnitude = elevation) when any sat crosses
rising through 30 degrees a pass begins
/gs/sats/recon_elev best elevation among the GAOFEN/COSMOS set only
Depends on `sgp4` (pure-python, `pip install sgp4`). Import-safe: if it is missing,
or there is no cached TLE and no network, the worker prints why and exits cleanly.
"""
from __future__ import annotations
import argparse
import datetime
import math
import os
import sys
import time
import urllib.error
import urllib.request
from pythonosc.udp_client import SimpleUDPClient
try:
from sgp4.api import Satrec, jday
HAVE_SGP4 = True
except Exception: # pure-python but still optional (house rule: import-safe)
HAVE_SGP4 = False
NAME = "world_sats"
UA = "Godstrument/1.0 (live instrument; contact monsterrobotparty@gmail.com)"
CACHE_DIR = os.path.expanduser("~/.cache/godverse")
CACHE_FILE = os.path.join(CACHE_DIR, "tles.txt")
CACHE_MAX_AGE = 24 * 3600.0 # Celestrak politeness — TLEs age slowly
REFRESH_EVERY = 6 * 3600.0 # re-check the cache age this often while running
# (url, is_recon) — the stations give us the ISS; the named queries are the watchers.
# A NAME query with no match returns the plain text "No GP data found"; parse_tles
# rejects any group whose first data line doesn't start with "1".
TLE_SOURCES = [
("https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle", False),
("https://celestrak.org/NORAD/elements/gp.php?NAME=GAOFEN&FORMAT=tle", True),
("https://celestrak.org/NORAD/elements/gp.php?NAME=COSMOS%202486&FORMAT=tle", True),
("https://celestrak.org/NORAD/elements/gp.php?NAME=COSMOS%202506&FORMAT=tle", True),
]
PASS_ELEV = 30.0 # a sat rising through this elevation rings the bell
OVERHEAD_ELEV = 10.0 # counted as "overhead" above this
ISS_CATNR = "25544" # NORAD catalog number of the ISS (ZARYA)
WGS_A = 6378.137 # WGS-84 semi-major axis, km
WGS_B = 6356.7523142 # semi-minor axis, km
def fetch_url(url, timeout=20):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", "replace")
def parse_tles(text):
"""Parse TLE text into [(name, line1, line2)]. Tolerant of 2-line groups and of
Celestrak's plain-text 'No GP data found' (any group whose line1 isn't a '1')."""
lines = [ln.rstrip() for ln in text.splitlines()
if ln.strip() and not ln.startswith("#")]
out = []
i = 0
while i < len(lines):
ln = lines[i]
if ln.startswith("1 ") and i + 1 < len(lines) and lines[i + 1].startswith("2 "):
out.append(("", ln, lines[i + 1])) # 2-line group, no name
i += 2
elif (not ln.startswith(("1 ", "2 "))) and i + 2 < len(lines) \
and lines[i + 1].startswith("1 ") and lines[i + 2].startswith("2 "):
out.append((ln.strip(), lines[i + 1], lines[i + 2])) # named 3-line group
i += 3
else:
i += 1 # junk / 'No GP data found' — skip
return out
def _write_cache(groups, recon_catnrs):
"""Cache the merged 3LE with a recon-catnr header so the tags survive a reload."""
try:
os.makedirs(CACHE_DIR, exist_ok=True)
tmp = CACHE_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write("# recon " + ",".join(sorted(recon_catnrs)) + "\n")
for name, l1, l2, _is_recon in groups:
f.write((name + "\n" if name else "") + l1 + "\n" + l2 + "\n")
os.replace(tmp, CACHE_FILE) # atomic
print(f"[{NAME}] refreshed TLE cache -> {CACHE_FILE}")
except OSError as e:
print(f"[{NAME}] warning: could not write cache: {e}")
def _read_cache():
"""Return (groups, recon_catnrs) from the tagged cache, or ([], set())."""
if not os.path.isfile(CACHE_FILE):
return [], set()
try:
with open(CACHE_FILE, "r", encoding="utf-8") as f:
text = f.read()
except OSError:
return [], set()
recon = set()
for ln in text.splitlines():
if ln.startswith("# recon "):
recon = {x for x in ln[len("# recon "):].strip().split(",") if x}
break
groups = [(name, l1, l2, l1[2:7].strip() in recon) for name, l1, l2 in parse_tles(text)]
return groups, recon
def load_grouped_tles(force_fetch=False):
"""Return [(name, l1, l2, is_recon)] — a fresh cache if possible, else a fetch.
Fetches each source separately so recon birds keep their tag, writes the tagged
cache, and falls back to a stale cache on network failure. Returns [] only when
there is no cache and no network."""
fresh = False
if not force_fetch:
try:
fresh = os.path.isfile(CACHE_FILE) and (time.time() - os.path.getmtime(CACHE_FILE)) < CACHE_MAX_AGE
except OSError:
fresh = False
if fresh:
groups, _ = _read_cache()
if groups:
print(f"[{NAME}] using cached TLEs (< 24 h old)")
return groups
merged, recon_catnrs = [], set()
any_failed = False
for url, is_recon in TLE_SOURCES:
try:
txt = fetch_url(url)
except (urllib.error.URLError, OSError, ValueError) as e:
print(f"[{NAME}] TLE fetch failed for {url.split('?')[-1]}: {e}")
any_failed = True
continue
for name, l1, l2 in parse_tles(txt):
catnr = l1[2:7].strip()
if is_recon:
recon_catnrs.add(catnr)
merged.append((name, l1, l2, is_recon))
if merged:
if any_failed: # partial fetch: backfill the failed groups
have = {l1[2:7].strip() for _n, l1, _l2, _r in merged} # from the old cache so a
old, _old_recon = _read_cache() # half-set never clobbers it
for name, l1, l2, is_recon in old:
if l1[2:7].strip() not in have:
merged.append((name, l1, l2, is_recon))
if is_recon:
recon_catnrs.add(l1[2:7].strip())
_write_cache(merged, recon_catnrs)
return merged
groups, _ = _read_cache() # network down — serve stale
if groups:
print(f"[{NAME}] network down — using stale TLE cache")
return groups
def gstime(jdut1):
"""Greenwich Mean Sidereal Time (radians) — the IAU-82 series (matches satellite.js)."""
t = (jdut1 - 2451545.0) / 36525.0
g = 67310.54841 + (876600.0 * 3600 + 8640184.812866) * t + 0.093104 * t * t - 6.2e-6 * t * t * t
g = math.radians(g / 240.0) % (2 * math.pi) # seconds -> degrees (/240) -> radians
return g + 2 * math.pi if g < 0 else g
def eci_to_geodetic(r, gmst):
"""TEME position (km) + GMST -> (lat_deg, lon_deg, alt_km). WGS-84, iterative."""
x, y, z = r
a, b = WGS_A, WGS_B
f = (a - b) / a
e2 = 2 * f - f * f
R = math.hypot(x, y)
lon = math.atan2(y, x) - gmst
lon = (lon + math.pi) % (2 * math.pi) - math.pi # wrap to [-pi, pi]
lat = math.atan2(z, R)
C = 1.0
for _ in range(20): # converges in a handful of steps
C = 1.0 / math.sqrt(1 - e2 * math.sin(lat) ** 2)
lat = math.atan2(z + a * C * e2 * math.sin(lat), R)
alt = R / math.cos(lat) - a * C
return math.degrees(lat), math.degrees(lon), alt
def _ecef(lat_deg, lon_deg, alt_km):
a, b = WGS_A, WGS_B
la, lo = math.radians(lat_deg), math.radians(lon_deg)
f = (a - b) / a
e2 = 2 * f - f * f
N = a / math.sqrt(1 - e2 * math.sin(la) ** 2)
return ((N + alt_km) * math.cos(la) * math.cos(lo),
(N + alt_km) * math.cos(la) * math.sin(lo),
(N * (1 - e2) + alt_km) * math.sin(la))
def elevation(sat_lat, sat_lon, sat_alt, obs_lat, obs_lon):
"""Elevation angle (degrees) of a sat above an observer's local horizon."""
sx, sy, sz = _ecef(sat_lat, sat_lon, sat_alt)
ox, oy, oz = _ecef(obs_lat, obs_lon, 0.0)
rx, ry, rz = sx - ox, sy - oy, sz - oz
rng = math.sqrt(rx * rx + ry * ry + rz * rz)
if rng <= 0:
return 0.0
la, lo = math.radians(obs_lat), math.radians(obs_lon)
ux, uy, uz = math.cos(la) * math.cos(lo), math.cos(la) * math.sin(lo), math.sin(la)
dot = (rx * ux + ry * uy + rz * uz) / rng
return math.degrees(math.asin(max(-1.0, min(1.0, dot))))
class Sat:
__slots__ = ("name", "catnr", "rec", "is_recon", "prev_elev")
def __init__(self, name, line1, line2, is_recon):
self.catnr = line1[2:7].strip()
self.name = name or f"SAT {self.catnr}"
self.rec = Satrec.twoline2rv(line1, line2) # handles no_kozai internally
self.is_recon = is_recon
self.prev_elev = None
def geodetic_now(self, dt):
jd, fr = jday(dt.year, dt.month, dt.day, dt.hour, dt.minute,
dt.second + dt.microsecond * 1e-6)
e, r, _v = self.rec.sgp4(jd, fr)
if e != 0: # decayed / propagation error
return None
return eci_to_geodetic(r, gstime(jd + fr))
def build_sats(groups):
sats = []
for name, l1, l2, is_recon in groups:
try:
sats.append(Sat(name, l1, l2, is_recon))
except Exception as e: # a malformed TLE shouldn't kill the set
print(f"[{NAME}] skip TLE '{name or l1[2:7]}': {e}")
return sats
def main():
ap = argparse.ArgumentParser(description="SGP4 satellites -> OSC (the sky's schedule)")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=9000)
ap.add_argument("--interval", type=float, default=2.0)
ap.add_argument("--lat", type=float, default=-27.47, help="venue latitude (run.py retargets this; default matches world_sky's Brisbane)")
ap.add_argument("--lon", type=float, default=153.02, help="venue longitude")
args = ap.parse_args()
if not HAVE_SGP4:
print(f"[{NAME}] sgp4 not installed — `pip install sgp4` to bring the sky in. Exiting.")
sys.exit(0)
sats = build_sats(load_grouped_tles())
if not sats:
# nap before exiting nonzero: run.py revives dead workers ~10s later, and
# without this pause a downed network would mean a Celestrak attempt every
# revive. 15 min keeps us polite and self-healing.
print(f"[{NAME}] no TLEs (no cache and no network) — napping 15 min, then retrying via the supervisor.")
time.sleep(900)
sys.exit(1)
client = SimpleUDPClient(args.host, args.port)
n_recon = sum(1 for s in sats if s.is_recon)
have_recon = n_recon > 0
print(f"[{NAME}] tracking {len(sats)} satellites ({n_recon} recon) from "
f"({args.lat:.2f}, {args.lon:.2f}); emitting /gs/sats/* every {args.interval:g}s")
last_refresh = time.time()
while True:
if time.time() - last_refresh > REFRESH_EVERY: # age-check + refresh TLEs in place
last_refresh = time.time()
fresh = build_sats(load_grouped_tles()) # refetches only if the cache is stale
if fresh:
prev = {s.catnr: s.prev_elev for s in sats} # carry pass-detection state so a
for s in fresh: # refresh mid-pass can't eat the event
s.prev_elev = prev.get(s.catnr)
sats = fresh
have_recon = any(s.is_recon for s in sats)
now = datetime.datetime.now(datetime.timezone.utc)
best_elev, recon_best, overhead = -90.0, -90.0, 0
for s in sats:
geo = s.geodetic_now(now)
if geo is None:
continue
lat, lon, alt = geo
el = elevation(lat, lon, alt, args.lat, args.lon)
if el > best_elev:
best_elev = el
if s.is_recon and el > recon_best:
recon_best = el
if el > OVERHEAD_ELEV:
overhead += 1
if s.prev_elev is not None and s.prev_elev < PASS_ELEV <= el: # rising through 30deg
client.send_message("/gs/sats/pass.event", float(el)) # -> sats.pass.event (impulse)
print(f"[{NAME}] pass: {s.name} rising through {PASS_ELEV:.0f}deg (now {el:.1f})")
s.prev_elev = el
if s.catnr == ISS_CATNR:
client.send_message("/gs/sats/iss_lat", float(lat))
client.send_message("/gs/sats/iss_lon", float(lon))
client.send_message("/gs/sats/best_elev", float(best_elev))
client.send_message("/gs/sats/overhead_count", float(overhead))
if have_recon:
client.send_message("/gs/sats/recon_elev", float(recon_best))
time.sleep(args.interval)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(0)

View File

@ -11,8 +11,6 @@ Emits:
/gs/sky/elev solar elevation in degrees (-90 night .. +90 overhead)
/gs/sky/day 0..1 smooth day/night (0 deep night, 1 broad daylight)
/gs/sky/az solar azimuth in degrees (0=N, 90=E, 180=S, 270=W)
/gs/sky/lat the venue's own latitude (so the console can place
/gs/sky/lon the venue's own longitude "here" on the god's eye map)
"""
from __future__ import annotations
@ -71,7 +69,7 @@ def main():
args = ap.parse_args()
client = SimpleUDPClient(args.host, args.port)
print(f"[sky] emitting /gs/sky/elev|day|az|lat|lon for ({args.lat},{args.lon}) "
print(f"[sky] emitting /gs/sky/elev|day|az for ({args.lat},{args.lon}) "
f"every {args.interval:g}s")
while True:
@ -81,8 +79,6 @@ def main():
client.send_message("/gs/sky/elev", float(elev))
client.send_message("/gs/sky/day", day_fraction(elev))
client.send_message("/gs/sky/az", float(az))
client.send_message("/gs/sky/lat", float(args.lat)) # the venue's own coordinates,
client.send_message("/gs/sky/lon", float(args.lon)) # so the console can place "here" on a map
except Exception as e: # noqa
print(f"[sky] warn: {e}")
time.sleep(args.interval)