SimulationLab ladder
Level 9 of 10 · Astrodynamics · space safety
VERIFIEDTwo independent orbit codes (python-sgp4, satellite.js) agree to 0.25 m. Top conjunction: a 75 m miss at 15.1 km/s.

L09 · Orbital Traffic Control

The real satellite catalogue, propagated, screened for near-misses and pushed 200 years into a Kessler future, all shown in a live 3D ops console in the browser.

Orbital Traffic Control: 18,677 tracked objects propagated live in the browser

Screenshot from a headless Chromium capture; index.html is the live version.

Open index.html by double-clicking it. It is a single file with no server and no build step, and runs on an integrated GPU. The console shows 18,677 real objects (all 16,001 active satellites plus the debris clouds of four historic breakups), each propagated live by SGP4 in the browser. You can scrub a 24 h timeline, click any dot for its orbit and live state, and click a conjunction to fly to the two objects and watch them pass each other with a live separation readout.


1. The question

Right now, which pairs of objects in low Earth orbit are about to pass dangerously close to each other? How likely is a collision, given how poorly we actually know where things are? And if we keep launching the way we do, does LEO tip into a self-sustaining collision cascade (the Kessler syndrome)?

Three time scales, three models:

Horizon Question Model
seconds When exactly is the closest approach, and how close? SGP4 + TCA root-finding
24 hours Which of ~174 million pairs come within 10 km? vectorised SGP4 + KD-tree sieve
200 years Does the debris population run away? shell source-sink Monte Carlo + NASA breakup model

2. The model

2.1 Propagation: SGP4/SDP4 from two-line elements

CelesTrak publishes mean orbital elements (TLEs) fitted by the US Space Force to radar and optical tracking. SGP4 is the analytical theory those elements were fitted with. It covers secular and periodic J2 to J4 effects and a drag term via B*, and switches to SDP4 with luni-solar and resonance terms for periods over 225 min. The output is position and velocity in the TEME frame. A TLE is only meaningful inside SGP4: feeding it to a numerical integrator gives worse answers, not better ones.

2.2 Screening: KD-tree sieve, then time of closest approach (TCA)

All N = 18,677 objects are propagated together (SatrecArray, C++) every Δt = 20 s for 24 h, which is 80.7 million states. At each sample:

  1. Coarse sieve: a KD-tree fixed-radius query returns every pair with |Δr| < R, where R = d + vmaxΔt/2, d = 10 km and vmax = 16 km/s. No pair that can close within d during ±Δt/2 can escape this radius.
  2. Linear filter: τ = −Δr·Δv/|Δv|², clamped to ±Δt/2. Keep the pair if |Δr + Δv τ| < d.
  3. Group: consecutive-step hits of one pair form one encounter. Pairs that stay within d for more than 10 min, or have vrel < 0.1 km/s, are co-orbiting: docked vehicles, formations, freshly deployed trains. They are excluded, because the short-encounter Pc model does not apply to them.
  4. TCA, vectorised: second-order relative motion with two-body gravity, D(τ) = Δr + Δv τ + ½Δa τ², then Newton iterations on D·D′ = 0 for all encounters at once.
  5. TCA, exact, where it matters: every encounter under 1 km is re-solved by bounded Brent minimisation of the true SGP4 separation |r₂(t) − r₁(t)|.

2.3 Collision probability (Pc)

In the encounter plane (normal to vrel), the combined position uncertainty C = C₁ + C₂ is projected to 2×2, and

$$P_c=\iint_{|\mathbf x|<R_{HB}} \frac{1}{2\pi\sqrt{|C|}}\exp\!\left(-\tfrac12(\mathbf x-\mathbf m)^\top C^{-1}(\mathbf x-\mathbf m)\right)dA$$

This is the Foster/Alfano short-encounter model. m is the miss vector and RHB the combined hard-body radius. The integral is evaluated with a 16×32 Gauss-Legendre polar quadrature.

Stated covariance assumption. A public TLE carries no covariance, so one has to be assumed. Each object gets independent Gaussian errors, diagonal in its own radial/in-track/cross-track (RTN) frame: σR = 0.1 km, σT = 0.5 km + 1.5 km/day × |TCA − TLE epoch|, σN = 0.2 km. These are TLE-grade values, of the order reported by Flohrer et al. (2008). Hard-body radii are 5 m for constellation satellites, 3 m for other payloads, 50 m for crewed stations and 0.5 m for debris. The page also reports the covariance-free worst case, Pcmax = RHB²/(e d²) (Alfano 2005), which bounds Pc over any isotropic covariance.

2.4 Kessler: shell source-sink Monte Carlo

LEO from 200 to 2000 km is split into 36 shells of 50 km. The state per shell is S (active payloads), D (derelict intact objects: dead payloads and rocket bodies) and N (fragments ≥ 10 cm). The initial state comes from today's SATCAT: 16,079 / 2,649 / 9,961.

The cloud-decay validation integrates each fragment's (a, e) with King-Hele's orbit-averaged drag: da/dt = −Bρp√(μa) e−c[I₀(c) + 2eI₁(c)] and de/dt = −Bρp√(μ/a) e−c[I₁ + ½e(I₀ + I₂)], where c = ae/H.


3. M&S concepts you'll learn here


4. Tech stack (cumulative; ★ = new in this level)

Tool Role here
Python 3.12, NumPy vectorised everything: sieve maths, Pc quadrature, Monte Carlo state arrays
SciPy optimize.minimize_scalar (Brent TCA), special.i0e/i1e (King-Hele drag), ★ spatial.cKDTree (fixed-radius pair search)
pandas, requests SATCAT join and history, polite cached downloads from CelesTrak and jsDelivr
Matplotlib the five analysis figures (dark theme)
multiprocessing 4 workers, each propagating and sieving a slice of the 24 h window
★ sgp4 (python-sgp4, Vallado C++) SatrecArray: 18,677 objects × 4,320 epochs in compiled code (≈0.2 µs/state)
★ Three.js 0.170 (WebGL, CDN importmap) globe with day/night + city-lights shader, atmosphere, 18,677-point custom shader, trails, markers
★ satellite.js 5.0 SGP4 in the browser (same Vallado code), live propagation of the whole catalogue
★ Node 22 headless cross-check of satellite.js against python-sgp4 (crosscheck.mjs)

Numba (L05/L06) is part of the stack but isn't needed here. The hot loop (SGP4) is already compiled C++, and the Monte Carlo is vectorised over runs and shells.


5. Industry & scientific tools

Tool What it is Who uses it How this project maps to it
Space-Track.org (US Space Force, 18/19 SDS) Authoritative public catalogue: GP/TLE, SATCAT, and CDMs (conjunction data messages) for registered operators Every operator; CelesTrak mirrors its GP data Our input data (via CelesTrak). A real CDM carries the actual covariance we had to assume
CelesTrak (+ SOCRATES) Curated GP/TLE groups, SATCAT, and SOCRATES, a public TLE-based conjunction screening report Researchers, educators, amateur trackers sim.py is a mini-SOCRATES. Compare our top events with theirs
NASA CARA (+ open-source CARA Analysis Tools) NASA's conjunction assessment and risk analysis for its own fleet: 2-D/3-D Pc, Monte Carlo Pc, manoeuvre planning NASA missions; its tools are public on GitHub pc_circle is the textbook 2-D Pc. CARA adds 3-D Pc for slow encounters and covariance realism
Ansys STK (+ STK SatPro/CAT, ODTK) Commercial astrodynamics environment: propagators (SGP4, HPOP), access, close-approach tool, orbit determination Defence, primes, satellite operators Our screening is STK's "Advanced CAT" in about 200 lines. STK would use HPOP and real covariances
NASA GMAT Open-source mission design and trajectory optimisation, high-fidelity force models, scripting NASA flight projects, universities Use it to design the avoidance manoeuvre after this tool flags an event
Orekit (Java, + Python wrapper) Open-source flight-dynamics library: frames, numerical and analytical propagators, OD, events CNES, ESA, commercial operators Proper TEME→GCRF→ITRF frames, numerical propagation and orbit determination beyond SGP4
poliastro → hapsira (Python) Pythonic astrodynamics (Lambert, manoeuvres, plotting); poliastro was archived in 2023 and hapsira is the maintained fork Students, researchers Two-body/Kepler maths like our refine_2body, plus manoeuvre planning
CesiumJS Open-source WebGL globe with time-dynamic CZML, terrain, 3D Tiles Space operations dashboards, AGI/Ansys, NASA What index.html does with Three.js; Cesium adds precise frames, terrain and CZML time series
LeoLabs Commercial phased-array radar network plus conjunction and collision-avoidance service Commercial LEO operators, insurers Their data would replace our TLE covariance with measured, much smaller uncertainties
NASA ORDEM Engineering debris flux model (sizes down to 10 µm) for spacecraft shielding design NASA/industry spacecraft designers The "environment" our shells approximate, at a snapshot in time
NASA LEGEND 3-D debris evolution model (LEO-to-GEO) with the NASA SBM, used for long-term projections NASA ODPO; IADC studies Our Kessler MC is a 1-D, shell-averaged LEGEND. LEGEND propagates each object
ESA MASTER / DELTA ESA's flux model (MASTER) and long-term evolution model (DELTA, now DELTA 4) ESA Space Debris Office, IADC European counterparts of ORDEM/LEGEND
MIT MOCAT Source-sink (SSEM) and Monte Carlo orbital-capacity models Academic policy studies Closest relative of our shell model (same S/D/N species and α avoidance factors)
ESA DRAMA / NASA DAS Mission compliance tools: lifetime, re-entry casualty risk, 25-/5-year rule Every mission applying for a licence Our drag and lifetime maths; the PMD fraction in our scenarios is what these tools check

6. Results

Catalogue fetched from CelesTrak on 2026-09-18: 18,677 objects (16,001 active payloads plus 2,676 catalogued fragments from FY-1C, Cosmos 2251, Iridium 33 and Cosmos 1408). The screening window is 2026-09-17 23:00 UTC + 24 h.

6.1 The catalogue and where objects get close

catalogue

Today's LEO is dominated by one 50 km shell: 9,700 active satellites between 450 and 500 km, almost all Starlink. Classic debris lives higher, at 700 to 1000 km, where drag is weak. Over 90 % of all close approaches happen in that one Starlink shell (right panel; the axis is symlog).

6.2 Screening: 24 hours of traffic

conjunctions

value
SGP4 states evaluated 80.7 M (18,677 objects × 4,320 steps)
sieve hits (pair-steps) 595,561
encounters < 10 km 270,442 (213,056 Starlink × Starlink)
encounters < 1 km 1,458
encounters with Pc > 1e-4 / > 1e-5 198 / 3,008
co-orbiting pairs excluded 558

That is about 40 close approaches under 10 km per Starlink satellite per day. At d < 10 km the cumulative count follows N(<d) ∝ d² almost exactly (left panel), the signature of random crossing geometry. It steepens above about 3 km, which is consistent with constellation shells being separated by a few km in altitude.

The Pc cloud (middle) is capped near 4e-4. With TLE-grade σ of a few hundred metres and a 10 m hard body, Pc can never be large, however close the nominal miss. This is the dilution effect: poor knowledge caps the computed risk. The orange line is Pcmax.

6.3 The top event

top conjunction

COSMOS 2581 × FIRESAT3: TCA 2026-09-18 00:39:54.065 UTC at 595 km. Miss 75 m (R/T/N = −70 / 0 / −27 m), vrel = 15.10 km/s, nearly head-on. Pc = 4.0e-4 under the stated covariance, Pcmax = 2.4e-3. The same FireSat satellite passes the other two members of the Cosmos 2581/2582/2583 group within 4 seconds (211 m and 135 m, ranks 3 and 5). Run #2 is STARLINK-4566 × CUBESX-HSE 3 at 37 m. In the web page, click row 1 (or open index.html?conj=0) to watch it:

conjunction fly-to in the console

6.4 Kessler outlook

kessler

Scenario (200 runs each) LEO objects ≥ 10 cm in 2226, median (5 to 95 %) catastrophic collisions by 2226
No new launches 16,586 (11,545 to 22,582) 16
Steady fleet (16k), 90 % disposal 111,538 (94,520 to 127,961) 183
Mega-constellation boom (65k), 90 % disposal 59.2 M (runaway) 21,618
Boom + 99 % disposal + ADR 5/yr 824,180 (779,258 to 874,293) 1,128

Today's LEO holds 28,689 objects (SATCAT, mean altitude 200 to 2000 km).

6.5 Verification and validation (actual numbers from the run)

breakups

Check Target Result
satellite.js vs python-sgp4, 60 objects (all 10 classes incl. SDP4 deep space) × 5 epochs (−12 h to +72 h) < 1 km max 0.247 m, rms 0.138 m (node); the page repeats it live: 0.25 m
Synthetic encounter (equatorial vs polar circular orbits meeting at a node, miss = 0.5 km exactly) hidden among 3,000 random objects found; TCA < 10 ms, miss < 0.1 m found; Brent: 0.024 ms, 0.065 mm; 2-body Newton: 0.032 ms, < 0.001 mm
Pc quadrature vs exact centred isotropic result 1 − exp(−R²/2σ²) < 1e-6 relative 3.3e-16
Sieve step-size convergence: Δt = 1 s re-screen of the first 15 min 0 missed at Δt = 20 s 2,702 encounters, 0 missed
Vectorised 2-body TCA vs exact SGP4+Brent on all 1,460 sub-km events a few m median < 0.1 m, max 2.4 m
Monte Carlo object bookkeeping (initial + launched + created − re-entered − disposed − destroyed − removed) exact 0 imbalance
NASA SBM fragment count vs SATCAT: Iridium 33 × Cosmos 2251 (the one true intact-intact collision) within ×2.5 1,211 vs 2,370 (×0.51)
SBM vs SATCAT: FY-1C / Cosmos 1408 (ASAT tests) (reported) 843 vs 3,531 (×0.24, miss) / 1,400 vs 1,806 (×0.78)
Fraction of fragments still in orbit today, model vs SATCAT within 0.35 FY-1C 0.72 vs 0.66 · Iridium-Cosmos 0.58 vs 0.31 (miss) · Cosmos 1408 0.03 vs 0.00

Honest misses. (1) The SBM under-predicts FY-1C by 4×. This is a known issue: the ASAT kinetic-kill vehicle fragmented the target far more finely than the SBM's collision branch predicts, and the catalogue includes objects smaller than 10 cm. (2) The static atmosphere does not reproduce the Iridium-Cosmos cloud. The real curve drops in steps during the 2014 and 2024 to 2025 solar maxima. The mean CIRA-72 density cannot do that, so the model keeps about twice as many fragments. A solar-cycle-driven density (NRLMSISE-00 with F10.7) is the fix.


7. Run it

# from SimulationLab/
uv run python L09_orbital_traffic_control/sim.py      # analytics + figures + data.js
# then double-click L09_orbital_traffic_control/index.html
node L09_orbital_traffic_control/crosscheck.mjs       # optional: standalone JS-vs-Python SGP4 check

Runtime on the 8-core laptop: about 2 min when the machine is quiet (the 24 h screening took 45 s on 4 cores). The final documented run took 16 min (screening 544 s) because other simulations were saturating all 16 threads; expect anything in between. The first run downloads about 10 MB (CelesTrak TLE groups, SATCAT, satellite.js) into data/ and never again; delete data/ to refresh the catalogue. Everything else is deterministic (fixed seeds).

Web console controls: drag to orbit, scroll to zoom, click a dot to inspect it (with trail and live altitude/speed/lat/lon). / searches by name or NORAD id, space pauses, and , / . change the time warp (1× to 1800×). Click a conjunction row or a timeline tick to fly to the encounter; Earth view resets the camera. The legend toggles layers. index.html?conj=0 opens directly on the top event.


8. Code tour

File / function What it does
sim.py · load_catalog cached CelesTrak TLE groups + SATCAT join (owner, launch date), categorisation
sim.py · screen_step KD-tree sieve + linear closest-approach filter for one epoch
sim.py · _screen_chunk / screen worker: SatrecArray propagation of a time slice, then sieve each step; Pool(4) over slices
sim.py · group_encounters merges consecutive hits of a pair into one encounter, flags co-orbiting pairs
sim.py · refine_2body vectorised 2nd-order Newton TCA for all encounters
sim.py · refine_tca Brent TCA on the exact SGP4 separation (sub-km events, synthetic test)
sim.py · cov_eci, encounter_plane, pc_circle covariance assumption → encounter plane → 2-D Pc quadrature
sim.py · verify_*, convergence_check, node_crosscheck the verification table above
sim.py · export_js writes data.js (window.SAT_DATA: TLEs, metadata, top 170 conjunctions, Kessler bands, reference states)
kessler.py · sbm_count, sbm_fragments NASA Standard Breakup Model (counts, size, A/M, Δv)
kessler.py · fragment_orbits, decay_cloud, breakup_validation fragment clouds from Δv kicks; King-Hele decay; comparison with SATCAT history
kessler.py · initial_state, spread_kernel, scenarios, run_mc shell Monte Carlo (collisions, drag, disposal, launches, ADR)
figures.py the five PNGs
crosscheck.mjs node: loads data.js + satellite.js, recomputes the reference states
index.html the console: Three.js scene, keyframed SGP4 (satellite.js) with cubic Hermite interpolation, picking, search, conjunction list, timeline, encounter-plane SVG, Kessler chart

