From a57fa196ac80b8dd413088959a284bfaede81b5b Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Thu, 16 Jul 2026 09:46:45 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=91=20neargod:=20real=20orbits=20for?= =?UTF-8?q?=20close=20approaches=20=E2=80=94=20CAD=20des=20=E2=86=92=20SBD?= =?UTF-8?q?B=20sbdb.api=20full-prec=20elements=20=E2=86=92=20shared=20Kepl?= =?UTF-8?q?er=20core=20(ephem.smallBodyEcl);=20true=20marker=20+=2025%-opa?= =?UTF-8?q?city=20ellipse=20per=20NEO,=20per-object=20fail-soft=20to=20the?= =?UTF-8?q?=20wave-1=20schematic=20ring,=20honest=20'N=20real=20=C2=B7=20M?= =?UTF-8?q?=20schematic=20=C2=B7=20CAD+SBDB'=20status;=20+sbdb=5Flookup=20?= =?UTF-8?q?upstream;=20+verify=20gate=20(opus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/layers/neos.js | 118 +++++++++++++++++++++++++++++++++++++--------- serve.py | 1 + verify.html | 38 +++++++++++++++ 3 files changed, 135 insertions(+), 22 deletions(-) diff --git a/js/layers/neos.js b/js/layers/neos.js index e7c8ce6..c9e120a 100644 --- a/js/layers/neos.js +++ b/js/layers/neos.js @@ -1,25 +1,48 @@ -// SOLARGOD near-Earth close-approach layer (Stage 5) — the CAD API's ±30-day list -// of close approaches, as highlighted clickable reticles arranged around Earth -// (a "close-approach radar"), colored by miss distance and labelled in lunar -// distances. Off by default; fail-soft (brief §5). NOTE: CAD returns approach -// EVENTS, not orbits — markers are schematic positions around Earth, not tracked -// trajectories; the data (designation, miss distance, date, speed) is real. +// SOLARGOD near-Earth close-approach layer (Stage 5 · wave 2 NEARGOD) — the CAD +// API's ±30-day list of close approaches, each object now drawn at its TRUE +// propagated position with its REAL orbit. CAD gives designations, miss distance, +// date, speed; SBDB's lookup endpoint (sbdb.api, full precision) gives the +// osculating elements, propagated by the SAME shared Kepler core as the belt +// (ephem.smallBodyEcl). Off by default; additive and fail-soft PER OBJECT — a +// lookup that won't resolve falls back to the wave-1 schematic ring for that one +// object, and the status line reports the split honestly (brief §5, Lane N). + +// Parse an SBDB lookup response → {a,e,i,om,w,ma,epoch} (deg/AU/JD) or null. +// Elements arrive as an array of {name, value} STRINGS; epoch is orbit.epoch (JD, +// TDB). Reject non-finite or e≥0.98 (the wave-1 elliptical-only rule, brief §3). +// Exported so verify.html's gate exercises the identical parser. +export function parseSbdbElements(j) { + const o = j && j.orbit; + if (!o || !Array.isArray(o.elements)) return null; + const m = {}; + for (const e of o.elements) m[e.name] = Number(e.value); + const el = { a: m.a, e: m.e, i: m.i, om: m.om, w: m.w, ma: m.ma, epoch: Number(o.epoch) }; + for (const v of Object.values(el)) if (!Number.isFinite(v)) return null; + if (el.e >= 0.98 || !(el.a > 0)) return null; + return el; +} export default function create(ctx) { - const { THREE, scene, CONFIG, lib, ui, ephem, toLocal, makeLabel } = ctx; + const { THREE, scene, CONFIG, lib, ui, scale, ephem, worldGroup, toLocal, makeLabel } = ctx; const LD_AU = 384400 / lib.AU_KM; // one lunar distance in AU + const SBDB_LOOKUP = 'proxy/sbdb_lookup'; // sibling of CONFIG.proxy.* (I don't own config.js) + const N = 256; // orbit samples per NEO const glyph = makeReticle(); const neos = []; let on = false; - const _abs = {}; + const _e = {}, _abs = {}, _tmp = {}; - ui.addLayer('neos', 'Close approaches', false, (v) => { on = v; for (const n of neos) { n.spr.visible = v; n.lbl.obj.visible = v; } }); + ui.addLayer('neos', 'Close approaches', false, (v) => { + on = v; + for (const n of neos) { n.spr.visible = v; n.lbl.obj.visible = v; if (n.line) n.line.visible = v; } + }); ui.setStatus('neos', 'off', 'off'); load(); async function load() { ui.setStatus('neos', 'fetching CAD…', 'warn'); + let rows; try { const now = new Date(ctx.clock.simMs); const fmt = (d) => `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; @@ -27,16 +50,33 @@ export default function create(ctx) { const dmax = fmt(new Date(now.getTime() + 30 * 86400000)); const j = await (await fetch(`${CONFIG.proxy.cad}?date-min=${dmin}&date-max=${dmax}&dist-max=0.05&sort=dist&limit=40`)).json(); const F = j.fields, ix = (k) => F.indexOf(k); - const rows = j.data || []; - rows.forEach((r, i) => build({ + rows = (j.data || []).map((r) => ({ des: r[ix('des')], jd: +r[ix('jd')], cd: r[ix('cd')], dist: +r[ix('dist')], v: +r[ix('v_rel')], h: +r[ix('h')], - }, i, rows.length)); - ui.setStatus('neos', neos.length ? `${neos.length} approaches ±30d · CAD · schematic ring` : 'none in window', on ? 'ok' : 'off'); + })); } catch (err) { - console.warn('[solargod] neos', err.message); + console.warn('[solargod] neos CAD', err.message); ui.setStatus('neos', 'CAD unavailable', 'err'); + return; } + + // Build every marker up-front (schematic ring by default), then upgrade each to + // a true orbit as its SBDB lookup lands. SEQUENTIAL — sbdb.api burst-throttles, + // and the disk cache makes reloads instant (brief §12: don't hammer JPL). + rows.forEach((d, i) => build(d, i, rows.length)); + let real = 0, sched = 0; + for (let i = 0; i < neos.length; i++) { + const n = neos[i]; + try { + const res = await fetch(`${SBDB_LOOKUP}?sstr=${encodeURIComponent(n.d.des)}&full-prec=true`); + const el = parseSbdbElements(await res.json()); + if (el) { attachOrbit(n, el); real++; } else sched++; + } catch (err) { sched++; console.warn('[solargod] neos', n.d.des, err.message); } + ui.setStatus('neos', `fetching ${i + 1}/${neos.length}… ${real} real`, 'warn'); + } + ui.setStatus('neos', + neos.length ? `${real} real orbits · ${sched} schematic · CAD+SBDB` : 'none in window', + on ? 'ok' : 'off'); } function build(d, i, total) { @@ -45,22 +85,56 @@ export default function create(ctx) { const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyph, color: new THREE.Color(col), transparent: true, depthWrite: false })); spr.visible = on; scene.add(spr); const lbl = makeLabel(spr, `${d.des} · ${ld.toFixed(1)} LD`, 'craft-label'); lbl.div.style.color = col; lbl.obj.visible = on; - neos.push({ d, ld, col, spr, lbl, angle: (i / Math.max(1, total)) * Math.PI * 2 }); + neos.push({ d, ld, col, spr, lbl, angle: (i / Math.max(1, total)) * Math.PI * 2, el: null, line: null, geo: null, auPath: null, lastFillP: NaN }); + } + + // Attach the real elliptical orbit (rides worldGroup in absolute view-world, like + // the comets). Refilled from the frozen AU path whenever the MEGA/TRUE P changes. + function attachOrbit(n, el) { + n.el = el; + n.auPath = ephem.orbitPathFromElements(el, N); + n.geo = new THREE.BufferGeometry(); + n.geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((N + 1) * 3), 3)); + n.line = new THREE.LineLoop(n.geo, new THREE.LineBasicMaterial({ color: new THREE.Color(n.col), transparent: true, opacity: 0.25 })); + n.line.frustumCulled = false; n.line.visible = on; + worldGroup.add(n.line); + fillOrbit(n); + } + + function fillOrbit(n) { + const pos = n.geo.attributes.position.array; + for (let k = 0; k <= N; k++) { + _tmp.x = n.auPath[k * 3]; _tmp.y = n.auPath[k * 3 + 1]; _tmp.z = n.auPath[k * 3 + 2]; + scale.viewFromEcl(_tmp, _tmp); + pos[k * 3] = _tmp.x; pos[k * 3 + 1] = _tmp.y; pos[k * 3 + 2] = _tmp.z; + } + n.geo.attributes.position.needsUpdate = true; + n.geo.computeBoundingSphere(); + n.lastFillP = scale.getP(); } return { id: 'neos', - onClockTick() { + onClockTick(simMs, jd) { if (!on) return; - const earth = ctx.bodyWorld.earth; - if (!earth) return; const camDist = ctx.camera.position.length(); + const P = scale.getP(); + const earth = ctx.bodyWorld.earth; const ringR = camDist * 0.06; for (const n of neos) { - _abs.x = earth.x; _abs.y = earth.y; _abs.z = earth.z; - toLocal(_abs, n.spr.position); - n.spr.position.x += Math.cos(n.angle) * ringR; - n.spr.position.y += Math.sin(n.angle) * ringR * 0.5; + if (n.el) { + // true propagated position (same core as the belt) + ephem.smallBodyEcl(n.el, jd, _e); + scale.viewFromEcl(_e, _abs); + toLocal(_abs, n.spr.position); + if (P !== n.lastFillP) fillOrbit(n); // track the MEGA↔TRUE tween + } else if (earth) { + // schematic ring fallback for THIS object alone (wave-1 placement) + _abs.x = earth.x; _abs.y = earth.y; _abs.z = earth.z; + toLocal(_abs, n.spr.position); + n.spr.position.x += Math.cos(n.angle) * ringR; + n.spr.position.y += Math.sin(n.angle) * ringR * 0.5; + } n.spr.scale.setScalar(Math.max(0.02, camDist * 0.012)); } }, diff --git a/serve.py b/serve.py index 9729757..3612098 100644 --- a/serve.py +++ b/serve.py @@ -36,6 +36,7 @@ CACHE_TTL_SEC = 24 * 3600 UPSTREAMS = { "horizons": "https://ssd.jpl.nasa.gov/api/horizons.api", "sbdb": "https://ssd-api.jpl.nasa.gov/sbdb_query.api", + "sbdb_lookup": "https://ssd-api.jpl.nasa.gov/sbdb.api", "cad": "https://ssd-api.jpl.nasa.gov/cad.api", } diff --git a/verify.html b/verify.html index 19ffb6d..c1428b7 100644 --- a/verify.html +++ b/verify.html @@ -196,6 +196,44 @@ await craftCheck('Voyager 1 (-31) @ 2026-07-15', '-31', 2461236.5, { x: -32.07, y: -136.21, z: 98.55 }, 0.05); await jwstCheck(); })(); + + /* PERIHELION-N — NEARGOD close-approach gate. A real NEO's propagated + heliocentric distance to Earth(≈EMB) at its CAD close-approach epoch must + match CAD's miss distance within 25% — generous on purpose: Earth≈EMB, + two-body propagation, CAD dist is geocentric; the point is catching + wrong-by-an-AU bugs, not arcseconds. Exercises the full lane-N pipeline: + CAD des → sbdb.api full-prec elements → parseSbdbElements → shared Kepler + core (ephem.smallBodyEcl), the identical code path the layer draws. */ + import { parseSbdbElements } from './js/layers/neos.js'; + async function neoCloseApproachGate() { + const label = 'NEO propagated miss vs CAD (real orbit)'; + const tr = pendingRow(label); + try { + const LD_AU = 384400 / lib.AU_KM; + // Fixed window anchored at the sim's default date so the gate is + // deterministic and always populated (past close approaches stay in CAD). + const cad = await (await fetch(`${CONFIG.proxy.cad}?date-min=2026-06-16&date-max=2026-08-15&dist-max=0.05&sort=dist&limit=40`)).json(); + const F = cad.fields, ix = (k) => F.indexOf(k); + const rows = cad.data || []; + if (!rows.length) throw new Error('CAD returned no rows'); + // Pick the largest-miss approach: 25% of a bigger distance is the most + // robust absolute tolerance (least sensitive to EMB≈Earth + two-body drift). + let best = null; + for (const r of rows) { const dist = +r[ix('dist')]; if (!best || dist > best.dist) best = { des: r[ix('des')], jd: +r[ix('jd')], dist }; } + const j = await (await fetch(`proxy/sbdb_lookup?sstr=${encodeURIComponent(best.des)}&full-prec=true`)).json(); + const el = parseSbdbElements(j); + if (!el) throw new Error(`no elliptical elements for ${best.des}`); + const neo = ephem.smallBodyEcl(el, best.jd, {}); + const emb = ephem.helioEcl('earth', best.jd, {}); + const d = Math.hypot(neo.x - emb.x, neo.y - emb.y, neo.z - emb.z); + const relErr = Math.abs(d - best.dist) / best.dist; + tr.remove(); + row(label, relErr < 0.25, + `${best.des} @ jd ${best.jd.toFixed(3)}: propagated ${(d / LD_AU).toFixed(2)} LD vs CAD ${(best.dist / LD_AU).toFixed(2)} LD — err ${(relErr * 100).toFixed(2)}% (tol 25%)`); + } catch (err) { tr.remove(); row(label, false, `neos/SBDB error: ${err.message}`); } + finish(); + } + neoCloseApproachGate();