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 (

diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 0787e6f..f25ddff 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -17,7 +17,7 @@ import DeltaVChart from './DeltaVChart'; import useStore from '../store'; export default function Dashboard() { - const { timestamp, activeCdmCount, satellites, collisionCount, maneuverQueueDepth, error } = + const { timestamp, activeCdmCount, satellites, collisionCount, maneuverQueueDepth, error, connected } = useStore(); return ( @@ -38,7 +38,10 @@ export default function Dashboard() { 0 ? 'text-eol font-bold' : 'text-nominal'}> COL: {collisionCount} - {error && ( + {!connected && ( + RECONNECTING... + )} + {error && connected && ( API ERR )}

diff --git a/frontend/src/components/DeltaVChart.jsx b/frontend/src/components/DeltaVChart.jsx index 3ef361b..7d77ff5 100644 --- a/frontend/src/components/DeltaVChart.jsx +++ b/frontend/src/components/DeltaVChart.jsx @@ -11,14 +11,14 @@ * Demonstrates evasion algorithm efficiency. */ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useCallback } from 'react'; import useStore from '../store'; export default function DeltaVChart() { const canvasRef = useRef(null); const { maneuverLog, collisionCount } = useStore(); - useEffect(() => { + const draw = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); @@ -55,7 +55,7 @@ export default function DeltaVChart() { let evasionCount = 0; for (const entry of maneuverLog) { totalDv += entry.delta_v_magnitude_ms || 0; - evasionCount += 1; // Each maneuver = 1 collision avoided + evasionCount += 1; points.push({ dv: totalDv, evasions: evasionCount }); } @@ -87,7 +87,6 @@ export default function DeltaVChart() { for (let i = 0; i <= maxEvasions; i += xLabelStep) { const x = padL + (i / maxEvasions) * chartW; ctx.fillText(`${i}`, x, h - padB + 14); - // Grid line if (i > 0) { ctx.beginPath(); ctx.moveTo(x, padT); @@ -167,6 +166,17 @@ export default function DeltaVChart() { ); }, [maneuverLog, collisionCount]); + useEffect(() => { draw(); }, [draw]); + + // Resize observer + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ro = new ResizeObserver(() => draw()); + ro.observe(canvas.parentElement); + return () => ro.disconnect(); + }, [draw]); + return (

diff --git a/frontend/src/components/ManeuverTimeline.jsx b/frontend/src/components/ManeuverTimeline.jsx index 369a7c1..850134d 100644 --- a/frontend/src/components/ManeuverTimeline.jsx +++ b/frontend/src/components/ManeuverTimeline.jsx @@ -13,7 +13,7 @@ * Data comes from store.maneuverLog + store.cdms. */ -import React, { useRef, useEffect, useMemo } from 'react'; +import React, { useRef, useEffect, useCallback, useMemo } from 'react'; import useStore from '../store'; const COOLDOWN_S = 600; // 600-second mandatory thruster cooldown @@ -77,7 +77,7 @@ export default function ManeuverTimeline() { return status; }, [satellites]); - useEffect(() => { + const draw = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); @@ -141,9 +141,8 @@ export default function ManeuverTimeline() { // ── Blackout zone indicator (orange hatched stripe at current time) ── if (blackoutStatus[satIds[i]]) { - // Show a blackout indicator around the NOW marker (satellite currently in blackout) const nowX = timeToX(centerMs); - const blackoutW = Math.max(20, (180 / (windowMs / 1000)) * (W - LABEL_W)); // ~3 min wide + const blackoutW = Math.max(20, (180 / (windowMs / 1000)) * (W - LABEL_W)); const bx = nowX - blackoutW / 2; ctx.fillStyle = 'rgba(255, 149, 0, 0.12)'; @@ -335,18 +334,18 @@ export default function ManeuverTimeline() { ctx.textAlign = 'right'; ctx.fillText(`${maneuverQueueDepth} burn(s) queued`, W - 4, legendY); } - }, [maneuverLog, cdms, maneuverQueueDepth, timestamp, satellites, selectedSatellite, satIds, simDate, blackoutStatus]); + }, [maneuverLog, cdms, maneuverQueueDepth, simDate, satIds, selectedSatellite, blackoutStatus]); + + useEffect(() => { draw(); }, [draw]); + // Resize observer useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; - const ro = new ResizeObserver(() => { - const el = canvasRef.current; - if (el) el.dispatchEvent(new Event('resize')); - }); + const ro = new ResizeObserver(() => draw()); ro.observe(canvas.parentElement); return () => ro.disconnect(); - }, []); + }, [draw]); return (
diff --git a/frontend/src/store.js b/frontend/src/store.js index 36e09f4..2063856 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -35,6 +35,7 @@ const useStore = create((set, get) => ({ selectedSatellite: null, isLoading: false, error: null, + connected: true, // Actions setSnapshot: (snapshot) => @@ -75,6 +76,7 @@ const useStore = create((set, get) => ({ setSelectedSatellite: (id) => set({ selectedSatellite: id }), setLoading: (loading) => set({ isLoading: loading }), setError: (error) => set({ error }), + setConnected: (val) => set({ connected: val }), })); export default useStore; diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0e61d60..ee1eaee 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -4,28 +4,59 @@ * * THE ONLY place fetch('/api/...') appears in the frontend. * Components use the Zustand store, not direct fetch calls. + * + * Features: + * - Retry with exponential backoff on transient failures + * - Connection status tracking (connected / reconnecting) + * - Waits for backend readiness on first load (auto-seed may still be running) */ import useStore from '../store'; const API_BASE = '/api'; +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 500; + +let consecutiveFailures = 0; /** * Fetch the latest visualization snapshot from the backend. - * Updates the Zustand store on success. + * Updates the Zustand store on success. On failure, retries up to + * MAX_RETRIES with exponential backoff before surfacing the error. */ export async function fetchSnapshot() { - try { - useStore.getState().setLoading(true); - const res = await fetch(`${API_BASE}/visualization/snapshot`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - useStore.getState().setSnapshot(data); - useStore.getState().setError(null); - } catch (err) { - useStore.getState().setError(err.message); - } finally { - useStore.getState().setLoading(false); + const store = useStore.getState(); + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const res = await fetch(`${API_BASE}/visualization/snapshot`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + + store.setSnapshot(data); + store.setError(null); + + // Reset failure counter and mark connected + if (consecutiveFailures > 0 || !store.connected) { + consecutiveFailures = 0; + store.setConnected(true); + } + return; + } catch (err) { + if (attempt < MAX_RETRIES) { + // Exponential backoff: 500ms, 1s, 2s + await new Promise((r) => setTimeout(r, BASE_DELAY_MS * 2 ** attempt)); + continue; + } + + // All retries exhausted + consecutiveFailures++; + store.setError(err.message); + + if (consecutiveFailures >= 3) { + store.setConnected(false); + } + } } } diff --git a/frontend/start-vite.cjs b/frontend/start-vite.cjs new file mode 100644 index 0000000..972f750 --- /dev/null +++ b/frontend/start-vite.cjs @@ -0,0 +1,5 @@ +const { pathToFileURL } = require('url'); +const path = require('path'); +process.chdir(__dirname); +process.argv.push('--port', '5173'); +import(pathToFileURL(path.join(__dirname, 'node_modules', 'vite', 'bin', 'vite.js')).href);