Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions backend/seed_demo.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 7 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: '3.8'
services:
acm:
build: .
Expand All @@ -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
25 changes: 18 additions & 7 deletions frontend/src/components/BullseyePlot.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -216,7 +218,7 @@ export default function BullseyePlot() {
animRef.current = requestAnimationFrame(draw);
}
},
[selectedSatellite, cdms, satellites, relevantCdms, selectedData, timestamp]
[selectedSatellite, relevantCdms, selectedData, timestamp]
);

useEffect(() => {
Expand All @@ -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 (
<div className="w-full h-full flex flex-col">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/components/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -38,7 +38,10 @@ export default function Dashboard() {
<span className={collisionCount > 0 ? 'text-eol font-bold' : 'text-nominal'}>
COL: {collisionCount}
</span>
{error && (
{!connected && (
<span className="text-evading animate-pulse">RECONNECTING...</span>
)}
{error && connected && (
<span className="text-eol">API ERR</span>
)}
</div>
Expand Down
Loading
Loading