How the browser keeps 18,677 objects at 60 fps: satellite.js SGP4 costs about 2 µs per object, so the whole catalogue takes about 40 ms, too slow to run every frame. The page propagates keyframes (every 60 to 300 s of sim time, depending on warp) and spreads the next keyframe's work over the frames in between. Each frame it evaluates a cubic Hermite spline from the positions and velocities at the two surrounding keyframes. Error is well under 1 km at 300 s spacing, and selected objects are always propagated exactly.


9. Simplifications & ceilings


10. Go further

  1. Real CDMs: register on Space-Track, pull the public CDMs for one day and compare their Pc with ours for the same pairs. How much does real covariance change the ranking?
  2. Solar-cycle drag: swap atmosphere() for NRLMSISE-00 (pymsis) driven by historical F10.7. Does the Iridium-Cosmos survival curve now show the 2014 and 2024 steps?
  3. Avoidance manoeuvre: for the top event, compute the smallest along-track Δv (hours before TCA) that raises the miss to 1 km or drops Pc below 1e-6. Check it in GMAT or Orekit.
  4. 3-D / Monte Carlo Pc for slow Starlink-Starlink encounters (sample both covariances, propagate, count hits). Compare with NASA CARA's open-source tools.
  5. Rebuild it in industry tools: redo the screening in STK Advanced CAT or the Orekit event detectors, and the globe in CesiumJS with CZML exported from sim.py. Rerun the Kessler scenarios in MOCAT-SSEM and compare the runaway thresholds.

11. References

Every output

Source code

crosscheck.mjs 20 lines
// Cross-check: satellite.js (the browser propagator) vs python-sgp4 reference states in data.js.
// Run by sim.py; standalone: node L09_orbital_traffic_control/crosscheck.mjs
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';

const satellite = createRequire(import.meta.url)('./data/satellite.min.js');
globalThis.window = {};
new Function(readFileSync(new URL('./data.js', import.meta.url), 'utf8'))();
const D = window.SAT_DATA, X = D.xcheck;
let n = 0, max = 0, sum2 = 0;
X.idx.forEach((k, m) => {
  const rec = satellite.twoline2satrec(D.l1[k], D.l2[k]);
  X.ms.forEach((ms, q) => {
    const p = satellite.propagate(rec, new Date(ms)).position, ref = X.pos[m][q];
    const d = Math.hypot(p.x - ref[0], p.y - ref[1], p.z - ref[2]);
    n++; sum2 += d * d; max = Math.max(max, d);
  });
});
console.log(JSON.stringify({ n, max_km: max, rms_km: Math.sqrt(sum2 / n) }));
figures.py 158 lines
"""Matplotlib figures for L09 (dark 'ops console' theme)."""
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import LogNorm
from matplotlib.patches import Circle, Ellipse

import kessler as K

BG, FG, MUTED, GRID = "#0b0f17", "#e6e9ef", "#8a93a6", "#1f2633"
SLOTS = ["#3987e5", "#d95926", "#199e70", "#c98500"]   # validated dark categorical slots 1-4
plt.rcParams.update({
    "figure.facecolor": BG, "axes.facecolor": BG, "savefig.facecolor": BG, "axes.edgecolor": GRID,
    "axes.labelcolor": FG, "xtick.color": MUTED, "ytick.color": MUTED, "text.color": FG, "axes.grid": True,
    "grid.color": GRID, "grid.linewidth": 0.6, "axes.spines.top": False, "axes.spines.right": False,
    "font.size": 10, "axes.titlesize": 11, "axes.titleweight": "bold", "legend.frameon": False})


def _save(fig, out, name):
    fig.savefig(out / name, dpi=150, bbox_inches="tight")
    plt.close(fig)


def fig_catalog(kess, ev, out):
    init = kess["init"]
    fig, (a, b) = plt.subplots(1, 2, figsize=(12, 5.2), sharey=True, gridspec_kw=dict(width_ratios=[1.4, 1]))
    left = np.zeros(K.K)
    for (key, label), c in zip([("S", "Active payloads"), ("D", "Derelict payloads + rocket bodies"),
                                ("N", "Debris fragments")], SLOTS):
        a.barh(K.H, init[key], left=left, height=K.DH - 4, color=c, label=f"{label} ({init[key].sum():,})")
        left += init[key]
    a.set(xscale="symlog", xlabel="objects per 50 km shell (symlog)", ylabel="mean altitude [km]",
          title="Who lives where: the LEO catalogue (SATCAT, today)")
    a.legend(loc="lower right")
    n = np.histogram(ev.alt, bins=K.EDGES)[0]
    b.barh(K.H, n, height=K.DH - 4, color=SLOTS[0])
    b.set(xscale="symlog", xlabel="close approaches < 10 km in 24 h (symlog)", title="...and where they get close")
    fig.tight_layout()
    _save(fig, out, "catalog_altitude.png")


