diff --git a/backend/main.py b/backend/main.py index 37b7cc8..10028d6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,9 +12,12 @@ import asyncio import logging +import os +import time from contextlib import asynccontextmanager from pathlib import Path +import numpy as np from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware @@ -46,16 +49,109 @@ def get_engine() -> SimulationEngine: return engine +# ── Auto-Seed ──────────────────────────────────────────────────────────── + +def _auto_seed(eng: SimulationEngine) -> None: + """Seed the engine with demo data so the dashboard is populated on boot. + + Runs synchronously during startup (before the server accepts requests). + Controlled by ACM_AUTO_SEED env var (defaults to "1" = enabled). + """ + if os.environ.get("ACM_AUTO_SEED", "1") == "0": + logger.info("AUTO_SEED | Skipped (ACM_AUTO_SEED=0)") + return + + from generate_telemetry import build_telemetry_payload, generate_satellite_batch + + logger.info("AUTO_SEED | Generating 50 satellites + 10,000 debris (LEO mode)...") + t0 = time.perf_counter() + + payload = build_telemetry_payload( + n_satellites=50, + n_debris=10_000, + mode="leo", + seed=42, + timestamp="2026-03-12T08:00:00.000Z", + ) + + # Inject 20 threat debris on near-collision courses + sat_objects = [o for o in payload["objects"] if o["type"] == "SATELLITE"] + threats = _generate_threat_debris(sat_objects) + payload["objects"].extend(threats) + + gen_t = time.perf_counter() - t0 + logger.info("AUTO_SEED | Generated %d objects in %.2fs", len(payload["objects"]), gen_t) + + # Ingest directly into the engine (no HTTP round-trip) + t0 = time.perf_counter() + result = eng.ingest_telemetry(payload["timestamp"], payload["objects"]) + logger.info( + "AUTO_SEED | Ingested %d objects | CDMs: %d | %.2fs", + result["processed_count"], result["active_cdm_warnings"], + time.perf_counter() - t0, + ) + + # Run 5 simulation steps (600s each = 50 min) to activate the full pipeline + logger.info("AUTO_SEED | Running 5 x 600s simulation steps...") + for i in range(5): + t0 = time.perf_counter() + step_result = eng.step(600) + logger.info( + "AUTO_SEED | Step %d/5 | collisions=%d maneuvers=%d | %.2fs", + i + 1, + step_result.get("collisions_detected", 0), + step_result.get("maneuvers_executed", 0), + time.perf_counter() - t0, + ) + + logger.info("AUTO_SEED | Complete — dashboard ready") + + +def _generate_threat_debris(satellites: list[dict], n_per_sat: int = 1) -> list[dict]: + """Create debris on near-collision courses with the first 20 satellites.""" + rng = np.random.default_rng(99) + threats = [] + targets = satellites[:min(20, len(satellites))] + + for sat in targets: + r_sat = np.array([sat["r"]["x"], sat["r"]["y"], sat["r"]["z"]]) + v_sat = np.array([sat["v"]["x"], sat["v"]["y"], sat["v"]["z"]]) + + for j in range(n_per_sat): + offset_dir = rng.normal(0, 1, 3) + offset_dir /= np.linalg.norm(offset_dir) + offset_km = rng.uniform(2.0, 8.0) + r_deb = r_sat + offset_dir * offset_km + + closing_speed = rng.uniform(0.001, 0.005) + v_deb = v_sat - offset_dir * closing_speed + v_deb += rng.normal(0, 0.0005, 3) + + threats.append({ + "id": f"THREAT-{sat['id']}-{j:02d}", + "type": "DEBRIS", + "r": {"x": float(r_deb[0]), "y": float(r_deb[1]), "z": float(r_deb[2])}, + "v": {"x": float(v_deb[0]), "y": float(v_deb[1]), "z": float(v_deb[2])}, + }) + + return threats + + # ── Lifespan ───────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): - """Initialize SimulationEngine on startup, cleanup on shutdown.""" + """Initialize SimulationEngine on startup, auto-seed, cleanup on shutdown.""" global engine logger.info("ACM-Orbital starting up — initializing SimulationEngine") engine = SimulationEngine() app.state.engine = engine app.state.engine_lock = engine_lock + + # Auto-seed in a thread so we don't block the event loop + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _auto_seed, engine) + yield logger.info("ACM-Orbital shutting down") engine = None diff --git a/backend/seed_demo.py b/backend/seed_demo.py new file mode 100644 index 0000000..0c5f860 --- /dev/null +++ b/backend/seed_demo.py @@ -0,0 +1,171 @@ +""" +seed_demo.py — Populate the running ACM backend with demo data via its REST API. + +Usage (while backend is running on port 8000): + python backend/seed_demo.py + +This sends: + 1. POST /api/telemetry — 50 satellites + 10,000 debris (realistic LEO orbits) + PLUS ~20 "threat" debris deliberately placed on near-collision courses + 2. POST /api/simulate/step — several steps to trigger the full physics pipeline: + propagation, conjunction assessment, auto-evasion, fuel burn, recovery + +After running, the frontend dashboard should show: + - Satellite positions + debris cloud on the Ground Track + - Active CDMs on the Bullseye Plot + - Evasion/recovery burns on the Maneuver Timeline + - Fuel depletion on the Fuel Heatmap + - Delta-V cost curve on the DeltaV Chart +""" + +import sys +import math +import time +import json + +import numpy as np + +try: + import httpx +except ImportError: + print("httpx not installed — falling back to urllib") + httpx = None + +sys.path.insert(0, "backend") +from generate_telemetry import build_telemetry_payload, generate_satellite_batch + +API = "http://localhost:8000/api" + + +def post(endpoint, payload): + """POST JSON to the API and return the response dict.""" + url = f"{API}/{endpoint}" + if httpx: + r = httpx.post(url, json=payload, timeout=120.0) + r.raise_for_status() + return r.json() + else: + import urllib.request + data = json.dumps(payload).encode() + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=120) as resp: + return json.loads(resp.read()) + + +def generate_threat_debris(satellites, n_threats_per_sat=1, seed=99): + """ + Create debris objects on near-collision courses with satellites. + + For each targeted satellite, places debris ~2-8 km away with a + relative velocity vector that will close the distance within + the 24-hour conjunction lookahead window. This guarantees CDM + generation and triggers the full evasion pipeline. + """ + rng = np.random.default_rng(seed) + threats = [] + + # Target the first 20 satellites for maximum dashboard activity + targets = satellites[:min(20, len(satellites))] + + for sat in targets: + r_sat = np.array([sat["r"]["x"], sat["r"]["y"], sat["r"]["z"]]) + v_sat = np.array([sat["v"]["x"], sat["v"]["y"], sat["v"]["z"]]) + + for j in range(n_threats_per_sat): + # Place debris 2-8 km away in a random direction + offset_dir = rng.normal(0, 1, 3) + offset_dir /= np.linalg.norm(offset_dir) + offset_km = rng.uniform(2.0, 8.0) + r_deb = r_sat + offset_dir * offset_km + + # Give it a velocity that converges toward the satellite + # Start with the satellite's velocity, then add a small + # closing component (retrograde intercept approach) + closing_speed = rng.uniform(0.001, 0.005) # 1-5 m/s closing + v_deb = v_sat - offset_dir * closing_speed + + # Add some cross-track perturbation for realism + perturb = rng.normal(0, 0.0005, 3) # ~0.5 m/s random + v_deb += perturb + + threats.append({ + "id": f"THREAT-{sat['id']}-{j:02d}", + "type": "DEBRIS", + "r": {"x": float(r_deb[0]), "y": float(r_deb[1]), "z": float(r_deb[2])}, + "v": {"x": float(v_deb[0]), "y": float(v_deb[1]), "z": float(v_deb[2])}, + }) + + return threats + + +def main(): + print("=" * 60) + print(" ACM-Orbital Demo Seeder (with threat debris)") + print("=" * 60) + + # Step 1: Generate base telemetry + print("\n[1/3] Generating 50 satellites + 10,000 debris (LEO mode)...") + t0 = time.perf_counter() + payload = build_telemetry_payload( + n_satellites=50, + n_debris=10_000, + mode="leo", + seed=42, + timestamp="2026-03-12T08:00:00.000Z", + ) + + # Extract satellite objects for threat generation + sat_objects = [o for o in payload["objects"] if o["type"] == "SATELLITE"] + + gen_t = time.perf_counter() - t0 + print(f" Generated {len(payload['objects']):,} objects in {gen_t:.2f}s") + + # Step 2: Add threat debris on collision courses + print("\n[2/3] Injecting 20 threat debris on near-collision courses...") + threats = generate_threat_debris(sat_objects, n_threats_per_sat=1, seed=99) + payload["objects"].extend(threats) + print(f" Added {len(threats)} threats targeting SAT-000 through SAT-019") + print(f" Total objects: {len(payload['objects']):,}") + + # Ingest everything + print("\n Sending POST /api/telemetry ...") + t0 = time.perf_counter() + result = post("telemetry", payload) + ingest_t = time.perf_counter() - t0 + print(f" -> status={result['status']} | " + f"processed={result['processed_count']} | " + f"CDM warnings={result['active_cdm_warnings']}") + print(f" Ingested in {ingest_t:.2f}s") + + # Step 3: Run simulation steps + print("\n[3/3] Running 5 simulation steps (600s each = 50 min total)...") + print(" This triggers: propagation -> conjunction assessment -> " + "auto-evasion -> fuel burn -> recovery\n") + total_collisions = 0 + total_maneuvers = 0 + for i in range(5): + print(f" Step {i+1}/5: POST /api/simulate/step (600s) ...") + t0 = time.perf_counter() + result = post("simulate/step", {"step_seconds": 600}) + step_t = time.perf_counter() - t0 + total_collisions += result.get("collisions_detected", 0) + total_maneuvers += result.get("maneuvers_executed", 0) + print(f" -> t={result['new_timestamp']} | " + f"collisions={result['collisions_detected']} | " + f"maneuvers={result['maneuvers_executed']} | {step_t:.2f}s") + + # Summary + print("\n" + "=" * 60) + print(" SEED COMPLETE — Dashboard should now show:") + print(f" Satellites: 50 (positions on Ground Track)") + print(f" Debris: 10,020 (cloud on Ground Track)") + print(f" CDMs: {result.get('active_cdm_warnings', '?')} active warnings") + print(f" Collisions: {total_collisions}") + print(f" Maneuvers: {total_maneuvers} burns executed") + print(f" Sim time: 2026-03-12T08:50:00Z") + print("=" * 60) + print("\nOpen http://localhost:5173 to see the full Orbital Insight dashboard.") + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml index 19b7077..8554c52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: acm: build: . @@ -7,3 +6,10 @@ services: environment: - PYTHONDONTWRITEBYTECODE=1 - LOG_LEVEL=debug + - ACM_AUTO_SEED=1 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 120s diff --git a/frontend/src/components/BullseyePlot.jsx b/frontend/src/components/BullseyePlot.jsx index 7c60f5f..173572e 100644 --- a/frontend/src/components/BullseyePlot.jsx +++ b/frontend/src/components/BullseyePlot.jsx @@ -10,7 +10,7 @@ * 5. Pulsing animation for CRITICAL CDMs [x] */ -import React, { useRef, useEffect, useCallback } from 'react'; +import React, { useRef, useEffect, useCallback, useMemo } from 'react'; import useStore from '../store'; const RISK_COLORS = { @@ -46,13 +46,15 @@ export default function BullseyePlot() { const animRef = useRef(null); const { selectedSatellite, cdms, satellites, timestamp } = useStore(); - const selectedData = selectedSatellite - ? satellites.find((s) => s.id === selectedSatellite) - : null; + const selectedData = useMemo( + () => selectedSatellite ? satellites.find((s) => s.id === selectedSatellite) : null, + [selectedSatellite, satellites] + ); // Filter CDMs for selected satellite - const relevantCdms = cdms.filter( - (c) => c.satellite_id === selectedSatellite || c.debris_id === selectedSatellite + const relevantCdms = useMemo( + () => cdms.filter((c) => c.satellite_id === selectedSatellite || c.debris_id === selectedSatellite), + [cdms, selectedSatellite] ); const draw = useCallback( @@ -216,7 +218,7 @@ export default function BullseyePlot() { animRef.current = requestAnimationFrame(draw); } }, - [selectedSatellite, cdms, satellites, relevantCdms, selectedData, timestamp] + [selectedSatellite, relevantCdms, selectedData, timestamp] ); useEffect(() => { @@ -226,6 +228,15 @@ export default function BullseyePlot() { }; }, [draw]); + // Resize observer + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ro = new ResizeObserver(() => draw(performance.now())); + ro.observe(canvas.parentElement); + return () => ro.disconnect(); + }, [draw]); + return (