def fig_conjunctions(ev, stats, out):
    fig, ax = plt.subplots(1, 3, figsize=(15, 4.8))
    d = np.sort(ev.miss.to_numpy())
    n = np.arange(1, len(d) + 1)
    ax[0].loglog(d, n, color=SLOTS[0], lw=2, label="screened encounters")
    k = n[len(n) // 2] / d[len(d) // 2] ** 2
    ax[0].loglog(d, k * d ** 2, color=MUTED, ls="--", lw=1.2, label=r"$N \propto d^2$ (random geometry)")
    ax[0].set(xlabel="miss distance d [km]", ylabel="encounters with miss < d (24 h)", title="Cumulative close approaches")
    ax[0].legend(loc="upper left")
    pc = np.maximum(ev.pc.to_numpy(), 1e-12)
    sc = ax[1].scatter(ev.miss, pc, c=ev.vrel, cmap="Blues", vmin=0, vmax=16, s=6, edgecolors="none", rasterized=True)
    dd = np.logspace(-2, 1, 100)
    ax[1].loglog(dd, np.minimum((0.010 ** 2) / (np.e * dd ** 2), 1), color=SLOTS[1], lw=1.5,
                 label="max Pc, HBR 10 m (any covariance)")
    ax[1].axhline(1e-4, color=MUTED, ls=":", lw=1)
    ax[1].text(9, 2e-3, "Pc = 1e-4: typical\nmanoeuvre threshold", color=MUTED, fontsize=8, ha="right",
               bbox=dict(facecolor=BG, edgecolor="none", pad=1))
    ax[1].set(xscale="log", yscale="log", ylim=(1e-12, 1), xlabel="miss distance [km]",
              ylabel="collision probability Pc", title="Pc under the stated TLE covariance")
    ax[1].legend(loc="upper right", fontsize=8)
    fig.colorbar(sc, ax=ax[1], label="relative speed [km/s]")
    pairs = ev.pair.value_counts().head(8)[::-1]
    ax[2].barh(range(len(pairs)), pairs.to_numpy(), color=SLOTS[0], height=0.7)
    ax[2].set_yticks(range(len(pairs)), pairs.index, fontsize=8)
    ax[2].set(xscale="log", xlabel="encounters < 10 km (log)", title="Who meets whom")
    for y, v in enumerate(pairs.to_numpy()):
        ax[2].text(v, y, f" {v}", va="center", color=MUTED, fontsize=8)
    ax[2].grid(axis="y", visible=False)
    fig.tight_layout()
    _save(fig, out, "conjunctions.png")


def fig_top(ev, cat, sats, jd0, fr0, t0, out):
    import datetime as dt
    r = ev.iloc[0]
    ts = r.tca + np.linspace(-60, 60, 2401)
    d = [np.linalg.norm(np.subtract(sats[r.j].sgp4(jd0, fr0 + t / 86400)[1], sats[r.i].sgp4(jd0, fr0 + t / 86400)[1]))
         for t in ts]
    fig, (a, b) = plt.subplots(1, 2, figsize=(12, 5))
    a.plot(ts - r.tca, d, color=SLOTS[0], lw=2)
    a.scatter([0], [r.miss], color=SLOTS[1], s=40, zorder=3)
    a.text(3, r.miss, f"TCA {t0 + dt.timedelta(seconds=float(r.tca)):%H:%M:%S.%f}"[:-3] + f" UTC\nmiss {r.miss * 1e3:.0f} m",
           color=FG, va="center")
    a.set(xlabel="time from TCA [s]", ylabel="separation [km]", yscale="log",
          title=f"{cat.name[r.i]}  x  {cat.name[r.j]}")
    C = np.array([[r.c2[0], r.c2[1]], [r.c2[1], r.c2[2]]]) * 1e6     # m^2
    w, V = np.linalg.eigh(C)
    ang = np.degrees(np.arctan2(V[1, 1], V[0, 1]))
    for k in (1, 2, 3):
        b.add_patch(Ellipse((0, 0), 2 * k * np.sqrt(w[1]), 2 * k * np.sqrt(w[0]), angle=ang, fill=False,
                            color=SLOTS[0], lw=1.2, alpha=1 - 0.25 * k, label=f"{k}-sigma" if k else None))
    b.add_patch(Circle((r.miss * 1e3, 0), r.hbr_m, color=SLOTS[1], label=f"hard body r = {r.hbr_m:.0f} m"))
    b.scatter([r.miss * 1e3], [0], marker="+", color=SLOTS[1], s=120, label=f"miss vector {r.miss * 1e3:.0f} m")
    lim = 3.3 * np.sqrt(w[1])
    b.set(xlim=(-lim, lim), ylim=(-lim, lim), aspect="equal", xlabel="encounter-plane x (along miss) [m]",
          ylabel="encounter-plane y [m]", title=f"Encounter plane: Pc = {r.pc:.2e}, v_rel = {r.vrel:.2f} km/s")
    b.legend(loc="upper left", fontsize=8)
    fig.tight_layout()
    _save(fig, out, "top_conjunction.png")


def fig_kessler(kess, out):
    y0 = kess["year0"]
    fig, ax = plt.subplots(2, 2, figsize=(13, 9))
    for (s, res), c in zip(kess["results"], SLOTS):
        tot = res["S"] + res["D"] + res["N"]
        yrs = y0 + np.arange(len(tot))
        lo, med, hi = np.percentile(tot, [5, 50, 95], axis=1)
        ax[0, 0].fill_between(yrs, lo, hi, color=c, alpha=0.2, lw=0)
        ax[0, 0].plot(yrs, med, color=c, lw=2, label=s["name"])
        cl, cm, ch = np.percentile(res["cat"], [5, 50, 95], axis=1)
        ax[0, 1].fill_between(yrs, np.maximum(cl, 0.5), np.maximum(ch, 0.5), color=c, alpha=0.2, lw=0)
        ax[0, 1].plot(yrs, np.maximum(cm, 0.5), color=c, lw=2, label=s["name"])
    ax[0, 0].set(yscale="log", xlabel="year", ylabel="LEO objects >= 10 cm", title="LEO population, median and 5-95% band")
    ax[0, 0].legend(fontsize=8, loc="upper left")
    ax[0, 1].set(yscale="log", xlabel="year", ylabel="cumulative catastrophic collisions",
                 title="Catastrophic collisions (cumulative)")
    for a, k in ((ax[1, 0], 0), (ax[1, 1], 2)):
        s, res = kess["results"][k]
        m = np.median(res["Nshell"], axis=1).T / (K.VOL[:, None] / 1e9)   # objects per 10^9 km^3
        im = a.pcolormesh(y0 + np.arange(m.shape[1] + 1) - 0.5, K.EDGES, np.maximum(m, 1e-1), cmap="Blues_r",
                          norm=LogNorm(1e-1, max(m.max(), 10)), shading="flat")
        fig.colorbar(im, ax=a, label="spatial density [objects / 1e9 km^3]")
        a.set(xlabel="year", ylabel="altitude [km]", title=f"Where the debris lives: {s['name']}")
        a.grid(False)
    fig.tight_layout()
    _save(fig, out, "kessler.png")


def fig_breakups(kess, out):
    fig, (a, b) = plt.subplots(1, 2, figsize=(12, 4.8), gridspec_kw=dict(width_ratios=[1.5, 1]))
    for v, c in zip(kess["validation"], SLOTS):
        a.plot(v["years"], v["cat_curve"], color=c, lw=2, label=f"{v['name']}: SATCAT")
        a.plot(np.arange(len(v["model_curve"])), v["model_curve"], color=c, lw=1.5, ls="--", label="model (SBM + drag)")
    a.set(xlabel="years since breakup", ylabel="fraction of fragments still in orbit",
          title="Fragment clouds decaying: model vs real catalogue")
    a.legend(fontsize=8)
    x = np.arange(len(kess["validation"]))
    b.bar(x - 0.2, [v["n_sbm"] for v in kess["validation"]], 0.38, color=SLOTS[0], label="NASA SBM (>= 10 cm)")
    b.bar(x + 0.2, [v["n_catalog"] for v in kess["validation"]], 0.38, color=SLOTS[1], label="catalogued (SATCAT)")
    b.set_xticks(x, [v["name"].split(" (")[0].replace(" x ", "\nx ") for v in kess["validation"]], fontsize=8)
    b.set(ylabel="fragments", title="Fragment counts")
    b.legend(fontsize=8)
    b.grid(axis="x", visible=False)
    fig.tight_layout()
    _save(fig, out, "breakup_validation.png")


def make_all(cat, satcat, ev, sats, jd0, fr0, t0, kess, stats, out):
    fig_catalog(kess, ev, out)
    fig_conjunctions(ev, stats, out)
    fig_top(ev, cat, sats, jd0, fr0, t0, out)
    fig_kessler(kess, out)
    fig_breakups(kess, out)
kessler.py 262 lines
"""Kessler syndrome: shell-based LEO debris source-sink Monte Carlo.

Physics pieces (all vectorised NumPy):
  * NASA Standard Breakup Model (Johnson et al. 2001): fragment counts, sizes, A/M and delta-v.
  * Drag decay with a piecewise-exponential atmosphere (Vallado Table 8-4) and King-Hele
    orbit-averaged density for eccentric fragments.
  * Gas-kinetic collision rates per 50 km shell, launch / post-mission-disposal / ADR scenarios.
Validated against real fragment clouds in the SATCAT (FY-1C, Iridium-Cosmos, Cosmos 1408).
"""
import numpy as np
import pandas as pd
from scipy.special import i0e, i1e

RE, MU = 6378.137, 398600.4418          # km, km^3/s^2
YEAR = 365.25 * 86400.0
EDGES = np.arange(200.0, 2001.0, 50.0)   # shell edges, km altitude
H = 0.5 * (EDGES[:-1] + EDGES[1:])
DH = 50.0
K = len(H)
VOL = 4 / 3 * np.pi * ((RE + EDGES[1:]) ** 3 - (RE + EDGES[:-1]) ** 3)  # km^3
V_REL = 10.0                             # km/s, mean LEO impact speed
CD = 2.2

# Vallado (2013) Table 8-4 exponential atmosphere: base alt [km], density [kg/m^3], scale height [km]
ATM = np.array([
    [100, 5.297e-7, 5.877], [110, 9.661e-8, 7.263], [120, 2.438e-8, 9.473], [130, 8.484e-9, 12.636],
    [140, 3.845e-9, 16.149], [150, 2.070e-9, 22.523], [180, 5.464e-10, 29.740], [200, 2.789e-10, 37.105],
    [250, 7.248e-11, 45.546], [300, 2.418e-11, 53.628], [350, 9.518e-12, 53.298], [400, 3.725e-12, 58.515],
    [450, 1.585e-12, 60.828], [500, 6.967e-13, 63.822], [600, 1.454e-13, 71.835], [700, 3.614e-14, 88.667],
    [800, 1.170e-14, 124.64], [900, 5.245e-15, 181.05], [1000, 3.019e-15, 268.00]])


def atmosphere(h):
    """Density [kg/m^3] and scale height [km] at altitude h [km] (above 1000 km: last layer extended)."""
    k = np.clip(np.searchsorted(ATM[:, 0], h, side="right") - 1, 0, len(ATM) - 1)
    h0, r0, sh = ATM[k].T
    return r0 * np.exp(-(h - h0) / sh), sh


def dadt_circ(h, B):
    """Circular-orbit decay rate [km/yr] = rho B sqrt(mu a), B = C_D A/m [m^2/kg]."""
    rho, _ = atmosphere(h)
    return rho * B * np.sqrt(MU * 1e9 * (RE + h) * 1e3) * YEAR / 1e3


# ---------------------------------------------------------------- NASA standard breakup model
def sbm_count(m_target, m_proj, v_kms, lc=0.1):
    """Fragments >= lc [m]. Catastrophic when projectile specific energy > 40 J/g."""
    e_spec = 0.5 * m_proj * (v_kms * 1e3) ** 2 / (m_target * 1e3)      # J/g
    M = np.where(e_spec >= 40, m_target + m_proj, m_proj * v_kms ** 2)
    return 0.1 * M ** 0.75 * lc ** -1.71


def sbm_fragments(n, rng, lc_min=0.1, lc_max=1.0):
    """Sample n collision fragments: characteristic length Lc [m], A/M [m^2/kg], |dv| [km/s]."""
    u = rng.random(n)   # truncated power law N(>L) ~ L^-1.71
    lc = lc_min * (1 - u * (1 - (lc_min / lc_max) ** 1.71)) ** (-1 / 1.71)
    lam = np.log10(lc)  # spacecraft-fragment A/M bimodal normal (Lc > 11 cm branch)
    alpha = np.clip(0.3 + 0.4 * (lam + 1.2), 0, 1)
    mu1, s1 = np.clip(-0.6 - 0.318 * (lam + 1.1), -0.95, -0.6), np.clip(0.1 + 0.2 * (lam + 1.3), 0.1, 0.3)
    mu2, s2 = np.clip(-1.2 - 1.333 * (lam + 0.7), -2.0, -1.2), np.clip(0.5 - (lam + 0.5), 0.3, 0.5)
    chi = np.where(rng.random(n) < alpha, rng.normal(mu1, s1), rng.normal(mu2, s2))
    dv = 10 ** rng.normal(0.9 * chi + 2.9, 0.4) / 1e3
    return lc, 10 ** chi, dv


def fragment_orbits(h0, n, rng):
    """Fragments from a breakup on a circular orbit at h0 [km]: (a [km], e, A/M)."""
    _, am, dv = sbm_fragments(n, rng)
    r0, v0 = RE + h0, np.sqrt(MU / (RE + h0))
    d = rng.normal(size=(n, 3))
    d *= (dv / np.linalg.norm(d, axis=1))[:, None]       # isotropic kick; local frame (radial, along, cross)
    vr, vt, vn = d[:, 0], v0 + d[:, 1], d[:, 2]
    a = 1 / (2 / r0 - (vr ** 2 + vt ** 2 + vn ** 2) / MU)
    hmom = r0 * np.hypot(vt, vn)
    e = np.sqrt(np.clip(1 - hmom ** 2 / (MU * a), 0, None))
    return a, e, am


def decay_cloud(a, e, am, years, dt_days=2.0, h_reentry=180.0):
    """King-Hele (a,e) decay with orbit-averaged exponential density (small-e expansion).
    Returns the yearly fraction still in orbit, relative to the fragments that were in orbit at t=0."""
    ok = (a > RE) & (e < 1) & (a * (1 - e) - RE > h_reentry)
    a, e, B = a[ok], e[ok], CD * am[ok]
    n0, out, dt = ok.sum(), [1.0], dt_days * 86400
    per_year = int(round(365.25 / dt_days))
    for step in range(1, int(years * per_year) + 1):
        rho, sh = atmosphere(np.maximum(a * (1 - e) - RE, 100.0))   # density at perigee
        c = np.maximum(a * e / sh, 1e-8)
        i0, i1 = i0e(c), i1e(c)                          # exp(-c) I_n(c); I2 = I0 - 2 I1 / c
        k = B * rho * dt
        a, e = (a - k * np.sqrt(MU * 1e9 * a * 1e3) * (i0 + 2 * e * i1) / 1e3,
                np.clip(e - k * np.sqrt(MU * 1e9 / (a * 1e3)) * (i1 + 0.5 * e * (2 * i0 - 2 * i1 / c)), 0, None))
        keep = a * (1 - e) - RE > h_reentry
        a, e, B = a[keep], e[keep], B[keep]
        if step % per_year == 0:
            out.append(len(a) / n0)
    return np.array(out)


# Historic breakups: satcat name prefixes, date, altitude [km], masses [kg], impact speed [km/s]
EVENTS = [
    ("FY-1C ASAT test (2007)", ("FENGYUN 1C DEB",), "2007-01-11", 865, 880, 20, 9.0),
    ("Iridium 33 x Cosmos 2251 (2009)", ("IRIDIUM 33 DEB", "COSMOS 2251 DEB"), "2009-02-10", 789, 900, 560, 11.7),
    ("Cosmos 1408 ASAT test (2021)", ("COSMOS 1408 DEB",), "2021-11-15", 480, 1750, 20, 7.0),
]


def breakup_validation(satcat, now, rng):
    """Compare SBM fragment counts and modelled cloud decay with the real SATCAT history."""
    rows = []
    for name, prefixes, date, h0, m1, m2, v in EVENTS:
        deb = satcat[satcat.OBJECT_NAME.str.startswith(prefixes)]
        t_ev = pd.Timestamp(date)
        yrs = int(np.ceil((now - t_ev).days / 365.25))
        decay = pd.to_datetime(deb.DECAY_DATE, errors="coerce")
        cat_curve = np.array([1 - (decay <= t_ev + pd.Timedelta(days=365.25 * y)).sum() / len(deb)
                              for y in range(yrs + 1)])
        a, e, am = fragment_orbits(h0, 4000, rng)
        model = decay_cloud(a, e, am, yrs)
        frac_now = (now - t_ev).days / 365.25
        rows.append(dict(name=name, h0=h0, n_catalog=len(deb), n_sbm=float(sbm_count(max(m1, m2), min(m1, m2), v)),
                         on_orbit=int(decay.isna().sum()), years=np.arange(yrs + 1), cat_curve=cat_curve,
                         model_curve=model, surv_cat=float(decay.isna().mean()),
                         surv_model=float(np.interp(frac_now, np.arange(yrs + 1), model))))
    return rows


# ---------------------------------------------------------------- shell Monte Carlo
R_INTACT, R_DEB = 2.0, 0.1                      # m, collision radii
M_S, M_D, M_N = 500.0, 1000.0, 0.05             # kg: active sat, derelict (sats + R/B), 10 cm fragment
FAIL_AA, FAIL_AX = 0.01, 0.05                   # collision-avoidance failure: active-active, active-other
LIFE = 5.0                                      # yr mission life
AM_DERELICT = 0.01                              # m^2/kg
BOOM_TARGET = 65000                             # active LEO sats announced by ~2045 (Starlink Gen2, Kuiper, Guowang, Qianfan)


def initial_state(satcat):
    """Shell counts of active payloads (S), derelict intact objects (D) and debris (N) from SATCAT."""
    s = satcat[satcat.DECAY_DATE.isna() & (satcat.APOGEE < 2100) & (satcat.PERIGEE > 150)]
    h = 0.5 * (s.APOGEE + s.PERIGEE)
    shell = np.searchsorted(EDGES, h, side="right") - 1
    ok = (shell >= 0) & (shell < K)
    s, shell = s[ok], shell[ok]
    active = (s.OBJECT_TYPE == "PAY") & s.OPS_STATUS_CODE.isin(["+", "P", "B", "S", "X"])
    intact = s.OBJECT_TYPE.isin(["PAY", "R/B"])
    cnt = lambda m: np.bincount(shell[m.to_numpy()], minlength=K)
    launch = pd.to_datetime(s.LAUNCH_DATE, errors="coerce")
    recent = active & (launch > launch.max() - pd.Timedelta(days=3 * 365))
    profile = cnt(recent).astype(float)
    return dict(S=cnt(active), D=cnt(intact & ~active), N=cnt(~intact), profile=profile / profile.sum())


def spread_kernel(rng, n=4000):
    """K[i, j]: expected share of fragments from a breakup in shell i that land in shell j (rest re-enter)."""
    ker = np.zeros((K, K))
    for i, h0 in enumerate(H):
        a, e, _ = fragment_orbits(h0, n, rng)
        j = np.searchsorted(EDGES, a - RE, side="right") - 1
        ok = (j >= 0) & (j < K) & (a * (1 - e) - RE > 150)
        ker[i] = np.bincount(j[ok], minlength=K)[:K] / n
    return ker


def scenarios(S0, profile):
    boom_prof = 0.7 * profile + 0.3 * np.isin(np.arange(K), np.searchsorted(EDGES, [1050, 1100])).astype(float) / 2
    boom = lambda y: S0 + (BOOM_TARGET - S0) / (1 + np.exp(-(y - 10) / 3))
    return [
        dict(name="No new launches", target=None, pmd=0.90, adr=0, profile=profile),
        dict(name="Steady fleet, 90% disposal", target=lambda y: S0, pmd=0.90, adr=0, profile=profile),
        dict(name="Mega-constellation boom, 90% disposal", target=boom, pmd=0.90, adr=0, profile=boom_prof),
        dict(name="Boom + 99% disposal + ADR 5/yr", target=boom, pmd=0.99, adr=5, profile=boom_prof),
    ]


def run_mc(init, scen, kernel, b_deb, years=200, dt=0.25, runs=200, seed=0, collisions=True):
    """Vectorised over runs x shells. Returns yearly totals per run and bookkeeping counters."""
    rng = np.random.default_rng(seed)
    S, D, N = (np.repeat(init[k][None].astype(np.int64), runs, 0) for k in "SDN")
    sig_ii, sig_in = np.pi * (2 * R_INTACT / 1e3) ** 2, np.pi * ((R_INTACT + R_DEB) / 1e3) ** 2   # km^2
    c = V_REL * YEAR * dt / VOL
    f_cat = {k: float(sbm_count(max(m), min(m), V_REL)) for k, m in
             dict(SS=(M_S, M_S), SD=(M_D, M_S), DD=(M_D, M_D)).items()}
    f_SN, f_DN = float(sbm_count(M_S, M_N, V_REL)), float(sbm_count(M_D, M_N, V_REL))
    pD = np.clip(dadt_circ(H, CD * AM_DERELICT) * dt / DH, 0, 1)
    pN = np.clip(dadt_circ(H, b_deb) * dt / DH, 0, 1)   # ponytail: <=1 shell per step, slow at 200-300 km
    book = {k: np.zeros(runs, np.int64) for k in ("launched", "created", "reentered", "disposed", "destroyed", "removed")}
    ncat = np.zeros(runs, np.int64)
    rec = dict(S=[], D=[], N=[], cat=[], Nshell=[])
    steps_per_year = int(round(1 / dt))
    for step in range(int(years / dt) + 1):
        if step % steps_per_year == 0:
            for k, X in (("S", S), ("D", D), ("N", N)):
                rec[k].append(X.sum(1))
            rec["cat"].append(ncat.copy())
            rec["Nshell"].append(N + D + S)
        if step == int(years / dt):
            break
        y = step * dt
        if collisions:
            n_ss = np.minimum(rng.poisson(0.5 * sig_ii * c * S * np.maximum(S - 1, 0) * FAIL_AA), S // 2)
            n_sd = np.minimum(rng.poisson(sig_ii * c * S * D * FAIL_AX), np.minimum(S - 2 * n_ss, D))
            n_dd = np.minimum(rng.poisson(0.5 * sig_ii * c * D * np.maximum(D - 1, 0)), (D - n_sd) // 2)
            n_sn = rng.poisson(sig_in * c * S * N * FAIL_AX)
            n_dn = rng.poisson(sig_in * c * D * N)
            S -= 2 * n_ss + n_sd
            D -= n_sd + 2 * n_dd
            book["destroyed"] += 2 * (n_ss + n_sd + n_dd).sum(1)
            ncat += (n_ss + n_sd + n_dd).sum(1)
            src = n_ss * f_cat["SS"] + n_sd * f_cat["SD"] + n_dd * f_cat["DD"] + n_sn * f_SN + n_dn * f_DN
            new = rng.poisson(src @ kernel)   # ponytail: Poisson thinning of the multinomial fragment spread
            N += new
            book["created"] += new.sum(1)
        for X, p in ((D, pD), (N, pN)):
            down = rng.binomial(X, p)
            X -= down
            X[:, :-1] += down[:, 1:]
            book["reentered"] += down[:, 0]
        retire = rng.binomial(S, dt / LIFE)
        comply = rng.binomial(retire, scen["pmd"])
        S -= retire
        D += retire - comply
        book["disposed"] += comply.sum(1)
        if scen["target"] is not None:
            need = np.maximum(scen["target"](y) - S.sum(1), 0) * dt + retire.sum(1)
            launch = rng.multinomial(np.round(need).astype(np.int64), scen["profile"])
            S += launch
            book["launched"] += launch.sum(1)
        if scen["adr"]:
            k = np.argmax(D * (D + N), axis=1)
            rem = np.minimum(rng.poisson(scen["adr"] * dt, runs), D[np.arange(runs), k])
            D[np.arange(runs), k] -= rem
            book["removed"] += rem
    out = {k: np.array(v) for k, v in rec.items()}
    out["book"], out["final"] = book, (S + D + N).sum(1)
    return out


def verify_bookkeeping(init, kernel, b_deb, scen):
    """Object conservation: final = initial + launched + created - reentered - disposed - destroyed - removed."""
    r = run_mc(init, scen, kernel, b_deb, years=50, runs=20, seed=7)
    b = r["book"]
    lhs = r["final"]
    rhs = sum(init[k].sum() for k in "SDN") + b["launched"] + b["created"] - b["reentered"] - b["disposed"] \
        - b["destroyed"] - b["removed"]
    assert np.array_equal(lhs, rhs), (lhs, rhs)
    return int(np.abs(lhs - rhs).max())


def run_all(satcat, now, seed=0, runs=200):
    rng = np.random.default_rng(seed)
    init = initial_state(satcat)
    kernel = spread_kernel(rng)
    _, am, _ = sbm_fragments(200_000, rng)
    b_deb = CD * float(np.median(am))
    scens = scenarios(int(init["S"].sum()), init["profile"])
    results = [(s, run_mc(init, s, kernel, b_deb, runs=runs, seed=seed + i)) for i, s in enumerate(scens)]
    val = breakup_validation(satcat, now, rng)
    book_err = verify_bookkeeping(init, kernel, b_deb, scens[3])
    return dict(init=init, kernel=kernel, b_deb=b_deb, results=results, validation=val, book_err=book_err,
                year0=now.year + (now.dayofyear - 1) / 365.25)
sim.py 458 lines
"""L09 Orbital Traffic Control: real catalogue -> SGP4 -> conjunction screening -> Pc -> Kessler MC -> 3D web.

Run from the repo root:   uv run python L09_orbital_traffic_control/sim.py
Then open L09_orbital_traffic_control/index.html (double-click; it reads data.js written here).
"""
import os

os.environ.setdefault("OMP_NUM_THREADS", "4")

import json
import math
import shutil
import subprocess
import time
from datetime import datetime, timedelta, timezone
from multiprocessing import Pool
from pathlib import Path

import numpy as np
import pandas as pd
import requests
from scipy.optimize import minimize_scalar
from scipy.spatial import cKDTree
from sgp4.api import Satrec, SatrecArray, jday

HERE = Path(__file__).parent
DATA, OUT = HERE / "data", HERE / "output"
GP_URL = "https://celestrak.org/NORAD/elements/gp.php?GROUP={}&FORMAT=tle"
GROUPS = ["active", "fengyun-1c-debris", "cosmos-2251-debris", "iridium-33-debris", "cosmos-1408-debris"]
SATCAT_URL = "https://celestrak.org/pub/satcat.csv"
SATJS_URL = "https://cdn.jsdelivr.net/npm/satellite.js@5.0.0/dist/satellite.min.js"

WINDOW_H = 24          # screening window [h]
DT = 20.0              # sieve time step [s]
D_SCREEN = 10.0        # screening distance [km]
V_REL_MAX = 16.0       # upper bound on LEO closing speed [km/s]; sieve radius = D_SCREEN + V_REL_MAX*DT/2
FORMATION_S = 600.0    # pairs inside D_SCREEN longer than this are co-orbiting (docked, formations, fresh trains)
V_SLOW = 0.1           # km/s; slower encounters are also treated as co-orbiting
POLISH_KM = 1.0        # encounters closer than this get the exact SGP4 + Brent TCA
CONV_S = 900.0         # length of the dt = 1 s step-convergence re-screen [s]
WORKERS = 4
MU = 398600.4418

CATS = [("Starlink", "#4cc9f0"), ("OneWeb", "#9d7bff"), ("Kuiper", "#c3f73a"), ("Qianfan/Guowang", "#ff7aa2"),
        ("Other LEO", "#cfd8e3"), ("MEO / GNSS", "#ffd166"), ("GEO", "#ff9f43"), ("HEO", "#f15bb5"),
        ("Stations & crew", "#ffffff"), ("Breakup debris", "#ff4d4d")]
RADIUS_M = np.array([5, 5, 5, 5, 3, 3, 3, 3, 50, 0.5])   # assumed hard-body radius per category [m]
STATIONS = ("ISS", "CSS", "TIANGONG", "SOYUZ", "PROGRESS", "CREW DRAGON", "DRAGON", "CYGNUS", "SHENZHOU",
            "TIANZHOU", "STARLINER", "HTV")
# Pc covariance assumption (TLE-grade, order of Flohrer et al. 2008): 1-sigma radial/in-track/cross-track [km]
SIG0 = np.array([0.1, 0.5, 0.2])
SIG_RATE = np.array([0.0, 1.5, 0.0])   # km/day growth with |TCA - TLE epoch|, in-track dominated
# ponytail: one sigma law for every object, no OD covariance; swap in CDM covariances for operational Pc


def cached(url, path):
    """Download once, then reuse the local copy (delete data/ to refresh)."""
    if not path.exists():
        print(f"  downloading {url}")
        r = requests.get(url, timeout=120, headers={"User-Agent": "SimulationLab-L09 (educational, cached)"})
        r.raise_for_status()
        path.write_bytes(r.content)
    return path


def category(name, group, period, ecc):
    if group != "active":
        return 9
    n = name.upper()
    for k, pre in enumerate(("STARLINK", "ONEWEB", "KUIPER", ("QIANFAN", "HULIANWANG", "GUOWANG"))):
        if n.startswith(pre):
            return k
    if n.startswith(STATIONS):
        return 8
    if ecc > 0.25:
        return 7
    if period < 225:
        return 4
    return 6 if 1300 < period < 1600 else 5


def load_catalog():
    rows = []
    for g in GROUPS:
        lines = [ln.rstrip() for ln in cached(GP_URL.format(g), DATA / f"{g}.tle").read_text().splitlines() if ln.strip()]
        rows += [(lines[k].strip(), lines[k + 1], lines[k + 2], g) for k in range(0, len(lines) - 2, 3)]
    cat = pd.DataFrame(rows, columns=["name", "l1", "l2", "group"])
    sats = [Satrec.twoline2rv(a, b) for a, b in zip(cat.l1, cat.l2)]
    cat["norad"] = [s.satnum for s in sats]
    keep = ~cat.norad.duplicated().to_numpy()
    cat, sats = cat[keep].reset_index(drop=True), [s for s, k in zip(sats, keep) if k]
    cat["period"] = [2 * np.pi / s.no_kozai for s in sats]          # minutes
    cat["ecc"] = [s.ecco for s in sats]
    cat["epoch_jd"] = [s.jdsatepoch + s.jdsatepochF for s in sats]
    cat["cat"] = [category(*a) for a in zip(cat.name, cat.group, cat.period, cat.ecc)]
    satcat = pd.read_csv(cached(SATCAT_URL, DATA / "satcat.csv"))
    meta = satcat.set_index("NORAD_CAT_ID")[["OWNER", "LAUNCH_DATE"]]
    cat = cat.join(meta, on="norad").fillna({"OWNER": "", "LAUNCH_DATE": ""})
    cached(SATJS_URL, DATA / "satellite.min.js")   # for the node cross-check
    return cat, sats, satcat


def analysis_epoch(cat):
    """Screening starts at the top of the hour after the 90th-percentile TLE epoch (fresh elements)."""
    jd = np.percentile(cat.epoch_jd[cat.group == "active"], 90)
    t = datetime(2000, 1, 1, 12, tzinfo=timezone.utc) + timedelta(days=jd - 2451545.0)
    return (t + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)


# ---------------------------------------------------------------- screening
def screen_step(r, v, dt=DT, d=D_SCREEN):
    """KD-tree sieve at one sample: pairs whose straight-line closest approach within +-dt/2 is < d."""
    ok = np.flatnonzero(np.isfinite(r[:, 0]))
    p = cKDTree(r[ok]).query_pairs(d + V_REL_MAX * dt / 2, output_type="ndarray")
    i, j = ok[p[:, 0]], ok[p[:, 1]]
    dr, dv = r[j] - r[i], v[j] - v[i]
    tau = np.clip(-(dr * dv).sum(1) / np.maximum((dv * dv).sum(1), 1e-12), -dt / 2, dt / 2)
    dmin = np.linalg.norm(dr + dv * tau[:, None], axis=1)
    m = dmin < d
    return i[m], j[m], tau[m], dmin[m]


_W = {}


def _init_worker(l1, l2, jd0, fr0):
    _W.update(arr=SatrecArray([Satrec.twoline2rv(a, b) for a, b in zip(l1, l2)]), jd0=jd0, fr0=fr0)


def _screen_chunk(args):
    steps, dt, d = args
    t = steps * dt
    e, r, v = _W["arr"].sgp4(np.full(len(t), _W["jd0"]), _W["fr0"] + t / 86400)
    r[e != 0] = np.nan
    out = [np.zeros((0, 17))]
    for s, k in enumerate(steps):
        i, j, tau, dm = screen_step(r[:, s], v[:, s], dt, d)
        out.append(np.column_stack([np.full(len(i), k), i, j, k * dt + tau, dm, r[i, s], v[i, s], r[j, s], v[j, s]]))
    return np.vstack(out)


def screen(pool, n_steps, dt, d=D_SCREEN):
    """Propagate the whole catalogue with vectorised SGP4 and sieve every step, in parallel time chunks."""
    chunks = np.array_split(np.arange(n_steps), max(1, n_steps // 30))
    return np.vstack(pool.map(_screen_chunk, [(c, dt, d) for c in chunks]))


def group_encounters(hits, dt):
    """Merge consecutive-step hits of the same pair into one encounter with a search bracket."""
    df = pd.DataFrame(hits[:, :5], columns=["k", "i", "j", "t", "d"]).astype({"k": int, "i": int, "j": int})
    df["row"] = np.arange(len(df))
    df = df.sort_values(["i", "j", "k"])
    df["run"] = ((df.i.diff() != 0) | (df.j.diff() != 0) | (df.k.diff() > 1)).cumsum()
    g = df.groupby("run")
    ev = df.loc[g.d.idxmin()].reset_index(drop=True)
    ev["t_lo"], ev["t_hi"] = g.t.min().to_numpy() - dt, g.t.max().to_numpy() + dt
    ev["span"] = (g.k.max() - g.k.min()).to_numpy() * dt + dt
    return ev, hits[ev.row.to_numpy(), 5:].reshape(-1, 4, 3)   # SGP4 states (ri, vi, rj, vj) at the best step


def refine_2body(tk, ri, vi, rj, vj, iters=4):
    """Vectorised TCA from sampled SGP4 states: relative motion to 2nd order in time (two-body gravity),
    Newton iterations on d/dt |D|^2 = 0. Returns t_ca and the propagated states of both objects."""
    ai = -MU * ri / np.linalg.norm(ri, axis=1, keepdims=True) ** 3
    aj = -MU * rj / np.linalg.norm(rj, axis=1, keepdims=True) ** 3
    dr, dv, da = rj - ri, vj - vi, aj - ai
    tau = -(dr * dv).sum(1) / np.maximum((dv * dv).sum(1), 1e-12)
    for _ in range(iters):
        T = tau[:, None]
        D, Dp = dr + dv * T + 0.5 * da * T ** 2, dv + da * T
        tau = tau - (D * Dp).sum(1) / ((Dp * Dp).sum(1) + (D * da).sum(1))
    T = tau[:, None]
    return tk + tau, (ri + vi * T + 0.5 * ai * T ** 2, vi + ai * T, rj + vj * T + 0.5 * aj * T ** 2, vj + aj * T)


def refine_tca(dist, t_lo, t_hi):
    """Time of closest approach: bounded Brent minimisation of the separation |r2 - r1|(t)."""
    res = minimize_scalar(dist, bounds=(t_lo, t_hi), method="bounded", options={"xatol": 1e-4})
    return res.x, res.fun


def sgp4_at(sat, jd0, fr0, t):
    _, r, v = sat.sgp4(jd0, fr0 + t / 86400)
    return np.array(r), np.array(v)


# ---------------------------------------------------------------- collision probability
def rtn(r, v):
    R = r / np.linalg.norm(r, axis=-1, keepdims=True)
    N = np.cross(r, v)
    N /= np.linalg.norm(N, axis=-1, keepdims=True)
    return np.stack([R, np.cross(N, R), N], axis=-2)        # rows: radial, in-track, cross-track


def cov_eci(r, v, age_days):
    sig = SIG0 + SIG_RATE * np.abs(age_days)[:, None]
    B = rtn(r, v)
    return np.einsum("mki,mk,mkj->mij", B, sig ** 2, B)


def encounter_plane(r1, v1, r2, v2, C):
    """Project miss vector and combined covariance onto the plane normal to the relative velocity."""
    dr, dv = r2 - r1, v2 - v1
    z = dv / np.linalg.norm(dv, axis=1, keepdims=True)
    x = dr - (dr * z).sum(1, keepdims=True) * z
    x /= np.linalg.norm(x, axis=1, keepdims=True)
    P = np.stack([x, np.cross(z, x)], 1)                     # (M, 2, 3)
    return np.einsum("mij,mj->mi", P, dr), P @ C @ P.transpose(0, 2, 1)


def pc_circle(miss, C2, hbr, nr=16, nt=32):
    """2-D Pc (Foster/Alfano short-encounter): Gaussian integrated over the hard-body disc, polar quadrature."""
    xg, wg = np.polynomial.legendre.leggauss(nr)
    rr = 0.5 * hbr[:, None] * (xg + 1)
    wr = 0.5 * hbr[:, None] * wg
    th = (np.arange(nt) + 0.5) * 2 * np.pi / nt
    dx = rr[:, :, None] * np.cos(th) - miss[:, 0, None, None]
    dy = rr[:, :, None] * np.sin(th) - miss[:, 1, None, None]
    det = C2[:, 0, 0] * C2[:, 1, 1] - C2[:, 0, 1] ** 2
    a, b, c = (C2[:, 1, 1] / det)[:, None, None], (-C2[:, 0, 1] / det)[:, None, None], (C2[:, 0, 0] / det)[:, None, None]
    f = np.exp(-0.5 * (a * dx ** 2 + 2 * b * dx * dy + c * dy ** 2)) / (2 * np.pi * np.sqrt(det))[:, None, None]
    return (f * (rr * wr)[:, :, None]).sum((1, 2)) * 2 * np.pi / nt


def conjunctions(hits, sats, cat, jd0, fr0, dt):
    """Group sieve hits -> vectorised 2-body TCA for all -> exact SGP4 + Brent polish below POLISH_KM -> Pc."""
    ev, st = group_encounters(hits, dt)
    form = (ev.span > FORMATION_S).to_numpy()
    n_formation = int(form.sum())
    ev, st = ev[~form].reset_index(drop=True), st[~form]
    tca, (r1, v1, r2, v2) = refine_2body(ev.k.to_numpy() * dt, *st.transpose(1, 0, 2))
    ev["tca"], ev["miss"] = tca, np.linalg.norm(r2 - r1, axis=1)
    keep = (ev.miss < D_SCREEN).to_numpy()
    ev, r1, v1, r2, v2 = ev[keep].reset_index(drop=True), r1[keep], v1[keep], r2[keep], v2[keep]
    I, J, tca, miss = ev.i.to_numpy(), ev.j.to_numpy(), ev.tca.to_numpy().copy(), ev.miss.to_numpy().copy()
    pol = np.flatnonzero(miss < POLISH_KM)
    d_err = np.zeros((len(pol), 2))
    for m, n in enumerate(pol):
        a, b, t = sats[I[n]], sats[J[n]], tca[n]
        tn, dn = refine_tca(lambda x: math.dist(a.sgp4(jd0, fr0 + x / 86400)[1], b.sgp4(jd0, fr0 + x / 86400)[1]),
                            t - dt / 2, t + dt / 2)
        d_err[m] = abs(dn - miss[n]), abs(tn - t)
        tca[n], miss[n] = tn, dn
        (r1[n], v1[n]), (r2[n], v2[n]) = sgp4_at(a, jd0, fr0, tn), sgp4_at(b, jd0, fr0, tn)
    ev["tca"], ev["miss"], ev["polished"] = tca, miss, miss < POLISH_KM
    jd_tca = jd0 + fr0 + ev.tca.to_numpy() / 86400
    ci, cj = cat.cat.to_numpy()[ev.i], cat.cat.to_numpy()[ev.j]
    C = cov_eci(r1, v1, jd_tca - cat.epoch_jd.to_numpy()[ev.i]) + cov_eci(r2, v2, jd_tca - cat.epoch_jd.to_numpy()[ev.j])
    miss2, C2 = encounter_plane(r1, v1, r2, v2, C)
    hbr = (RADIUS_M[ci] + RADIUS_M[cj]) / 1e3
    ev["vrel"] = np.linalg.norm(v2 - v1, axis=1)
    ev["pc"] = pc_circle(miss2, C2, hbr)
    ev["pcmax"] = np.minimum(hbr ** 2 / (math.e * ev.miss.to_numpy() ** 2), 1)   # worst-case covariance (Alfano 2005)
    ev["hbr_m"] = hbr * 1e3
    ev["alt"] = np.linalg.norm(0.5 * (r1 + r2), axis=1) - 6378.137
    ev["rtn"] = list(np.einsum("mij,mj->mi", rtn(r1, v1), r2 - r1))
    ev["c2"] = list(C2.reshape(-1, 4)[:, [0, 1, 3]])
    ev["ci"], ev["cj"] = ci, cj
    names = [CATS[k][0] for k in range(len(CATS))]
    ev["pair"] = [" x ".join(sorted((names[a], names[b]))) for a, b in zip(ci, cj)]
    slow = (ev.vrel < V_SLOW).to_numpy()       # drifting co-orbiters: the short-encounter 2-D Pc does not apply
    info = dict(n_formation=n_formation + int(slow.sum()), polish_err=d_err)
    ev = ev[~slow].sort_values(["i", "j", "tca"])
    ev = ev[~((ev.i.diff() == 0) & (ev.j.diff() == 0) & (ev.tca.diff() < 60))]
    return ev.sort_values("pc", ascending=False).reset_index(drop=True), info


# ---------------------------------------------------------------- verification
def jd_ms(ms):
    return 2440587.5 + ms // 86_400_000, (ms % 86_400_000) / 86_400_000


def xcheck_reference(cat, sats, t0_ms):
    """Python SGP4 positions for a sample of objects; satellite.js must reproduce them (node + browser)."""
    rng = np.random.default_rng(1)
    idx = sorted(set(cat.groupby("cat").head(2).index) | set(rng.choice(len(cat), 40, replace=False).tolist()))
    ms = [t0_ms + h * 3_600_000 for h in (-12, 0, 6, 24, 72)]
    ref_idx, pos = [], []
    for k in idx:
        res = [sats[k].sgp4(*jd_ms(m)) for m in ms]
        if all(e == 0 for e, _, _ in res):
            ref_idx.append(int(k))
            pos.append([[round(x, 7) for x in r] for _, r, _ in res])
    return dict(idx=ref_idx, ms=ms, pos=pos)


def node_crosscheck():
    if not shutil.which("node"):
        print("  node not found: skipping satellite.js cross-check (the web page repeats it live)")
        return None
    p = subprocess.run(["node", str(HERE / "crosscheck.mjs")], capture_output=True, text=True, timeout=300)
    if p.returncode:
        raise RuntimeError(p.stderr)
    return json.loads(p.stdout)


def synthetic_pair(t):
    """Circular equatorial (R) and polar (R+h) orbits that meet over the node at t=TC: miss = h exactly."""
    R, h, TC = 7000.0, 0.5, 1234.5
    nA, nB = math.sqrt(MU / R ** 3), math.sqrt(MU / (R + h) ** 3)
    a, b = nA * (t - TC), nB * (t - TC)
    rA = np.array([R * np.cos(a), R * np.sin(a), 0 * a]).T
    vA = np.array([-R * nA * np.sin(a), R * nA * np.cos(a), 0 * a]).T
    rB = np.array([(R + h) * np.cos(b), 0 * b, (R + h) * np.sin(b)]).T
    vB = np.array([-(R + h) * nB * np.sin(b), 0 * b, (R + h) * nB * np.cos(b)]).T
    return rA, vA, rB, vB, h, TC


def verify_synthetic(rng):
    """Sieve + TCA refinement recover a known encounter hidden in 3000 random background objects."""
    _, _, _, _, h, TC = synthetic_pair(0.0)
    nbg = 3000
    rb = rng.normal(size=(nbg, 3))
    rb *= (rng.uniform(6800, 7400, nbg) / np.linalg.norm(rb, axis=1))[:, None]
    vb = np.cross(rb, rng.normal(size=(nbg, 3)))
    vb *= (np.sqrt(MU / np.linalg.norm(rb, axis=1)) / np.linalg.norm(vb, axis=1))[:, None]
    found = []
    for k in range(int(TC // DT) - 3, int(TC // DT) + 4):
        rA, vA, rB, vB, _, _ = synthetic_pair(k * DT)
        r = np.vstack([rA, rB, rb + vb * k * DT])   # straight-line background is fine for a sieve test
        v = np.vstack([vA, vB, vb])
        i, j, tau, dm = screen_step(r, v)
        found += [(dm0, k) for a, b, dm0 in zip(i, j, dm) if (a, b) == (0, 1)]
    assert found, "sieve missed the synthetic encounter"
    kb = min(found)[1]
    rA, vA, rB, vB, _, _ = synthetic_pair(np.array([kb * DT]))
    t2, (r1, _, r2, _) = refine_2body(np.array([kb * DT]), rA, vA, rB, vB)
    d2 = float(np.linalg.norm(r2 - r1))
    assert abs(t2[0] - TC) < 1e-2 and abs(d2 - h) < 1e-3, (t2, d2)
    found = [k * DT for _, k in found]

    def dist(t):
        rA, _, rB, _, _, _ = synthetic_pair(t)
        return float(np.linalg.norm(rB - rA))
    t, d = refine_tca(dist, min(found) - DT, max(found) + DT)
    assert abs(t - TC) < 1e-2 and abs(d - h) < 1e-4, (t, d)
    return dict(tca_err_s=abs(t - TC), miss_err_m=abs(d - h) * 1e3, sieve_hits=len(found),
                tca2_err_s=abs(t2[0] - TC), miss2_err_m=abs(d2 - h) * 1e3)


def verify_pc():
    """Pc quadrature vs the exact result for a centred isotropic Gaussian: 1 - exp(-R^2 / 2 sigma^2)."""
    sig, hbr = 0.05, np.array([0.02])
    pc = pc_circle(np.zeros((1, 2)), np.eye(2)[None] * sig ** 2, hbr)[0]
    exact = 1 - math.exp(-hbr[0] ** 2 / (2 * sig ** 2))
    assert abs(pc / exact - 1) < 1e-6, (pc, exact)
    return abs(pc / exact - 1)


def convergence_check(ev, fine_ev):
    """Encounters found with a 1 s sieve step in the first CONV_S seconds must also be found with DT."""
    inner = fine_ev[(fine_ev.tca > DT) & (fine_ev.tca < CONV_S - DT)]
    coarse = set(zip(ev.i, ev.j))
    missed = [(i, j) for i, j in zip(inner.i, inner.j) if (i, j) not in coarse]
    return dict(fine=len(inner), missed=len(missed))


# ---------------------------------------------------------------- export
def export_js(cat, ev, stats, kess, xref, t0_ms):
    top = pd.concat([ev.head(150), ev.nsmallest(20, "miss")]).drop_duplicates(["i", "j", "tca"])
    conj = [dict(i=int(r.i), j=int(r.j), t=int(t0_ms + r.tca * 1000), d=round(r.miss, 4), v=round(r.vrel, 3),
                 pc=float(f"{r.pc:.3e}"), pcmax=float(f"{r.pcmax:.3e}"), hbr=float(r.hbr_m), alt=round(r.alt, 1),
                 rtn=[round(x, 4) for x in r.rtn], c2=[round(x, 5) for x in r.c2], pair=r.pair)
            for r in top.itertuples()]
    ks = []
    for s, res in kess["results"]:
        tot = res["S"] + res["D"] + res["N"]
        q = np.percentile(tot, [5, 50, 95], axis=1)
        ks.append(dict(name=s["name"], p05=q[0, ::2].round().tolist(), p50=q[1, ::2].round().tolist(),
                       p95=q[2, ::2].round().tolist(), cat200=float(np.median(res["cat"][-1]))))
    D = dict(
        meta=dict(t0=t0_ms, window_h=WINDOW_H, dt=DT, d_screen=D_SCREEN, sig0=SIG0.tolist(), sig_rate=SIG_RATE.tolist(),
                  generated=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), source="CelesTrak GP + SATCAT"),
        cats=[dict(name=n, color=c, n=int((cat.cat == k).sum()), r=float(RADIUS_M[k])) for k, (n, c) in enumerate(CATS)],
        name=cat.name.tolist(), cat=cat.cat.tolist(), owner=cat.OWNER.tolist(), launch=cat.LAUNCH_DATE.tolist(),
        l1=cat.l1.tolist(), l2=cat.l2.tolist(), conj=conj, stats=stats, xcheck=xref,
        kessler=dict(year0=round(kess["year0"], 2), step=2, scen=ks))
    (HERE / "data.js").write_text("window.SAT_DATA = " + json.dumps(D, separators=(",", ":")) + ";\n")


def main():
    import figures
    import kessler
    wall = time.perf_counter()
    OUT.mkdir(exist_ok=True)
    DATA.mkdir(exist_ok=True)
    cat, sats, satcat = load_catalog()
    t0 = analysis_epoch(cat)
    t0_ms = int(t0.timestamp() * 1000)
    jd0, fr0 = jday(t0.year, t0.month, t0.day, t0.hour, 0, 0)
    print(f"catalogue: {len(cat)} objects ({(cat.group == 'active').sum()} active), window start {t0:%Y-%m-%d %H:%M} UTC")

    tic = time.perf_counter()
    n_steps = int(WINDOW_H * 3600 / DT)
    with Pool(WORKERS, initializer=_init_worker, initargs=(cat.l1.tolist(), cat.l2.tolist(), jd0, fr0)) as pool:
        hits = screen(pool, n_steps, DT)
        t_screen = time.perf_counter() - tic
        fine_hits = screen(pool, int(CONV_S), 1.0)
    ev, info = conjunctions(hits, sats, cat, jd0, fr0, DT)
    fine_ev, _ = conjunctions(fine_hits, sats, cat, jd0, fr0, 1.0)
    n_form, perr = info["n_formation"], info["polish_err"]
    print(f"screening: {len(cat) * n_steps / 1e6:.1f} M SGP4 states, {len(hits)} sieve hits -> {len(ev)} encounters "
          f"< {D_SCREEN:.0f} km ({n_form} co-orbiting pairs excluded) in {t_screen:.1f} s")
    top = ev.iloc[0]
    print(f"top Pc: {cat.name[top.i]} x {cat.name[top.j]}  miss {top.miss * 1e3:.0f} m  vrel {top.vrel:.2f} km/s  "
          f"Pc {top.pc:.2e}  TCA {t0 + timedelta(seconds=float(top.tca)):%Y-%m-%d %H:%M:%S} UTC")

    kess = kessler.run_all(satcat, pd.Timestamp(t0.replace(tzinfo=None)))
    xref = xcheck_reference(cat, sats, t0_ms)
    stats = dict(n_objects=len(cat), n_active=int((cat.group == "active").sum()), steps=n_steps,
                 states=len(cat) * n_steps, sieve_hits=len(hits), encounters=len(ev), formation=n_form,
                 lt1=int((ev.miss < 1).sum()), lt5=int((ev.miss < 5).sum()), pc4=int((ev.pc > 1e-4).sum()),
                 pc5=int((ev.pc > 1e-5).sum()), screen_s=round(t_screen, 1),
                 pairs=ev.pair.value_counts().head(8).to_dict())
    export_js(cat, ev, stats, kess, xref, t0_ms)

    # ---- verification
    print("verification:")
    js = node_crosscheck()
    if js:
        print(f"  satellite.js vs python-sgp4: {js['n']} states, max |dr| = {js['max_km'] * 1e3:.3g} m, "
              f"rms = {js['rms_km'] * 1e3:.3g} m")
        assert js["max_km"] < 1.0
    syn = verify_synthetic(np.random.default_rng(3))
    print(f"  synthetic encounter: Brent TCA error {syn['tca_err_s'] * 1e3:.3f} ms, miss error {syn['miss_err_m'] * 1e3:.3f} mm; "
          f"2-body Newton {syn['tca2_err_s'] * 1e3:.3f} ms, {syn['miss2_err_m'] * 1e3:.3f} mm")
    pc_err = verify_pc()
    print(f"  Pc quadrature vs analytic: rel. error {pc_err:.1e}")
    conv = convergence_check(ev, fine_ev)
    print(f"  sieve step convergence (first {CONV_S / 60:.0f} min): {conv['fine']} encounters at dt=1 s, "
          f"{conv['missed']} missed at dt={DT:.0f} s")
    print(f"  2-body TCA vs exact SGP4+Brent on {len(perr)} sub-km events: miss error median {np.median(perr[:, 0]) * 1e3:.1f} m, "
          f"max {perr[:, 0].max() * 1e3:.1f} m; TCA error max {perr[:, 1].max() * 1e3:.0f} ms")
    assert conv["missed"] == 0
    for v in kess["validation"]:
        print(f"  {v['name']}: SBM {v['n_sbm']:.0f} vs catalogued {v['n_catalog']} fragments; "
              f"surviving today model {v['surv_model']:.2f} vs catalogue {v['surv_cat']:.2f}")
        assert abs(v["surv_model"] - v["surv_cat"]) < 0.35
    iri = kess["validation"][1]
    assert 0.4 < iri["n_sbm"] / iri["n_catalog"] < 2.5
    print(f"  Kessler MC object bookkeeping: max imbalance {kess['book_err']}")
    for s, res in kess["results"]:
        tot = res["S"] + res["D"] + res["N"]
        print(f"  scenario '{s['name']}': LEO objects 2226 median {np.median(tot[-1]):,.0f} "
              f"(5-95% {np.percentile(tot[-1], 5):,.0f}-{np.percentile(tot[-1], 95):,.0f}), "
              f"catastrophic collisions {np.median(res['cat'][-1]):.0f}")

    figures.make_all(cat, satcat, ev, sats, jd0, fr0, t0, kess, stats, OUT)
    (OUT / "results.json").write_text(json.dumps(dict(stats=stats, crosscheck=js, synthetic=syn, pc_rel_err=pc_err,
                                                      convergence=conv, polish_err_m=[float(np.median(perr[:, 0]) * 1e3),
                                                      float(perr[:, 0].max() * 1e3)], top=dict(a=cat.name[top.i], b=cat.name[top.j],
                                                      miss_m=top.miss * 1e3, vrel=top.vrel, pc=top.pc)), indent=1, default=float))
    print(f"done in {time.perf_counter() - wall:.0f} s")


if __name__ == "__main__":
    main()
index.html 774 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Orbital Traffic Control</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
  --bg: #03050a; --panel: rgba(9, 13, 22, 0.78); --panel-2: rgba(18, 25, 40, 0.8); --line: rgba(120, 160, 255, 0.14);
  --line-2: rgba(120, 160, 255, 0.28); --ink: #e6ebf5; --ink-2: #aab4c8; --muted: #6f7b93; --accent: #4cc9f0;
  --high: #ff4d5e; --med: #ffb020; --low: #e8d44d; --min: #56627a; --good: #2fd67b;
  --mono: 'JetBrains Mono', ui-monospace, Consolas, monospace; --sans: 'Inter', system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font: 13px/1.45 var(--sans); overflow: hidden; }
canvas#c { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
.panel { position: fixed; background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
  backdrop-filter: blur(12px) saturate(1.2); -webkit-backdrop-filter: blur(12px) saturate(1.2); box-shadow: 0 10px 40px rgba(0,0,0,.45); }
.label { font: 600 10px/1 var(--sans); letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
button { font: inherit; color: var(--ink); background: var(--panel-2); border: 1px solid var(--line); border-radius: 7px;
  padding: 5px 9px; cursor: pointer; transition: background .15s, border-color .15s; }
button:hover { border-color: var(--line-2); background: rgba(40, 55, 85, .8); }
button.on { background: rgba(76, 201, 240, .18); border-color: rgba(76, 201, 240, .6); color: #bdefff; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* header */
#top { top: 12px; left: 12px; right: 12px; height: 56px; display: flex; align-items: center; gap: 18px; padding: 0 14px; }
#brand { display: flex; align-items: center; gap: 11px; min-width: 250px; }
#brand svg { flex: none; }
#brand h1 { margin: 0; font: 700 14px/1.1 var(--sans); letter-spacing: .16em; }
#brand small { display: block; font: 500 10.5px/1.3 var(--sans); color: var(--muted); letter-spacing: .04em; margin-top: 3px; }
#search { position: relative; }
#search input { width: 230px; background: rgba(0,0,0,.35); border: 1px solid var(--line); color: var(--ink); border-radius: 8px;
  padding: 7px 10px 7px 30px; font: 12.5px var(--sans); }
#search svg { position: absolute; left: 9px; top: 8px; opacity: .6; }
#results { position: absolute; top: 36px; left: 0; width: 320px; display: none; padding: 4px; }
#results div { padding: 6px 9px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; gap: 8px; }
#results div:hover, #results div.act { background: rgba(76, 201, 240, .14); }
#results span { color: var(--muted); font: 11px var(--mono); }
#clock { margin-left: auto; text-align: right; white-space: nowrap; }
#clock .t { font: 600 17px/1 var(--mono); letter-spacing: .02em; }
#clock .s { font: 11px var(--mono); color: var(--muted); margin-top: 4px; }
#controls { display: flex; gap: 5px; align-items: center; white-space: nowrap; }
#warps { display: flex; gap: 5px; }
#controls .sep { width: 1px; height: 24px; background: var(--line); margin: 0 4px; }

/* timeline */
#timeline { top: 76px; left: 12px; right: 12px; height: 34px; padding: 0 14px; display: flex; align-items: center; gap: 12px; }
#track { position: relative; flex: 1; height: 18px; cursor: pointer; }
#track .base { position: absolute; left: 0; right: 0; top: 8px; height: 2px; background: rgba(120,160,255,.18); border-radius: 2px; }
#track .tick { position: absolute; top: 3px; width: 2px; height: 12px; border-radius: 1px; transform: translateX(-1px); opacity: .85; }
#track .tick:hover { transform: translateX(-1px) scaleY(1.5); opacity: 1; }
#track .hr { position: absolute; top: 12px; font: 9px var(--mono); color: var(--muted); transform: translateX(-50%); pointer-events: none; }
#head { position: absolute; top: -2px; width: 2px; height: 22px; background: var(--accent); box-shadow: 0 0 10px var(--accent); pointer-events: none; }
#timeline .label { white-space: nowrap; }

/* left: conjunction watch */
#left { top: 120px; left: 12px; bottom: 12px; width: 356px; display: flex; flex-direction: column; overflow: hidden; }
#left header { padding: 14px 14px 8px; border-bottom: 1px solid var(--line); }
#left h2, #right h2 { margin: 6px 0 2px; font: 600 15px var(--sans); }
#left .sub { color: var(--muted); font-size: 11.5px; }
#sort { display: flex; gap: 4px; margin-top: 9px; }
#sort button { padding: 3px 8px; font-size: 11px; }
#list { flex: 1; overflow-y: auto; padding: 6px; scrollbar-width: thin; scrollbar-color: #2a3550 transparent; }
.row { display: grid; grid-template-columns: 26px 1fr auto; gap: 8px; padding: 8px 8px; border-radius: 8px; cursor: pointer;
  border: 1px solid transparent; align-items: center; }
.row:hover { background: rgba(76, 201, 240, .07); border-color: var(--line); }
.row.sel { background: rgba(76, 201, 240, .12); border-color: rgba(76, 201, 240, .45); }
.row.past { opacity: .45; }
.row .rk { font: 600 11px var(--mono); color: var(--muted); text-align: right; }
.row .nm { font-weight: 600; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .nm i { font-style: normal; color: var(--muted); font-weight: 400; margin: 0 4px; }
.row .mt { font: 11px var(--mono); color: var(--ink-2); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .mt b { font-weight: 500; color: var(--ink); }
.badge { font: 600 10.5px var(--mono); padding: 3px 6px; border-radius: 5px; text-align: center; min-width: 62px; }
.badge small { display: block; font: 600 8.5px var(--sans); letter-spacing: .1em; opacity: .85; margin-top: 1px; }
.b-high { background: rgba(255, 77, 94, .16); color: #ff8a95; border: 1px solid rgba(255, 77, 94, .45); }
.b-med { background: rgba(255, 176, 32, .13); color: #ffc862; border: 1px solid rgba(255, 176, 32, .4); }
.b-low { background: rgba(232, 212, 77, .1); color: #efe08a; border: 1px solid rgba(232, 212, 77, .3); }
.b-min { background: rgba(86, 98, 122, .15); color: #9aa6bd; border: 1px solid rgba(86, 98, 122, .4); }
#legend { border-top: 1px solid var(--line); padding: 10px 12px 12px; }
#legend .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px 10px; margin-top: 8px; }
#legend .it { display: flex; align-items: center; gap: 7px; cursor: pointer; padding: 2px 4px; border-radius: 5px; user-select: none; font-size: 11.5px; }
#legend .it:hover { background: rgba(255,255,255,.05); }
#legend .it.off { opacity: .35; }
#legend .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; box-shadow: 0 0 6px currentColor; }
#legend .n { margin-left: auto; font: 10.5px var(--mono); color: var(--muted); }

/* right column */
#right { position: fixed; top: 120px; right: 12px; bottom: 12px; width: 336px; display: flex; flex-direction: column; gap: 10px; pointer-events: none; }
#right > .card { position: static; pointer-events: auto; padding: 14px; }
#inspect { display: none; max-height: calc(100vh - 470px); min-height: 200px; overflow-y: auto; scrollbar-width: thin; }
#right.busy #stats { display: none; }
.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; margin-top: 10px; }
.kv div span { display: block; font: 600 9.5px var(--sans); letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
.kv div b { font: 500 13px var(--mono); }
.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; padding: 2px 8px; border-radius: 99px; background: rgba(255,255,255,.06); border: 1px solid var(--line); }
.chip i { width: 7px; height: 7px; border-radius: 50%; }
#inspect .acts { display: flex; gap: 6px; margin-top: 12px; }
#inspect .note { color: var(--muted); font-size: 11px; margin-top: 10px; }
#bplane { width: 100%; height: 170px; margin-top: 10px; background: rgba(0,0,0,.25); border-radius: 8px; border: 1px solid var(--line); }
#stats .big { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 10px; }
#stats .big div { background: rgba(0,0,0,.22); border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
#stats .big b { display: block; font: 600 16px var(--mono); }
#stats .big span { font-size: 10px; color: var(--muted); }
#kess svg { width: 100%; height: 178px; margin-top: 8px; overflow: visible; }
#kess .lg { display: grid; grid-template-columns: 1fr; gap: 2px; margin-top: 6px; font-size: 11px; }
#kess .lg div { display: flex; align-items: center; gap: 7px; }
#kess .lg i { width: 12px; height: 3px; border-radius: 2px; flex: none; }
#kess .lg span { margin-left: auto; font: 10.5px var(--mono); color: var(--ink-2); }
#ktip { position: fixed; pointer-events: none; display: none; padding: 7px 9px; font: 11px var(--mono); z-index: 9; }
.grow { flex: 1; min-height: 0; }

/* bottom status + overlays */
#status { left: 380px; right: 360px; bottom: 12px; height: 34px; display: flex; align-items: center; gap: 14px; padding: 0 12px; font-size: 11.5px; color: var(--ink-2); white-space: nowrap; overflow: hidden; }
#status .ok { color: var(--good); }
#status .hint { margin-left: auto; color: var(--muted); }
#tip { position: fixed; pointer-events: none; padding: 5px 8px; font: 11.5px var(--sans); display: none; z-index: 10; border-radius: 7px; }
.tag { position: fixed; pointer-events: none; font: 600 11px var(--sans); padding: 3px 7px; border-radius: 6px; white-space: nowrap;
  background: rgba(5, 9, 16, .82); border: 1px solid var(--line-2); transform: translate(12px, -50%); display: none; }
#tagB { transform: translate(calc(-100% - 12px), -50%); }
#dtag { transform: translate(-50%, 90%); font: 600 12px var(--mono); }
#loading { position: fixed; inset: 0; display: grid; place-items: center; background: radial-gradient(ellipse at center, #0a1224 0%, #03050a 70%); z-index: 50; transition: opacity .6s; }
#loading div { text-align: center; }
#loading b { display: block; font: 700 15px var(--sans); letter-spacing: .2em; margin-bottom: 10px; }
#loading span { color: var(--muted); font: 12px var(--mono); }
@media (max-width: 1500px) { #status .hint { display: none; } #brand small { display: none; } #brand { min-width: 0; } #search input { width: 190px; } }
@media (max-width: 1340px) { #search { display: none; } #controls button { padding: 5px 7px; } #clock .t { font-size: 15px; } #btnStart { display: none; } }
@media (max-height: 820px) { #right.busy #kess { display: none; } #inspect { max-height: calc(100vh - 140px); } }
@media (max-width: 1180px) { #kess { display: none; } }
@media (max-width: 900px) {
  #right, #status, #timeline, #clock .s { display: none; } #left { width: auto; right: 12px; top: auto; height: 42vh; }
  #brand small, #search { display: none; } #brand { min-width: 0; }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/satellite.js@5.0.0/dist/satellite.min.js"></script>
<script src="data.js"></script>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
               "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/" } }
</script>
</head>
<body>
<canvas id="c"></canvas>
<div id="loading"><div><b>ORBITAL TRAFFIC CONTROL</b><span id="loadmsg">loading catalogue...</span></div></div>

<div id="top" class="panel">
  <div id="brand">
    <svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true"><circle cx="15" cy="15" r="6" fill="#4cc9f0" opacity=".9"/><ellipse cx="15" cy="15" rx="13.5" ry="5.5" fill="none" stroke="#4cc9f0" stroke-width="1.3" transform="rotate(-28 15 15)" opacity=".7"/><circle cx="26.4" cy="9" r="1.8" fill="#fff"/></svg>
    <div><h1>ORBITAL TRAFFIC CONTROL</h1><small>LEO space-situational-awareness console &middot; L09</small></div>
  </div>
  <div id="search">
    <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="#fff" stroke-width="1.6"/><path d="M11 11l4 4" stroke="#fff" stroke-width="1.6"/></svg>
    <input id="q" placeholder="Find object: name or NORAD id  ( / )" autocomplete="off" aria-label="Find object">
    <div id="results" class="panel"></div>
  </div>
  <div id="clock"><div class="t mono" id="utc">--</div><div class="s" id="rel">--</div></div>
  <div id="controls">
    <button id="play" title="Play / pause (space)" aria-label="Play or pause">&#10074;&#10074;</button>
    <span id="warps"></span>
    <span class="sep"></span>
    <button id="btnStart" title="Jump to screening-window start">Window start</button>
    <button id="btnLive" title="Jump to wall-clock time (TLEs age!)">Live</button>
    <button id="btnEarth" title="Reset camera (Esc)">Earth view</button>
  </div>
</div>

<div id="timeline" class="panel"><span class="label">24 h screen</span><div id="track"><div class="base"></div><div id="head"></div></div></div>

<div id="left" class="panel">
  <header>
    <div class="label">Conjunction watch</div>
    <h2>Close approaches, next 24 h</h2>
    <div class="sub" id="conjsub"></div>
    <div id="sort"><button data-k="pc" class="on">Rank by Pc</button><button data-k="d">Miss distance</button><button data-k="t">Time</button></div>
  </header>
  <div id="list"></div>
  <div id="legend"><div class="label">Catalogue layers &middot; click to toggle</div><div class="grid" id="lg"></div></div>
</div>

<div id="right">
  <div id="inspect" class="panel card"></div>
  <div id="stats" class="panel card">
    <div class="label">Screening run (python, sgp4 + KD-tree)</div>
    <div class="big" id="bigstats"></div>
  </div>
  <div class="grow"></div>
  <div id="kess" class="panel card">
    <div class="label">Kessler outlook &middot; shell Monte Carlo</div>
    <div style="font-weight:600;margin-top:6px">LEO objects &ge; 10 cm, next 200 years</div>
    <svg id="ksvg" role="img" aria-label="Projected LEO debris population by scenario"></svg>
    <div class="lg" id="klg"></div>
  </div>
</div>

<div id="status" class="panel"><span id="xchk">cross-checking satellite.js...</span><span id="fps" class="mono"></span><span class="hint">drag: orbit &middot; scroll: zoom &middot; click a dot: inspect &middot; space: pause</span></div>
<div id="tip" class="panel"></div>
<div id="ktip" class="panel"></div>
<div id="tagA" class="tag"></div><div id="tagB" class="tag"></div><div id="dtag" class="tag"></div>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const D = window.SAT_DATA;
const RE = 6378.135, N = D.l1.length, T0 = D.meta.t0, WIN = D.meta.window_h * 3600e3;
const $ = s => document.querySelector(s);
const pad = n => String(n).padStart(2, '0');
const utc = t => new Date(t).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const hms = ms => { const s = Math.floor(Math.abs(ms) / 1000); return `${pad(Math.floor(s / 3600))}:${pad(Math.floor(s % 3600 / 60))}:${pad(s % 60)}`; };
const pcTxt = p => p < 1e-12 ? '<1e-12' : p.toExponential(1).replace('e', 'e');
const risk = p => p >= 1e-4 ? 'high' : p >= 1e-5 ? 'med' : p >= 1e-7 ? 'low' : 'min';
const RISKCOL = { high: '#ff4d5e', med: '#ffb020', low: '#e8d44d', min: '#56627a' };
const km = (x, d = 1) => x >= 1 ? `${x.toFixed(d)} km` : `${(x * 1000).toFixed(0)} m`;
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const jdOf = t => t / 86400000 + 2440587.5;
const toScene = (x, y, z, v) => v.set(x / RE, z / RE, -y / RE);     // ECI (z north) -> three (y up)

// ------------------------------------------------------------------ renderer & scene
const canvas = $('#c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 4000);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true; controls.dampingFactor = 0.08; controls.minDistance = 0.0008; controls.maxDistance = 60;
controls.rotateSpeed = 0.6; controls.zoomSpeed = 1.2;

// stars
{
  const n = 7000, p = new Float32Array(n * 3), c = new Float32Array(n * 3), v = new THREE.Vector3(), col = new THREE.Color();
  for (let i = 0; i < n; i++) {
    v.randomDirection().multiplyScalar(1500).toArray(p, i * 3);
    const b = Math.pow(Math.random(), 3) * 0.9 + 0.1;
    col.setHSL(0.55 + (Math.random() - 0.5) * 0.25, 0.35, b * 0.8).toArray(c, i * 3);
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(p, 3)); g.setAttribute('color', new THREE.BufferAttribute(c, 3));
  scene.add(new THREE.Points(g, new THREE.PointsMaterial({ size: 1.6, sizeAttenuation: false, vertexColors: true, depthWrite: false })));
}

// earth: day/night shader + atmosphere
const IMG = 'https://cdn.jsdelivr.net/npm/three-globe@2.31.0/example/img/';
const loader = new THREE.TextureLoader();
const tex = f => { const t = loader.load(IMG + f); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8; return t; };
const sunDir = new THREE.Vector3(1, 0, 0);
const earthMat = new THREE.ShaderMaterial({
  uniforms: { dayMap: { value: tex('earth-blue-marble.jpg') }, nightMap: { value: tex('earth-night.jpg') }, sunDir: { value: sunDir } },
  vertexShader: `varying vec2 vUv; varying vec3 vN; varying vec3 vVN; varying vec3 vVP;
    void main(){ vUv = uv; vN = normalize(mat3(modelMatrix) * normal); vVN = normalize(normalMatrix * normal);
      vec4 mv = modelViewMatrix * vec4(position, 1.0); vVP = mv.xyz; gl_Position = projectionMatrix * mv; }`,
  fragmentShader: `uniform sampler2D dayMap, nightMap; uniform vec3 sunDir; varying vec2 vUv; varying vec3 vN; varying vec3 vVN; varying vec3 vVP;
    void main(){
      float l = dot(normalize(vN), sunDir);
      float dayMix = smoothstep(-0.08, 0.12, l);
      vec3 day = texture2D(dayMap, vUv).rgb, night = texture2D(nightMap, vUv).rgb;
      vec3 lit = day * (0.10 + 1.05 * clamp(l + 0.1, 0.0, 1.0));
      vec3 dark = night * vec3(1.0, 0.78, 0.5) * 1.8 + day * 0.008;
      vec3 col = mix(dark, lit, dayMix);
      float rim = pow(1.0 - max(dot(normalize(vVN), normalize(-vVP)), 0.0), 5.0);
      col += vec3(0.25, 0.55, 1.0) * rim * (0.1 + 0.9 * dayMix) * 0.7;
      gl_FragColor = vec4(col, 1.0);
      #include <colorspace_fragment>
    }`
});
const earth = new THREE.Mesh(new THREE.SphereGeometry(1, 160, 80), earthMat);
scene.add(earth);
const atmo = new THREE.Mesh(new THREE.SphereGeometry(1.035, 96, 48), new THREE.ShaderMaterial({
  uniforms: { sunDir: { value: sunDir } }, side: THREE.BackSide, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
  vertexShader: `varying vec3 vVN; varying vec3 vN; void main(){ vVN = normalize(normalMatrix * normal); vN = normalize(mat3(modelMatrix)*normal);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
  fragmentShader: `uniform vec3 sunDir; varying vec3 vVN; varying vec3 vN; void main(){
    float i = pow(clamp(0.78 + dot(vVN, vec3(0.0, 0.0, 1.0)), 0.0, 1.0), 5.0);
    float d = 0.25 + 0.75 * smoothstep(-0.35, 0.35, dot(vN, sunDir));
    gl_FragColor = vec4(vec3(0.3, 0.6, 1.0) * i * d * 1.3, 1.0);
    #include <colorspace_fragment>
  }`
}));
scene.add(atmo);

// ------------------------------------------------------------------ satellites (SGP4 keyframes + Hermite)
const recs = [];
const catOf = Int8Array.from(D.cat);
const mask = new Array(10).fill(1);
const satGeo = new THREE.BufferGeometry();
const posAttr = new THREE.BufferAttribute(new Float32Array(N * 3), 3).setUsage(THREE.DynamicDrawUsage);
const colArr = new Float32Array(N * 3), catArr = new Float32Array(N);
{ const c = new THREE.Color(); for (let i = 0; i < N; i++) { c.set(D.cats[catOf[i]].color).toArray(colArr, i * 3); catArr[i] = catOf[i]; } }
satGeo.setAttribute('position', posAttr);
satGeo.setAttribute('color', new THREE.BufferAttribute(colArr, 3));
satGeo.setAttribute('aCat', new THREE.BufferAttribute(catArr, 1));
const satMat = new THREE.ShaderMaterial({
  uniforms: { uMask: { value: mask }, uPR: { value: renderer.getPixelRatio() }, uSize: { value: 3.2 } },
  transparent: true, depthWrite: false,
  vertexShader: `attribute vec3 color; attribute float aCat; uniform float uMask[10]; uniform float uPR; uniform float uSize;
    varying vec3 vC; varying float vK;
    void main(){ int k = int(aCat + 0.5); vC = color; vK = aCat;
      vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv;
      float s = uSize * (k == 8 ? 2.4 : (k == 9 ? 0.8 : 1.0)) * clamp(2.6 / -mv.z, 0.7, 4.5);
      gl_PointSize = uMask[k] > 0.5 && dot(position, position) > 0.5 ? s * uPR : 0.0; }`,
  fragmentShader: `varying vec3 vC; varying float vK;
    void main(){ vec2 p = gl_PointCoord - 0.5; float r = length(p); if (r > 0.5) discard;
      float core = smoothstep(0.5, 0.12, r); gl_FragColor = vec4(vC * (0.55 + 0.9 * core), core * (vK > 8.5 ? 0.75 : 0.95));
      #include <colorspace_fragment>
    }`
});
const sats = new THREE.Points(satGeo, satMat);
sats.frustumCulled = false;
scene.add(sats);

const mkKey = () => ({ t: 0, p: new Float64Array(N * 3), v: new Float64Array(N * 3), done: 0 });
let KA = mkKey(), KB = mkKey(), KC = mkKey();
function fill(K, t, i0 = 0, i1 = N) {
  K.t = t; const jd = jdOf(t), P = K.p, V = K.v;
  for (let i = i0; i < i1; i++) {
    const rec = recs[i], pv = satellite.sgp4(rec, (jd - rec.jdsatepoch) * 1440), o = 3 * i, p = pv.position;
    if (p && p.x === p.x) { const v = pv.velocity; P[o] = p.x; P[o + 1] = p.y; P[o + 2] = p.z; V[o] = v.x; V[o + 1] = v.y; V[o + 2] = v.z; }
    else P[o] = NaN;
  }
  K.done = i1;
}
function stateOf(i, t) {   // exact SGP4 state (km, km/s) or null
  const rec = recs[i], pv = satellite.sgp4(rec, (jdOf(t) - rec.jdsatepoch) * 1440);
  return pv.position && pv.position.x === pv.position.x ? pv : null;
}
const stepFor = w => (w <= 30 ? 60e3 : w <= 300 ? 120e3 : 300e3);
function resetKeys(t) { const s = stepFor(warp); fill(KA, t); fill(KB, t + s); KC.t = KB.t + s; KC.done = 0; }
function advanceKeys(frameMs) {
  if (simT < KA.t || simT > KB.t + stepFor(warp) * 2) return resetKeys(simT);
  while (simT >= KB.t) {
    if (KC.done < N) fill(KC, KC.t, KC.done, N);
    [KA, KB, KC] = [KB, KC, KA]; KC.t = KB.t + stepFor(warp); KC.done = 0;
  }
  if (KC.done < N) {
    const framesLeft = warp > 0 ? Math.max(1, (KB.t - simT) / (warp * frameMs)) : 90;
    fill(KC, KC.t, KC.done, Math.min(N, KC.done + Math.max(300, Math.ceil((N - KC.done) / framesLeft) + 150)));
  }
}
function interpolate() {
  const h = (KB.t - KA.t) / 1000, u = (simT - KA.t) / (KB.t - KA.t), u2 = u * u, u3 = u2 * u;
  const h00 = 2 * u3 - 3 * u2 + 1, h10 = (u3 - 2 * u2 + u) * h, h01 = -2 * u3 + 3 * u2, h11 = (u3 - u2) * h;
  const A = KA.p, Av = KA.v, B = KB.p, Bv = KB.v, P = posAttr.array, s = 1 / RE;
  for (let o = 0; o < 3 * N; o += 3) {
    const x = h00 * A[o] + h10 * Av[o] + h01 * B[o] + h11 * Bv[o];
    if (x !== x) { P[o] = P[o + 1] = P[o + 2] = 0; continue; }
    const y = h00 * A[o + 1] + h10 * Av[o + 1] + h01 * B[o + 1] + h11 * Bv[o + 1];
    const z = h00 * A[o + 2] + h10 * Av[o + 2] + h01 * B[o + 2] + h11 * Bv[o + 2];
    P[o] = x * s; P[o + 1] = z * s; P[o + 2] = -y * s;
  }
  posAttr.needsUpdate = true;
}

// ------------------------------------------------------------------ highlight objects: markers, trails, link
const markGeo = new THREE.BufferGeometry();
markGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
markGeo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(6), 3));
const markers = new THREE.Points(markGeo, new THREE.ShaderMaterial({
  uniforms: { uPR: { value: renderer.getPixelRatio() } }, transparent: true, depthTest: false,
  vertexShader: `attribute vec3 color; varying vec3 vC; uniform float uPR; void main(){ vC = color;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_PointSize = 26.0 * uPR; }`,
  fragmentShader: `varying vec3 vC; void main(){ float r = length(gl_PointCoord - 0.5);
    float a = smoothstep(0.05, 0.0, abs(r - 0.40)) + smoothstep(0.1, 0.0, r);
    if (a < 0.02) discard; gl_FragColor = vec4(vC, a);
    #include <colorspace_fragment>
  }`
}));
markers.frustumCulled = false; markers.renderOrder = 5; markers.visible = false;
scene.add(markers);
const TRAIL = 360;
function mkTrail() {
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(TRAIL * 3), 3));
  g.setAttribute('color', new THREE.BufferAttribute(new Float32Array(TRAIL * 4), 4));
  const l = new THREE.Line(g, new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, depthWrite: false }));
  l.frustumCulled = false; l.visible = false; scene.add(l); return l;
}
const trails = [mkTrail(), mkTrail()];
const link = new THREE.Line(new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3)),
  new THREE.LineBasicMaterial({ color: 0xff4d5e, transparent: true, opacity: 0.9, depthTest: false }));
link.frustumCulled = false; link.visible = false; link.renderOrder = 4; scene.add(link);
const SELCOL = ['#4cc9f0', '#ffb020'];

let sel = [];            // selected catalogue indices (1 object, or 2 for a conjunction)
let activeConj = null, follow = null, trailStamp = 0;
function updateTrails() {
  const v = new THREE.Vector3(), c = new THREE.Color();
  sel.forEach((i, k) => {
    const g = trails[k].geometry, P = g.attributes.position.array, C = g.attributes.color.array;
    const per = 2 * Math.PI / recs[i].no * 60e3;         // ms
    const span = Math.min(per, 6 * 3600e3);
    c.set(SELCOL[k]);
    for (let n = 0; n < TRAIL; n++) {
      const f = n / (TRAIL - 1), st = stateOf(i, simT - span * (1 - f));
      if (st) toScene(st.position.x, st.position.y, st.position.z, v).toArray(P, n * 3); else v.set(0, 0, 0).toArray(P, n * 3);
      C[n * 4] = c.r; C[n * 4 + 1] = c.g; C[n * 4 + 2] = c.b; C[n * 4 + 3] = 0.08 + 0.85 * f * f;
    }
    g.attributes.position.needsUpdate = g.attributes.color.needsUpdate = true;
    trails[k].visible = true;
  });
  for (let k = sel.length; k < 2; k++) trails[k].visible = false;
}
const tmpA = new THREE.Vector3(), tmpB = new THREE.Vector3();
function selPositions() {
  return sel.map(i => { const st = stateOf(i, simT); return st ? toScene(st.position.x, st.position.y, st.position.z, new THREE.Vector3()) : null; });
}

// ------------------------------------------------------------------ time
let simT = T0, warp = 30, playing = true, lastReal = performance.now();
const WARPS = [1, 3, 10, 30, 120, 600, 1800];
$('#warps').innerHTML = WARPS.map(w => `<button data-w="${w}">${w}&times;</button>`).join(' ');
function setWarp(w) { warp = w; document.querySelectorAll('#warps button').forEach(b => b.classList.toggle('on', +b.dataset.w === w)); }
function setPlaying(p) { playing = p; $('#play').innerHTML = p ? '&#10074;&#10074;' : '&#9654;'; }
function jumpTo(t) { simT = t; resetKeys(simT); trailStamp = 0; }
$('#warps').onclick = e => { if (e.target.dataset.w) { setWarp(+e.target.dataset.w); setPlaying(true); } };
$('#play').onclick = () => setPlaying(!playing);
$('#btnStart').onclick = () => jumpTo(T0);
$('#btnLive').onclick = () => { jumpTo(Date.now()); setWarp(1); };

// ------------------------------------------------------------------ camera moves
let flight = null;
function flyTo(targetFn, offset, dur = 1800) {
  flight = { t0: performance.now(), dur, fromT: controls.target.clone(), fromOff: camera.position.clone().sub(controls.target), toOff: offset, targetFn };
  follow = targetFn;
}
function earthView() {
  const off = sunDir.clone().applyAxisAngle(new THREE.Vector3(0, 1, 0), 1.75).setY(0.45).normalize().multiplyScalar(4.3);
  flyTo(() => new THREE.Vector3(), off);
}
$('#btnEarth').onclick = () => { clearSel(); earthView(); };

// ------------------------------------------------------------------ selection & panels
const catName = k => D.cats[k].name, catCol = k => D.cats[k].color;
function clearSel() { sel = []; activeConj = null; markers.visible = false; link.visible = false; updateTrails(); $('#inspect').style.display = 'none'; $('#right').classList.remove('busy');
  ['#tagA', '#tagB', '#dtag'].forEach(s => $(s).style.display = 'none'); document.querySelectorAll('.row.sel').forEach(r => r.classList.remove('sel')); }
function selectSat(i, fly = true) {
  activeConj = null; sel = [i]; link.visible = false; $('#dtag').style.display = 'none'; $('#tagB').style.display = 'none';
  document.querySelectorAll('.row.sel').forEach(r => r.classList.remove('sel'));
  updateTrails(); renderInspect();
  if (fly) {
    const f = () => selPositions()[0] || new THREE.Vector3();
    const p = f(), off = p.clone().normalize().multiplyScalar(0.55).add(new THREE.Vector3(0, 0.25, 0));
    flyTo(f, off);
  }
}
function focusConj(c) {
  activeConj = c; sel = [c.i, c.j];
  document.querySelectorAll('.row').forEach(r => r.classList.toggle('sel', +r.dataset.k === D.conj.indexOf(c)));
  jumpTo(c.t - 40e3); setWarp(3); setPlaying(true);
  updateTrails(); renderInspect();
  const mid = () => { const [a, b] = selPositions(); return a && b ? a.add(b).multiplyScalar(0.5) : (a || b || new THREE.Vector3()); };
  const sa = stateOf(c.i, c.t), sb = stateOf(c.j, c.t), up = mid().normalize();
  const w = toScene(sb.velocity.x - sa.velocity.x, sb.velocity.y - sa.velocity.y, sb.velocity.z - sa.velocity.z, new THREE.Vector3()).normalize();
  const side = new THREE.Vector3().crossVectors(up, w).normalize();   // horizontal, perpendicular to the approach
  flyTo(mid, up.multiplyScalar(0.07).add(side.multiplyScalar(0.15)), 2200);
}

function renderInspect() {
  const box = $('#inspect'); box.style.display = 'block'; $('#right').classList.add('busy');
  if (activeConj) {
    const c = activeConj, r = risk(c.pc);
    box.innerHTML = `<div class="label">Conjunction</div>
      <h2 style="font-size:14px">${esc(D.name[c.i])} <span style="color:var(--muted)">&times;</span> ${esc(D.name[c.j])}</h2>
      <div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
        <span class="chip"><i style="background:${SELCOL[0]}"></i>${esc(catName(D.cat[c.i]))}</span>
        <span class="chip"><i style="background:${SELCOL[1]}"></i>${esc(catName(D.cat[c.j]))}</span></div>
      <div class="kv">
        <div><span>TCA (UTC)</span><b>${utc(c.t).slice(11, 19)}</b></div><div><span>Miss distance</span><b>${km(c.d, 2)}</b></div>
        <div><span>Relative speed</span><b>${c.v.toFixed(2)} km/s</b></div><div><span>Altitude</span><b>${c.alt.toFixed(0)} km</b></div>
        <div><span>Pc (stated cov.)</span><b style="color:${RISKCOL[r]}">${pcTxt(c.pc)}</b></div><div><span>Pc max (any cov.)</span><b>${pcTxt(c.pcmax)}</b></div>
        <div><span>Miss R / T / N</span><b style="font-size:11.5px">${c.rtn.map(x => (x * 1000).toFixed(0)).join(' / ')} m</b></div><div><span>Hard-body radius</span><b>${c.hbr.toFixed(1)} m</b></div>
      </div>
      <svg id="bplane" viewBox="-100 -60 200 120"></svg>
      <div class="note">Encounter plane (normal to v<sub>rel</sub>): combined 1/2/3&sigma; TLE covariance, miss vector, hard body drawn to scale.
        Covariance assumed &sigma;<sub>RTN</sub> = (${D.meta.sig0[0]}, ${D.meta.sig0[1]} + ${D.meta.sig_rate[1]}&middot;age, ${D.meta.sig0[2]}) km per object.</div>
      <div class="acts"><button id="bReplay">Replay approach</button><button id="bClear">Close</button></div>`;
    drawBplane(c);
    $('#bReplay').onclick = () => focusConj(c);
  } else {
    const i = sel[0], rec = recs[i], k = D.cat[i];
    const ev = D.conj.filter(c => c.i === i || c.j === i).slice(0, 4);
    box.innerHTML = `<div class="label">Object</div><h2>${esc(D.name[i])}</h2>
      <div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px"><span class="chip"><i style="background:${catCol(k)}"></i>${esc(catName(k))}</span>
      <span class="chip mono">NORAD ${rec.satnum}</span>${D.owner[i] ? `<span class="chip">${esc(D.owner[i])}</span>` : ''}</div>
      <div class="kv">
        <div><span>Altitude</span><b id="iAlt">-</b></div><div><span>Speed</span><b id="iSpd">-</b></div>
        <div><span>Latitude</span><b id="iLat">-</b></div><div><span>Longitude</span><b id="iLon">-</b></div>
        <div><span>Perigee &times; apogee</span><b>${(rec.altp * RE).toFixed(0)} &times; ${(rec.alta * RE).toFixed(0)} km</b></div>
        <div><span>Inclination</span><b>${(rec.inclo * 180 / Math.PI).toFixed(2)}&deg;</b></div>
        <div><span>Period</span><b>${(2 * Math.PI / rec.no).toFixed(1)} min</b></div><div><span>Launched</span><b>${D.launch[i] || '-'}</b></div>
        <div><span>TLE age</span><b id="iAge">-</b></div><div><span>Eccentricity</span><b>${rec.ecco.toFixed(4)}</b></div>
      </div>
      ${ev.length ? `<div class="label" style="margin-top:12px">Its close approaches</div>` + ev.map(c => `<div class="row" data-c="${D.conj.indexOf(c)}" style="grid-template-columns:1fr auto">
        <div><div class="nm">&times; ${esc(D.name[c.i === i ? c.j : c.i])}</div><div class="mt">${utc(c.t).slice(11, 19)} &middot; <b>${km(c.d, 2)}</b></div></div>
        <div class="badge b-${risk(c.pc)}">${pcTxt(c.pc)}</div></div>`).join('') : ''}
      <div class="acts"><button id="bFollow" class="on">Following</button><button id="bClear">Close</button></div>`;
    box.querySelectorAll('.row[data-c]').forEach(r => r.onclick = () => focusConj(D.conj[+r.dataset.c]));
    $('#bFollow').onclick = e => { const on = !e.target.classList.contains('on'); e.target.classList.toggle('on', on); e.target.textContent = on ? 'Following' : 'Follow';
      follow = on ? () => selPositions()[0] || new THREE.Vector3() : null; };
  }
  $('#bClear').onclick = () => { clearSel(); earthView(); };
}
function liveInspect() {
  if (activeConj || !sel.length || !$('#iAlt')) return;
  const st = stateOf(sel[0], simT); if (!st) return;
  const g = satellite.eciToGeodetic(st.position, satellite.gstime(new Date(simT))), v = st.velocity;
  $('#iAlt').textContent = `${g.height.toFixed(1)} km`; $('#iSpd').textContent = `${Math.hypot(v.x, v.y, v.z).toFixed(3)} km/s`;
  $('#iLat').textContent = `${(g.latitude * 180 / Math.PI).toFixed(2)}°`;
  $('#iLon').textContent = `${(((g.longitude * 180 / Math.PI) + 540) % 360 - 180).toFixed(2)}°`;
  $('#iAge').textContent = `${((jdOf(simT) - recs[sel[0]].jdsatepoch)).toFixed(2)} d`;
}
function drawBplane(c) {
  const [sxx, sxy, syy] = c.c2.map(x => x * 1e6), tr = sxx + syy, det = sxx * syy - sxy * sxy;
  const l1 = tr / 2 + Math.sqrt(tr * tr / 4 - det), l2 = tr / 2 - Math.sqrt(tr * tr / 4 - det);
  const ang = Math.atan2(l1 - sxx, sxy || 1e-12) * 180 / Math.PI, d = c.d * 1000;
  const sc = 55 / Math.max(3 * Math.sqrt(l1), d * 1.15);
  let s = `<line x1="-100" y1="0" x2="100" y2="0" stroke="#1f2a40"/><line x1="0" y1="-60" x2="0" y2="60" stroke="#1f2a40"/>`;
  for (const k of [3, 2, 1]) s += `<ellipse cx="0" cy="0" rx="${k * Math.sqrt(l1) * sc}" ry="${k * Math.sqrt(Math.max(l2, 0)) * sc}" transform="rotate(${-ang})" fill="rgba(76,201,240,${0.05 * (4 - k)})" stroke="#4cc9f0" stroke-opacity="${0.25 * (4 - k)}" stroke-width="0.8"/>`;
  s += `<line x1="0" y1="0" x2="${d * sc}" y2="0" stroke="#ffb020" stroke-width="1" stroke-dasharray="2 2"/>`;
  s += `<circle cx="${d * sc}" cy="0" r="${Math.max(c.hbr * sc, 0.6)}" fill="#ff4d5e"/><circle cx="${d * sc}" cy="0" r="4" fill="none" stroke="#ff4d5e" stroke-width="0.8"/>`;
  s += `<text x="${d * sc}" y="-8" fill="#e6ebf5" font-size="7" text-anchor="middle" font-family="JetBrains Mono">${d.toFixed(0)} m</text>`;
  s += `<text x="-96" y="55" fill="#6f7b93" font-size="6.5" font-family="Inter">1&sigma; = ${Math.sqrt(l1).toFixed(0)} &times; ${Math.sqrt(Math.max(l2, 0)).toFixed(0)} m</text>`;
  $('#bplane').innerHTML = s;
}

// ------------------------------------------------------------------ conjunction list & timeline
let sortKey = 'pc';
function renderList() {
  const arr = D.conj.map((c, k) => [c, k]);
  if (sortKey === 'pc') arr.sort((a, b) => b[0].pc - a[0].pc);
  if (sortKey === 'd') arr.sort((a, b) => a[0].d - b[0].d);
  if (sortKey === 't') arr.sort((a, b) => a[0].t - b[0].t);
  $('#list').innerHTML = arr.slice(0, 80).map(([c, k], n) => `<div class="row" data-k="${k}" role="button" tabindex="0">
    <div class="rk">${n + 1}</div>
    <div style="min-width:0"><div class="nm" title="${esc(D.name[c.i])} x ${esc(D.name[c.j])}">${esc(D.name[c.i])}<i>&times;</i>${esc(D.name[c.j])}</div>
      <div class="mt"><span class="cd" data-t="${c.t}"></span> &middot; <b>${km(c.d, 2)}</b> &middot; ${c.v.toFixed(1)} km/s</div></div>
    <div class="badge b-${risk(c.pc)}">${pcTxt(c.pc)}<small>${{ high: 'HIGH', med: 'MED', low: 'LOW', min: 'MIN' }[risk(c.pc)]}</small></div></div>`).join('');
  $('#list').querySelectorAll('.row').forEach(r => { const go = () => focusConj(D.conj[+r.dataset.k]); r.onclick = go; r.onkeydown = e => { if (e.key === 'Enter') go(); }; });
  if (activeConj) $(`#list .row[data-k="${D.conj.indexOf(activeConj)}"]`)?.classList.add('sel');
  countdowns();
}
function countdowns() {
  document.querySelectorAll('#list .cd').forEach(el => {
    const dt = +el.dataset.t - simT;
    el.textContent = dt >= 0 ? `in ${hms(dt)}` : `${hms(dt)} ago`;
    el.closest('.row').classList.toggle('past', dt < -60e3);
  });
}
$('#sort').onclick = e => { if (!e.target.dataset.k) return; sortKey = e.target.dataset.k;
  document.querySelectorAll('#sort button').forEach(b => b.classList.toggle('on', b === e.target)); renderList(); $('#list').scrollTop = 0; };
{
  const S = D.stats, tr = $('#track');
  $('#conjsub').innerHTML = `${S.encounters.toLocaleString()} encounters &lt; ${D.meta.d_screen} km &middot; ${S.lt1.toLocaleString()} &lt; 1 km &middot; ${S.pc4} with Pc &gt; 1e-4`;
  for (let h = 0; h <= D.meta.window_h; h += 3) tr.insertAdjacentHTML('beforeend', `<div class="hr" style="left:${h / D.meta.window_h * 100}%">${pad(new Date(T0 + h * 3600e3).getUTCHours())}:00</div>`);
  [...D.conj].sort((a, b) => a.pc - b.pc).forEach(c => {
    const x = (c.t - T0) / WIN; if (x < 0 || x > 1) return;
    const el = document.createElement('div'); el.className = 'tick'; el.style.left = `${x * 100}%`; el.style.background = RISKCOL[risk(c.pc)];
    el.title = `${D.name[c.i]} x ${D.name[c.j]} | ${km(c.d, 2)} | Pc ${pcTxt(c.pc)}`;
    el.onclick = e => { e.stopPropagation(); focusConj(c); }; tr.appendChild(el);
  });
  tr.onclick = e => { const r = tr.getBoundingClientRect(); jumpTo(T0 + (e.clientX - r.left) / r.width * WIN); };
  const big = [[(S.states / 1e6).toFixed(1) + ' M', 'SGP4 states'], [S.n_objects.toLocaleString(), 'objects screened'], [S.steps.toLocaleString(), `epochs, ${D.meta.dt} s step`],
    [S.encounters.toLocaleString(), `pairs < ${D.meta.d_screen} km`], [S.lt1.toLocaleString(), 'pairs < 1 km'], [S.pc5.toLocaleString(), 'Pc > 1e-5']];
  $('#bigstats').innerHTML = big.map(([a, b]) => `<div><b>${a}</b><span>${b}</span></div>`).join('');
}

// legend
$('#lg').innerHTML = D.cats.map((c, k) => `<div class="it" data-k="${k}" role="button" tabindex="0"><span class="dot" style="background:${c.color};color:${c.color}"></span>${esc(c.name)}<span class="n">${c.n.toLocaleString()}</span></div>`).join('');
$('#lg').querySelectorAll('.it').forEach(el => { const t = () => { const k = +el.dataset.k; mask[k] = 1 - mask[k]; el.classList.toggle('off', !mask[k]); };
  el.onclick = t; el.onkeydown = e => { if (e.key === 'Enter') t(); }; });

// ------------------------------------------------------------------ Kessler chart (SVG, log y, hover crosshair)
{
  const K = D.kessler, COL = ['#3987e5', '#d95926', '#199e70', '#c98500'];
  const svg = $('#ksvg'), W = 300, H = 170, L = 38, R = 8, T = 6, B = 20;
  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
  const n = K.scen[0].p50.length, yr = k => K.year0 + k * K.step;
  const lo = Math.min(...K.scen.flatMap(s => s.p05)), hi = Math.max(...K.scen.flatMap(s => s.p95));
  const l0 = Math.floor(Math.log10(lo)), l1 = Math.ceil(Math.log10(hi));
  const X = k => L + k / (n - 1) * (W - L - R), Y = v => T + (1 - (Math.log10(v) - l0) / (l1 - l0)) * (H - T - B);
  let s = '';
  for (let e = l0; e <= l1; e++) s += `<line x1="${L}" x2="${W - R}" y1="${Y(10 ** e)}" y2="${Y(10 ** e)}" stroke="#1c2436"/><text x="${L - 5}" y="${Y(10 ** e) + 3}" fill="#6f7b93" font-size="8.5" text-anchor="end" font-family="JetBrains Mono">${e >= 6 ? 10 ** (e - 6) + 'M' : e >= 3 ? 10 ** (e - 3) + 'k' : 10 ** e}</text>`;
  for (let k = 0; k < n; k += 25) s += `<text x="${X(k)}" y="${H - 5}" fill="#6f7b93" font-size="8.5" text-anchor="middle" font-family="JetBrains Mono">${Math.round(yr(k))}</text>`;
  K.scen.forEach((sc, j) => {
    const band = sc.p95.map((v, k) => `${X(k)},${Y(v)}`).concat(sc.p05.map((v, k) => `${X(k)},${Y(v)}`).reverse()).join(' ');
    s += `<polygon points="${band}" fill="${COL[j]}" opacity=".18"/>`;
    s += `<polyline points="${sc.p50.map((v, k) => `${X(k)},${Y(v)}`).join(' ')}" fill="none" stroke="${COL[j]}" stroke-width="2" stroke-linejoin="round"/>`;
  });
  s += `<line id="kx" y1="${T}" y2="${H - B}" stroke="#aab4c8" stroke-width=".8" visibility="hidden"/>`;
  svg.innerHTML = s;
  const f = v => v >= 1e6 ? (v / 1e6).toFixed(1) + 'M' : v >= 1e3 ? (v / 1e3).toFixed(0) + 'k' : v.toFixed(0);
  $('#klg').innerHTML = K.scen.map((sc, j) => `<div><i style="background:${COL[j]}"></i>${esc(sc.name)}<span>${f(sc.p50[n - 1])}</span></div>`).join('');
  const tip = $('#ktip'), kx = $('#kx');
  svg.onmousemove = e => {
    const r = svg.getBoundingClientRect(), x = (e.clientX - r.left) / r.width * W, k = Math.round((x - L) / (W - L - R) * (n - 1));
    if (k < 0 || k >= n) { tip.style.display = 'none'; kx.setAttribute('visibility', 'hidden'); return; }
    kx.setAttribute('x1', X(k)); kx.setAttribute('x2', X(k)); kx.setAttribute('visibility', 'visible');
    tip.innerHTML = `<b>${Math.round(yr(k))}</b><br>` + K.scen.map((sc, j) => `<span style="color:${COL[j]}">&#9632;</span> ${f(sc.p50[k])} <span style="color:#6f7b93">(${f(sc.p05[k])}-${f(sc.p95[k])})</span>`).join('<br>');
    tip.style.display = 'block'; tip.style.left = `${Math.min(e.clientX + 14, innerWidth - 230)}px`; tip.style.top = `${e.clientY - 40}px`;
  };
  svg.onmouseleave = () => { tip.style.display = 'none'; kx.setAttribute('visibility', 'hidden'); };
}

// ------------------------------------------------------------------ picking & search
const vp = new THREE.Vector3(), camPos = new THREE.Vector3();
function pick(cx, cy, maxPx = 9) {
  const r = canvas.getBoundingClientRect(), P = posAttr.array; camera.getWorldPosition(camPos);
  let best = -1, bd = maxPx * maxPx;
  for (let i = 0; i < N; i++) {
    if (!mask[catOf[i]]) continue;
    const o = 3 * i; if (P[o] === 0 && P[o + 1] === 0) continue;
    vp.set(P[o], P[o + 1], P[o + 2]).project(camera);
    if (vp.z > 1) continue;
    const dx = (vp.x + 1) / 2 * r.width - (cx - r.left), dy = (1 - vp.y) / 2 * r.height - (cy - r.top), d2 = dx * dx + dy * dy;
    if (d2 >= bd) continue;
    const dir = tmpA.set(P[o], P[o + 1], P[o + 2]).sub(camPos), a = dir.dot(dir), b = 2 * camPos.dot(dir), c = camPos.dot(camPos) - 1, disc = b * b - 4 * a * c;
    if (disc > 0) { const s1 = (-b - Math.sqrt(disc)) / (2 * a); if (s1 > 0 && s1 < 1) continue; }   // hidden behind Earth
    bd = d2; best = i;
  }
  return best;
}
let down = null;
canvas.addEventListener('pointerdown', e => { down = [e.clientX, e.clientY]; });
canvas.addEventListener('pointerup', e => {
  if (down && Math.hypot(e.clientX - down[0], e.clientY - down[1]) < 5) { const i = pick(e.clientX, e.clientY); if (i >= 0) selectSat(i); }
  down = null;
});
controls.addEventListener('start', () => { if (flight) flight = null; });
let hoverT = 0;
canvas.addEventListener('pointermove', e => {
  const now = performance.now(); if (now - hoverT < 60 || e.buttons) return; hoverT = now;
  const i = pick(e.clientX, e.clientY), tip = $('#tip');
  if (i < 0) { tip.style.display = 'none'; canvas.style.cursor = ''; return; }
  canvas.style.cursor = 'pointer';
  tip.innerHTML = `<b>${esc(D.name[i])}</b> <span style="color:${catCol(D.cat[i])}">&#9679;</span> <span style="color:var(--muted)">${esc(catName(D.cat[i]))}</span>`;
  tip.style.display = 'block'; tip.style.left = `${e.clientX + 14}px`; tip.style.top = `${e.clientY + 12}px`;
});
const NAMES = D.name.map(s => s.toUpperCase());
let hits = [], hitSel = 0;
function showResults() {
  const res = $('#results');
  res.style.display = hits.length ? 'block' : 'none';
  res.innerHTML = hits.map((i, k) => `<div data-i="${i}" class="${k === hitSel ? 'act' : ''}">${esc(D.name[i])}<span>${recs[i].satnum}</span></div>`).join('');
  res.querySelectorAll('div').forEach(d => d.onmousedown = () => { selectSat(+d.dataset.i); $('#q').blur(); res.style.display = 'none'; });
}
$('#q').oninput = e => {
  const q = e.target.value.trim().toUpperCase(); hits = []; hitSel = 0;
  if (q.length >= 2) for (let i = 0; i < N && hits.length < 8; i++) if (NAMES[i].includes(q) || String(+recs[i].satnum) === q) hits.push(i);
  showResults();
};
$('#q').onkeydown = e => {
  if (e.key === 'ArrowDown') { hitSel = Math.min(hitSel + 1, hits.length - 1); showResults(); e.preventDefault(); }
  if (e.key === 'ArrowUp') { hitSel = Math.max(hitSel - 1, 0); showResults(); e.preventDefault(); }
  if (e.key === 'Enter' && hits.length) { selectSat(hits[hitSel]); e.target.blur(); $('#results').style.display = 'none'; }
  if (e.key === 'Escape') { e.target.blur(); $('#results').style.display = 'none'; }
};
$('#q').onblur = () => setTimeout(() => $('#results').style.display = 'none', 150);
addEventListener('keydown', e => {
  if (e.target.tagName === 'INPUT') return;
  if (e.key === ' ') { setPlaying(!playing); e.preventDefault(); }
  if (e.key === '/') { $('#q').focus(); e.preventDefault(); }
  if (e.key === 'Escape') { clearSel(); earthView(); }
  const w = WARPS.indexOf(warp);
  if (e.key === '.' || e.key === '>') setWarp(WARPS[Math.min(w + 1, WARPS.length - 1)]);
  if (e.key === ',' || e.key === '<') setWarp(WARPS[Math.max(w - 1, 0)]);
});

// ------------------------------------------------------------------ frame loop
function sunECI(t) {
  const n = jdOf(t) - 2451545.0, rad = Math.PI / 180;
  const L = (280.460 + 0.9856474 * n) * rad, g = (357.528 + 0.9856003 * n) * rad;
  const lam = L + (1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * rad, eps = (23.439 - 4e-7 * n) * rad;
  return [Math.cos(lam), Math.cos(eps) * Math.sin(lam), Math.sin(eps) * Math.sin(lam)];
}
function resize() { const w = innerWidth, h = innerHeight; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); }
addEventListener('resize', resize);
function placeTag(el, v, text) {
  const p = v.clone().project(camera);
  if (p.z > 1) { el.style.display = 'none'; return; }
  el.style.display = 'block'; el.style.left = `${(p.x + 1) / 2 * innerWidth}px`; el.style.top = `${(1 - p.y) / 2 * innerHeight}px`;
  if (text !== undefined) el.innerHTML = text;
}
let fpsN = 0, fpsT = performance.now(), secT = 0;
function frame(now) {
  const dtReal = Math.min(now - lastReal, 100); lastReal = now;
  if (playing) simT += dtReal * warp;
  advanceKeys(Math.max(dtReal, 8));
  interpolate();
  const gmst = satellite.gstime(new Date(simT));
  earth.rotation.y = gmst;
  const s = sunECI(simT); toScene(s[0] * RE, s[1] * RE, s[2] * RE, sunDir).normalize();

  // highlights
  if (sel.length) {
    const ps = selPositions(), M = markGeo.attributes.position.array, C = markGeo.attributes.color.array, c = new THREE.Color();
    ps.forEach((p, k) => { if (p) p.toArray(M, k * 3); c.set(SELCOL[k]).toArray(C, k * 3); });
    markGeo.setDrawRange(0, ps.length); markGeo.attributes.position.needsUpdate = markGeo.attributes.color.needsUpdate = true; markers.visible = true;
    if (now - trailStamp > 400) { updateTrails(); trailStamp = now; }
    placeTag($('#tagA'), ps[0] || new THREE.Vector3(), esc(D.name[sel[0]]));
    $('#tagA').style.borderColor = SELCOL[0];
    if (activeConj && ps[0] && ps[1]) {
      const L = link.geometry.attributes.position.array; ps[0].toArray(L, 0); ps[1].toArray(L, 3); link.geometry.attributes.position.needsUpdate = true;
      const dkm = ps[0].distanceTo(ps[1]) * RE, dt = activeConj.t - simT;
      link.material.color.set(dkm < 5 ? '#ff4d5e' : dkm < 50 ? '#ffb020' : '#4cc9f0'); link.visible = true;
      placeTag($('#tagB'), ps[1], esc(D.name[sel[1]])); $('#tagB').style.borderColor = SELCOL[1];
      placeTag($('#dtag'), ps[0].clone().add(ps[1]).multiplyScalar(0.5),
        `&Delta; ${dkm < 1 ? (dkm * 1000).toFixed(0) + ' m' : dkm.toFixed(dkm < 100 ? 2 : 0) + ' km'} <span style="color:var(--muted)">TCA ${dt >= 0 ? '&minus;' : '+'}${hms(dt)}</span>`);
      $('#dtag').style.borderColor = dkm < 5 ? '#ff4d5e' : 'rgba(120,160,255,.28)';
    }
  }

  // camera: fly / follow
  if (flight) {
    const k = Math.min((now - flight.t0) / flight.dur, 1), e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
    const tgt = flight.targetFn();
    controls.target.lerpVectors(flight.fromT, tgt, e);
    camera.position.copy(controls.target).add(tmpB.lerpVectors(flight.fromOff, flight.toOff, e));
    if (k >= 1) flight = null;
  } else if (follow) {
    const p = follow(), d = tmpB.copy(p).sub(controls.target);
    controls.target.copy(p); camera.position.add(d);
  }
  controls.update();
  const dist = camera.position.distanceTo(controls.target);
  const near = THREE.MathUtils.clamp(dist * 0.05, 1e-5, 0.05);
  if (Math.abs(near - camera.near) / camera.near > 0.05) { camera.near = near; camera.updateProjectionMatrix(); }
  renderer.render(scene, camera);

  $('#utc').textContent = utc(simT);
  const rel = simT - T0;
  $('#rel').textContent = `T${rel >= 0 ? '+' : '-'}${hms(rel)} from window start · ${warp}×${playing ? '' : ' (paused)'}`;
  $('#head').style.left = `${THREE.MathUtils.clamp(rel / WIN, 0, 1) * 100}%`;
  if (activeConj === null && sel.length && now - secT > 250) liveInspect();
  if (now - secT > 250) { countdowns(); secT = now; }
  fpsN++; if (now - fpsT > 1000) { $('#fps').textContent = `${Math.round(fpsN * 1000 / (now - fpsT))} fps · ${N.toLocaleString()} objects`; fpsN = 0; fpsT = now; }
  requestAnimationFrame(frame);
}

// ------------------------------------------------------------------ boot
function crosscheck() {
  const X = D.xcheck; let mx = 0, n = 0;
  X.idx.forEach((k, m) => X.ms.forEach((ms, q) => {
    const p = satellite.propagate(recs[k], new Date(ms)).position, r = X.pos[m][q];
    mx = Math.max(mx, Math.hypot(p.x - r[0], p.y - r[1], p.z - r[2])); n++;
  }));
  const ok = mx < 1;
  $('#xchk').innerHTML = `<span class="${ok ? 'ok' : ''}">${ok ? '&#10003;' : '&#10007;'}</span> satellite.js vs python-sgp4: max &Delta; ${(mx * 1000).toFixed(2)} m over ${n} states`;
}
async function boot() {
  const msg = $('#loadmsg');
  for (let i = 0; i < N; i += 2000) {
    for (let k = i; k < Math.min(N, i + 2000); k++) recs.push(satellite.twoline2satrec(D.l1[k], D.l2[k]));
    msg.textContent = `parsing ${recs.length.toLocaleString()} / ${N.toLocaleString()} TLEs...`;
    await new Promise(r => setTimeout(r));
  }
  crosscheck();
  resize(); setWarp(warp); setPlaying(true); renderList();
  resetKeys(simT); interpolate();
  const s = sunECI(simT); toScene(s[0], s[1], s[2], sunDir).normalize();
  const off = sunDir.clone().applyAxisAngle(new THREE.Vector3(0, 1, 0), 1.75).setY(0.45).normalize().multiplyScalar(4.3);
  camera.position.copy(off); controls.target.set(0, 0, 0);
  const url = new URLSearchParams(location.search);
  if (url.has('conj')) focusConj([...D.conj].sort((a, b) => b.pc - a.pc)[+url.get('conj') || 0]);
  requestAnimationFrame(t => { lastReal = t; frame(t); });
  $('#loading').style.opacity = 0; setTimeout(() => $('#loading').remove(), 700);
}
boot();
</script>
</body>
</html>