Adisri99 commited on
Commit
9e6ee24
·
verified ·
1 Parent(s): fafc199

Upload 24 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ ENV PYTHONDONTWRITEBYTECODE=1
3
+ ENV PYTHONUNBUFFERED=1
4
+ ENV PORT=7860
5
+ WORKDIR /app
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+ COPY . .
9
+ EXPOSE 7860
10
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,11 @@
1
  ---
2
  title: Space Risk Intelligence API
3
- emoji: 💻
4
- colorFrom: red
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: Space Risk Intelligence API
3
+ emoji: 🚀
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Space Risk Intelligence API
app/__init__.py ADDED
File without changes
app/config.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+ class Settings(BaseSettings):
4
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
5
+ APP_NAME: str = "Space Risk Intelligence API"
6
+ APP_ENV: str = "dev"
7
+ DATABASE_URL: str = "sqlite:///./space_risk.db"
8
+ CELESTRAK_URL: str = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json"
9
+ ALLOWED_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
10
+ TOP_K_ALERTS: int = 25
11
+ MAX_OBJECTS_PER_RUN: int = 600
12
+ MAX_CANDIDATE_PAIRS: int = 2500
13
+
14
+ settings = Settings()
app/database.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.orm import DeclarativeBase, sessionmaker
3
+ from app.config import settings
4
+
5
+ connect_args = {"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
6
+ engine = create_engine(settings.DATABASE_URL, connect_args=connect_args)
7
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
8
+
9
+ class Base(DeclarativeBase):
10
+ pass
11
+
12
+ def get_db():
13
+ db = SessionLocal()
14
+ try:
15
+ yield db
16
+ finally:
17
+ db.close()
app/explanations.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def build_top_factors(features, anomaly_score, final_score):
2
+ f = []
3
+ if features.get("close_approach_proxy", 0) > 0.5: f.append("small orbital separation proxy")
4
+ if features.get("same_shell", 0) >= 1: f.append("same orbital shell")
5
+ if features.get("graph_local_density", 0) > 0.2: f.append("dense interaction neighborhood")
6
+ if features.get("recurrence_count", 0) >= 3: f.append("repeated appearance across scoring windows")
7
+ if features.get("trend_delta_score", 0) > 0.1: f.append("risk trend increasing over time")
8
+ if anomaly_score > 0.6: f.append("unusual conjunction pattern")
9
+ if final_score > 0.9: f.append("high blended system score")
10
+ return f[:5] or ["general risk elevation from orbital similarity"]
11
+
12
+ def analyst_summary(features, top_factors, final_score):
13
+ text = "This pair is prioritized because " + ", ".join(top_factors[:3]) + "." if top_factors else "This pair is prioritized because multiple similarity signals are elevated."
14
+ if features.get("recurrence_count", 0) >= 3:
15
+ text += " The pair has appeared repeatedly in recent scoring windows."
16
+ if features.get("graph_local_density", 0) > 0.2:
17
+ text += " The surrounding interaction neighborhood is congested."
18
+ if final_score > 0.9:
19
+ text += " This pair should be reviewed first."
20
+ return text
21
+
22
+ def recommended_action(label):
23
+ return {
24
+ "critical": "immediate analyst review",
25
+ "high": "prioritize analyst review",
26
+ "medium": "monitor and rescore on next cycle",
27
+ }.get(label, "low priority monitoring")
app/feature_engineering.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.utils import safe_float, safe_int
2
+
3
+ FEATURE_COLUMNS = [
4
+ "delta_mean_motion","delta_inclination","delta_eccentricity","delta_raan","delta_bstar",
5
+ "launch_year_gap","same_object_type","same_shell","shell_density_proxy","close_approach_proxy",
6
+ "persistence_proxy","recurrence_count","trend_delta_score","score_volatility_proxy",
7
+ "graph_degree_sum","graph_common_neighbors","graph_jaccard","graph_local_density",
8
+ ]
9
+
10
+ def normalize_object(raw):
11
+ name = raw.get("OBJECT_NAME") or raw.get("object_name") or "UNKNOWN"
12
+ norad = raw.get("NORAD_CAT_ID") or raw.get("norad_cat_id")
13
+ intl = raw.get("OBJECT_ID") or raw.get("object_id") or ""
14
+ launch_year = int(intl[:4]) if len(intl) >= 4 and intl[:4].isdigit() else None
15
+ return {
16
+ "object_id": str(norad or name),
17
+ "norad_cat_id": safe_int(norad, 0) or None,
18
+ "object_name": name,
19
+ "object_type": raw.get("OBJECT_TYPE") or raw.get("object_type"),
20
+ "mean_motion": safe_float(raw.get("MEAN_MOTION") or raw.get("mean_motion")),
21
+ "inclination": safe_float(raw.get("INCLINATION") or raw.get("inclination")),
22
+ "eccentricity": safe_float(raw.get("ECCENTRICITY") or raw.get("eccentricity")),
23
+ "raan": safe_float(raw.get("RA_OF_ASC_NODE") or raw.get("raan")),
24
+ "bstar": safe_float(raw.get("BSTAR") or raw.get("bstar")),
25
+ "launch_year": launch_year,
26
+ }
27
+
28
+ def orbital_shell_key(obj):
29
+ mm = safe_float(obj.get("mean_motion"))
30
+ inc = safe_float(obj.get("inclination"))
31
+ ecc = safe_float(obj.get("eccentricity"))
32
+ return f"mm:{int(mm)}|inc:{int(inc//5)*5}|ecc:{int(ecc*1000)//10}"
33
+
34
+ def base_pair_features(a, b):
35
+ mm1, mm2 = safe_float(a.get("mean_motion")), safe_float(b.get("mean_motion"))
36
+ inc1, inc2 = safe_float(a.get("inclination")), safe_float(b.get("inclination"))
37
+ ecc1, ecc2 = safe_float(a.get("eccentricity")), safe_float(b.get("eccentricity"))
38
+ raan1, raan2 = safe_float(a.get("raan")), safe_float(b.get("raan"))
39
+ b1, b2 = safe_float(a.get("bstar")), safe_float(b.get("bstar"))
40
+ ly1, ly2 = safe_int(a.get("launch_year")), safe_int(b.get("launch_year"))
41
+ same_type = 1 if (a.get("object_type") or "") == (b.get("object_type") or "") else 0
42
+ same_shell = 1 if orbital_shell_key(a) == orbital_shell_key(b) else 0
43
+ delta_mm = abs(mm1 - mm2)
44
+ delta_inc = abs(inc1 - inc2)
45
+ delta_ecc = abs(ecc1 - ecc2)
46
+ delta_raan = abs(raan1 - raan2)
47
+ delta_bstar = abs(b1 - b2)
48
+ launch_gap = abs(ly1 - ly2) if ly1 and ly2 else 25
49
+ shell_density_proxy = max(0.0, 10.0 - delta_mm) + max(0.0, 8.0 - delta_inc / 2.0)
50
+ close_approach_proxy = 1.0 / (1.0 + delta_mm + delta_inc / 10.0 + delta_ecc * 50.0 + delta_raan / 60.0)
51
+ persistence_proxy = 1.0 if same_shell else 0.25
52
+ return {
53
+ "delta_mean_motion": delta_mm,
54
+ "delta_inclination": delta_inc,
55
+ "delta_eccentricity": delta_ecc,
56
+ "delta_raan": delta_raan,
57
+ "delta_bstar": delta_bstar,
58
+ "launch_year_gap": float(launch_gap),
59
+ "same_object_type": float(same_type),
60
+ "same_shell": float(same_shell),
61
+ "shell_density_proxy": float(shell_density_proxy),
62
+ "close_approach_proxy": float(close_approach_proxy),
63
+ "persistence_proxy": float(persistence_proxy),
64
+ }
65
+
66
+ def combine_features(a, b, trend, graph):
67
+ return {**base_pair_features(a, b), **trend, **graph}
app/graph_features.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import networkx as nx
2
+
3
+ def build_graph(candidate_pairs):
4
+ g = nx.Graph()
5
+ for a, b in candidate_pairs:
6
+ g.add_edge(a, b)
7
+ return g
8
+
9
+ def pair_graph_features(g, a, b):
10
+ degree_sum = float(g.degree(a) + g.degree(b))
11
+ common = len(list(nx.common_neighbors(g, a, b))) if a in g and b in g else 0
12
+ na = set(g.neighbors(a)) if a in g else set()
13
+ nb = set(g.neighbors(b)) if b in g else set()
14
+ union = len(na | nb)
15
+ inter = len(na & nb)
16
+ jaccard = float(inter / union) if union else 0.0
17
+ nodes = set([a, b]) | na | nb
18
+ sub = g.subgraph(nodes)
19
+ possible = max(1, len(nodes) * (len(nodes) - 1) / 2)
20
+ density = float(sub.number_of_edges() / possible)
21
+ return {
22
+ "graph_degree_sum": degree_sum,
23
+ "graph_common_neighbors": float(common),
24
+ "graph_jaccard": jaccard,
25
+ "graph_local_density": density,
26
+ }
app/ingestion.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from app.config import settings
3
+
4
+ def fetch_celestrak_json():
5
+ r = requests.get(settings.CELESTRAK_URL, timeout=60)
6
+ r.raise_for_status()
7
+ body = r.json()
8
+ return body[: settings.MAX_OBJECTS_PER_RUN] if isinstance(body, list) else []
app/main.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from app.config import settings
4
+ from app.database import Base, engine
5
+ from app.routers.health import router as health_router
6
+ from app.routers.objects import router as objects_router
7
+ from app.routers.pairs import router as pairs_router
8
+ from app.routers.admin import router as admin_router
9
+ from app.scripts.bootstrap_demo import bootstrap_if_needed
10
+
11
+ Base.metadata.create_all(bind=engine)
12
+ bootstrap_if_needed()
13
+
14
+ app = FastAPI(title=settings.APP_NAME, docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json")
15
+ origins = [x.strip() for x in settings.ALLOWED_ORIGINS.split(",") if x.strip()]
16
+ app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
17
+
18
+ @app.get("/")
19
+ def root():
20
+ return {"status": "ok", "message": "Space Risk Intelligence API is running", "docs": "/docs", "health": "/health"}
21
+
22
+ app.include_router(health_router)
23
+ app.include_router(objects_router)
24
+ app.include_router(pairs_router)
25
+ app.include_router(admin_router)
app/ml.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+ import joblib, numpy as np
4
+ from sklearn.ensemble import IsolationForest
5
+ from xgboost import XGBClassifier
6
+ from app.feature_engineering import FEATURE_COLUMNS
7
+
8
+ BASE_DIR = Path(__file__).resolve().parents[1]
9
+ MODEL_DIR = BASE_DIR / "models"
10
+ MODEL_DIR.mkdir(exist_ok=True)
11
+ BASELINE_PATH = MODEL_DIR / "baseline_model.joblib"
12
+ ANOMALY_PATH = MODEL_DIR / "anomaly_model.joblib"
13
+ FEATURE_COLUMNS_PATH = MODEL_DIR / "feature_columns.json"
14
+
15
+ def train_models(X, y):
16
+ clf = XGBClassifier(n_estimators=140, max_depth=5, learning_rate=0.06, subsample=0.9, colsample_bytree=0.9, eval_metric="logloss", random_state=42)
17
+ clf.fit(X, y)
18
+ anomaly = IsolationForest(n_estimators=180, contamination=0.08, random_state=42)
19
+ anomaly.fit(X)
20
+ joblib.dump(clf, BASELINE_PATH)
21
+ joblib.dump(anomaly, ANOMALY_PATH)
22
+ FEATURE_COLUMNS_PATH.write_text(json.dumps(FEATURE_COLUMNS), encoding="utf-8")
23
+ return str(BASELINE_PATH)
24
+
25
+ def predict_local(feature_vector):
26
+ clf = joblib.load(BASELINE_PATH)
27
+ anomaly = joblib.load(ANOMALY_PATH)
28
+ x = np.array([feature_vector])
29
+ risk = float(clf.predict_proba(x)[0][1])
30
+ raw = float(anomaly.decision_function(x)[0])
31
+ anomaly_score = float(max(0.0, min(1.0, 1.0 - ((raw + 0.5) / 1.0))))
32
+ final = 0.72 * risk + 0.18 * anomaly_score + 0.10 * min(1.0, feature_vector[10] if len(feature_vector) > 10 else 0.0)
33
+ return risk, anomaly_score, float(max(0.0, min(1.0, final)))
app/models.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import String, Float, Integer, DateTime, Text, Boolean
2
+ from sqlalchemy.orm import Mapped, mapped_column
3
+ from datetime import datetime
4
+ from app.database import Base
5
+
6
+ class SpaceObject(Base):
7
+ __tablename__ = "space_objects"
8
+ object_id: Mapped[str] = mapped_column(String(64), primary_key=True)
9
+ norad_cat_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
10
+ object_name: Mapped[str] = mapped_column(String(255), index=True)
11
+ object_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
12
+ mean_motion: Mapped[float | None] = mapped_column(Float, nullable=True)
13
+ inclination: Mapped[float | None] = mapped_column(Float, nullable=True)
14
+ eccentricity: Mapped[float | None] = mapped_column(Float, nullable=True)
15
+ raan: Mapped[float | None] = mapped_column(Float, nullable=True)
16
+ bstar: Mapped[float | None] = mapped_column(Float, nullable=True)
17
+ launch_year: Mapped[int | None] = mapped_column(Integer, nullable=True)
18
+ inserted_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
19
+
20
+ class PairScore(Base):
21
+ __tablename__ = "pair_scores"
22
+ pair_id: Mapped[str] = mapped_column(String(128), primary_key=True)
23
+ primary_object_id: Mapped[str] = mapped_column(String(64), index=True)
24
+ secondary_object_id: Mapped[str] = mapped_column(String(64), index=True)
25
+ latest_run_id: Mapped[str] = mapped_column(String(64), index=True)
26
+ risk_score: Mapped[float] = mapped_column(Float, index=True)
27
+ anomaly_score: Mapped[float] = mapped_column(Float, index=True)
28
+ final_score: Mapped[float] = mapped_column(Float, index=True)
29
+ risk_label: Mapped[str] = mapped_column(String(32), index=True)
30
+ recurrence_count: Mapped[int] = mapped_column(Integer, default=1)
31
+ trend_delta_24h: Mapped[float | None] = mapped_column(Float, nullable=True)
32
+ top_factors_json: Mapped[str] = mapped_column(Text)
33
+ feature_payload_json: Mapped[str] = mapped_column(Text)
34
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
35
+
36
+ class PairScoreHistory(Base):
37
+ __tablename__ = "pair_score_history"
38
+ history_id: Mapped[str] = mapped_column(String(128), primary_key=True)
39
+ pair_id: Mapped[str] = mapped_column(String(128), index=True)
40
+ run_id: Mapped[str] = mapped_column(String(64), index=True)
41
+ risk_score: Mapped[float] = mapped_column(Float)
42
+ anomaly_score: Mapped[float] = mapped_column(Float)
43
+ final_score: Mapped[float] = mapped_column(Float)
44
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
45
+
46
+ class ScoringRun(Base):
47
+ __tablename__ = "scoring_runs"
48
+ run_id: Mapped[str] = mapped_column(String(64), primary_key=True)
49
+ source: Mapped[str] = mapped_column(String(64))
50
+ object_count: Mapped[int] = mapped_column(Integer, default=0)
51
+ candidate_pair_count: Mapped[int] = mapped_column(Integer, default=0)
52
+ scored_pair_count: Mapped[int] = mapped_column(Integer, default=0)
53
+ completed: Mapped[bool] = mapped_column(Boolean, default=False)
54
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
app/repository.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session
2
+ from sqlalchemy import select, desc
3
+ from app.models import SpaceObject, PairScore, PairScoreHistory, ScoringRun
4
+
5
+ def upsert_space_object(db: Session, payload):
6
+ obj = db.get(SpaceObject, payload["object_id"])
7
+ if obj:
8
+ for k, v in payload.items():
9
+ setattr(obj, k, v)
10
+ db.add(obj)
11
+ else:
12
+ db.add(SpaceObject(**payload))
13
+
14
+ def list_objects(db: Session, limit=100):
15
+ return db.scalars(select(SpaceObject).limit(limit)).all()
16
+
17
+ def save_pair_score(db: Session, payload):
18
+ row = db.get(PairScore, payload["pair_id"])
19
+ if row:
20
+ for k, v in payload.items():
21
+ setattr(row, k, v)
22
+ db.add(row)
23
+ else:
24
+ db.add(PairScore(**payload))
25
+
26
+ def insert_pair_history(db: Session, payload):
27
+ db.add(PairScoreHistory(**payload))
28
+
29
+ def list_high_risk_pairs(db: Session, limit=50):
30
+ return db.scalars(select(PairScore).order_by(desc(PairScore.final_score)).limit(limit)).all()
31
+
32
+ def get_pair(db: Session, pair_id):
33
+ return db.get(PairScore, pair_id)
34
+
35
+ def get_pair_history(db: Session, pair_id, limit=20):
36
+ return db.scalars(select(PairScoreHistory).where(PairScoreHistory.pair_id == pair_id).order_by(desc(PairScoreHistory.created_at)).limit(limit)).all()
37
+
38
+ def create_run(db: Session, payload):
39
+ db.add(ScoringRun(**payload))
40
+
41
+ def latest_runs(db: Session, limit=10):
42
+ return db.scalars(select(ScoringRun).order_by(desc(ScoringRun.created_at)).limit(limit)).all()
app/routers/__init__.py ADDED
File without changes
app/routers/admin.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from fastapi import APIRouter, Depends
3
+ from sqlalchemy.orm import Session
4
+ from app.database import get_db
5
+ from app.ingestion import fetch_celestrak_json
6
+ from app.services import scoring_cycle, demo_objects
7
+ from app.repository import list_high_risk_pairs, latest_runs
8
+ from app.scripts.train_baseline import run_training
9
+
10
+ router = APIRouter(prefix="/api/v1", tags=["admin"])
11
+
12
+ @router.post("/ingest/celestrak")
13
+ def ingest_celestrak(db: Session = Depends(get_db)):
14
+ return {"status": "ok", **scoring_cycle(db, [__import__("app.feature_engineering", fromlist=["normalize_object"]).normalize_object(x) for x in fetch_celestrak_json()], source="celestrak")}
15
+
16
+ @router.post("/score/demo-cycle")
17
+ def score_demo_cycle(db: Session = Depends(get_db)):
18
+ return {"status": "ok", **scoring_cycle(db, demo_objects(db), source="demo")}
19
+
20
+ @router.post("/train/baseline")
21
+ def train_baseline():
22
+ model_path, rows, metrics = run_training()
23
+ return {"status": "ok", "model_path": model_path, "rows_used": rows, "metrics": metrics}
24
+
25
+ @router.get("/alerts/live")
26
+ def alerts_live(limit: int = 25, db: Session = Depends(get_db)):
27
+ rows = list_high_risk_pairs(db, limit)
28
+ return [{"pair_id": r.pair_id, "final_score": r.final_score, "risk_label": r.risk_label, "top_factors": json.loads(r.top_factors_json)} for r in rows]
29
+
30
+ @router.get("/runs")
31
+ def runs(db: Session = Depends(get_db)):
32
+ return [{"run_id": r.run_id, "source": r.source, "object_count": r.object_count, "candidate_pair_count": r.candidate_pair_count, "scored_pair_count": r.scored_pair_count, "completed": r.completed, "created_at": r.created_at} for r in latest_runs(db, 10)]
app/routers/health.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from app.config import settings
3
+
4
+ router = APIRouter()
5
+
6
+ @router.get("/health")
7
+ def health():
8
+ return {"status": "ok", "app": settings.APP_NAME}
app/routers/objects.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.orm import Session
3
+ from app.database import get_db
4
+ from app.repository import list_objects
5
+
6
+ router = APIRouter(prefix="/api/v1/objects", tags=["objects"])
7
+
8
+ @router.get("")
9
+ def get_objects(limit: int = 100, db: Session = Depends(get_db)):
10
+ return list_objects(db, limit)
app/routers/pairs.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from sqlalchemy.orm import Session
4
+ from app.database import get_db
5
+ from app.feature_engineering import normalize_object
6
+ from app.services import score_pair
7
+ from app.repository import list_high_risk_pairs, get_pair, get_pair_history
8
+
9
+ router = APIRouter(prefix="/api/v1", tags=["pairs"])
10
+
11
+ @router.post("/score/pair")
12
+ def score_pair_route(payload: dict, db: Session = Depends(get_db)):
13
+ return score_pair(db, normalize_object(payload["primary"]), normalize_object(payload["secondary"]))
14
+
15
+ @router.get("/pairs/high-risk")
16
+ def high_risk(limit: int = 50, db: Session = Depends(get_db)):
17
+ rows = list_high_risk_pairs(db, limit)
18
+ return [{"pair_id": r.pair_id, "risk_score": r.risk_score, "anomaly_score": r.anomaly_score, "final_score": r.final_score, "risk_label": r.risk_label, "recurrence_count": r.recurrence_count, "trend_delta_24h": r.trend_delta_24h, "top_factors": json.loads(r.top_factors_json)} for r in rows]
19
+
20
+ @router.get("/pairs/{pair_id}")
21
+ def pair_detail(pair_id: str, db: Session = Depends(get_db)):
22
+ row = get_pair(db, pair_id)
23
+ if not row:
24
+ raise HTTPException(status_code=404, detail="Pair not found")
25
+ payload = json.loads(row.feature_payload_json)
26
+ return {"pair_id": row.pair_id, "risk_score": row.risk_score, "anomaly_score": row.anomaly_score, "final_score": row.final_score, "risk_label": row.risk_label, "recurrence_count": row.recurrence_count, "trend_delta_24h": row.trend_delta_24h, "top_factors": json.loads(row.top_factors_json), "features": payload, "analyst_summary": payload.get("analyst_summary", "")}
27
+
28
+ @router.get("/pairs/{pair_id}/history")
29
+ def pair_history(pair_id: str, limit: int = 20, db: Session = Depends(get_db)):
30
+ rows = get_pair_history(db, pair_id, limit)
31
+ return [{"history_id": r.history_id, "run_id": r.run_id, "risk_score": r.risk_score, "anomaly_score": r.anomaly_score, "final_score": r.final_score, "created_at": r.created_at} for r in rows]
app/scripts/bootstrap_demo.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from app.database import Base, engine, SessionLocal
3
+ from app.repository import list_objects, latest_runs
4
+ from app.scripts.seed_synthetic import main as seed_main
5
+ from app.scripts.train_baseline import run_training
6
+ from app.services import scoring_cycle, demo_objects
7
+
8
+ def bootstrap_if_needed():
9
+ Base.metadata.create_all(bind=engine)
10
+ model_path = Path(__file__).resolve().parents[2] / "models" / "baseline_model.joblib"
11
+ db = SessionLocal()
12
+ try:
13
+ has_objects = bool(list_objects(db, 5))
14
+ finally:
15
+ db.close()
16
+ if not has_objects:
17
+ seed_main()
18
+ if not model_path.exists():
19
+ run_training()
20
+ db = SessionLocal()
21
+ try:
22
+ if not latest_runs(db, 1):
23
+ scoring_cycle(db, demo_objects(db), source="bootstrap-demo")
24
+ finally:
25
+ db.close()
26
+
27
+ if __name__ == "__main__":
28
+ bootstrap_if_needed()
app/scripts/seed_synthetic.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from app.database import Base, engine, SessionLocal
3
+ from app.repository import upsert_space_object
4
+
5
+ def synthetic_objects(n=300):
6
+ items = []
7
+ for i in range(n):
8
+ shell = random.choice([(15.2, 53), (14.9, 98), (13.8, 74), (2.0, 0), (12.5, 55)])
9
+ mm, inc = shell
10
+ items.append({
11
+ "object_id": f"OBJ-{i+1}",
12
+ "norad_cat_id": 10000 + i,
13
+ "object_name": f"SIM_OBJECT_{i+1}",
14
+ "object_type": random.choice(["PAYLOAD", "DEBRIS", "ROCKET BODY"]),
15
+ "mean_motion": round(random.gauss(mm, 0.12), 5),
16
+ "inclination": round(random.gauss(inc, 1.4), 5),
17
+ "eccentricity": round(abs(random.gauss(0.001, 0.002)), 6),
18
+ "raan": round(random.uniform(0, 360), 4),
19
+ "bstar": round(random.uniform(0.00001, 0.005), 8),
20
+ "launch_year": random.randint(1998, 2025),
21
+ })
22
+ return items
23
+
24
+ def main():
25
+ Base.metadata.create_all(bind=engine)
26
+ db = SessionLocal()
27
+ try:
28
+ for item in synthetic_objects():
29
+ upsert_space_object(db, item)
30
+ db.commit()
31
+ print("Synthetic objects loaded.")
32
+ finally:
33
+ db.close()
34
+
35
+ if __name__ == "__main__":
36
+ main()
app/scripts/train_baseline.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json, random
2
+ import numpy as np, pandas as pd
3
+ from sklearn.metrics import roc_auc_score
4
+ from app.database import SessionLocal
5
+ from app.repository import list_objects
6
+ from app.feature_engineering import FEATURE_COLUMNS, combine_features
7
+ from app.graph_features import build_graph, pair_graph_features
8
+ from app.ml import train_models
9
+
10
+ def run_training():
11
+ db = SessionLocal()
12
+ try:
13
+ objs = list_objects(db, 5000)
14
+ objects = [{"object_id": o.object_id, "object_type": o.object_type, "mean_motion": o.mean_motion, "inclination": o.inclination, "eccentricity": o.eccentricity, "raan": o.raan, "bstar": o.bstar, "launch_year": o.launch_year} for o in objs]
15
+ finally:
16
+ db.close()
17
+ pairs = [tuple(random.sample(objects, 2)) for _ in range(4000)]
18
+ g = build_graph([(a["object_id"], b["object_id"]) for a, b in pairs[:1000]])
19
+ rows = []
20
+ raw_scores = []
21
+ for a, b in pairs:
22
+ trend = {"recurrence_count": float(random.choice([0,1,2,3,4])), "trend_delta_score": float(random.uniform(-0.1, 0.3)), "score_volatility_proxy": float(random.uniform(0, 0.2))}
23
+ f = combine_features(a, b, trend, pair_graph_features(g, a["object_id"], b["object_id"]))
24
+ score = 0.30*f["close_approach_proxy"] + 0.16*f["same_shell"] + 0.10*min(1.0,f["shell_density_proxy"]/12.0) + 0.10*min(1.0,f["graph_local_density"]*2.0) + 0.09*min(1.0,f["graph_jaccard"]) + 0.10*min(1.0,f["recurrence_count"]/5.0) + 0.08*max(0.0,f["trend_delta_score"]) + np.random.normal(0,0.05)
25
+ y = 1 if score > 0.48 else 0
26
+ rows.append({**f, "label": y})
27
+ raw_scores.append(score)
28
+ df = pd.DataFrame(rows)
29
+ path = train_models(df[FEATURE_COLUMNS].values, df["label"].values)
30
+ auc = float(roc_auc_score(df["label"].values, np.array(raw_scores)))
31
+ return path, len(df), {"pseudo_auc": round(auc, 4), "rows": int(len(df))}
32
+
33
+ if __name__ == "__main__":
34
+ p, r, m = run_training()
35
+ print(json.dumps({"model_path": p, "rows": r, "metrics": m}, indent=2))
app/services.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from itertools import combinations
3
+ from sqlalchemy.orm import Session
4
+ from app.config import settings
5
+ from app.feature_engineering import normalize_object, combine_features, orbital_shell_key, FEATURE_COLUMNS
6
+ from app.graph_features import build_graph, pair_graph_features
7
+ from app.repository import upsert_space_object, save_pair_score, insert_pair_history, create_run, get_pair_history, list_objects
8
+ from app.ml import predict_local
9
+ from app.utils import new_id, dumps
10
+ from app.explanations import build_top_factors, analyst_summary, recommended_action
11
+
12
+ def demo_objects(db: Session, limit=200):
13
+ rows = list_objects(db, limit=limit)
14
+ return [{"object_id": r.object_id, "object_name": r.object_name, "object_type": r.object_type, "mean_motion": r.mean_motion, "inclination": r.inclination, "eccentricity": r.eccentricity, "raan": r.raan, "bstar": r.bstar, "launch_year": r.launch_year} for r in rows]
15
+
16
+ def generate_candidate_pairs(objects):
17
+ grouped = {}
18
+ for obj in objects:
19
+ key = orbital_shell_key(obj)
20
+ grouped.setdefault(key, []).append(obj)
21
+ candidates = []
22
+ for group in grouped.values():
23
+ if len(group) < 2:
24
+ continue
25
+ for a, b in combinations(group[:120], 2):
26
+ candidates.append((a, b))
27
+ if len(candidates) >= settings.MAX_CANDIDATE_PAIRS:
28
+ return candidates
29
+ return candidates
30
+
31
+ def _trend_features(db, pair_id):
32
+ hist = get_pair_history(db, pair_id, limit=10)
33
+ if len(hist) < 2:
34
+ return {"recurrence_count": float(len(hist)), "trend_delta_score": 0.0, "score_volatility_proxy": 0.0}
35
+ scores = [h.final_score for h in hist]
36
+ avg = sum(scores) / len(scores)
37
+ vol = sum(abs(x - avg) for x in scores) / len(scores)
38
+ return {"recurrence_count": float(len(hist)), "trend_delta_score": float(scores[0] - scores[-1]), "score_volatility_proxy": float(vol)}
39
+
40
+ def score_pair(db: Session, a, b, graph_feats=None):
41
+ pair_id = f"{a['object_id']}__{b['object_id']}"
42
+ trend = _trend_features(db, pair_id)
43
+ graph = graph_feats or {"graph_degree_sum": 0.0, "graph_common_neighbors": 0.0, "graph_jaccard": 0.0, "graph_local_density": 0.0}
44
+ features = combine_features(a, b, trend, graph)
45
+ vector = [float(features.get(c, 0.0)) for c in FEATURE_COLUMNS]
46
+ risk, anomaly, final = predict_local(vector)
47
+ label = "critical" if final >= 0.9 else "high" if final >= 0.75 else "medium" if final >= 0.45 else "low"
48
+ top = build_top_factors(features, anomaly, final)
49
+ summary = analyst_summary(features, top, final)
50
+ return {"pair_id": pair_id, "risk_score": risk, "anomaly_score": anomaly, "final_score": final, "risk_label": label, "top_factors": top, "analyst_summary": summary, "recommended_action": recommended_action(label), "features": features}
51
+
52
+ def scoring_cycle(db: Session, objects, source="demo"):
53
+ run_id = new_id("run")
54
+ create_run(db, {"run_id": run_id, "source": source, "object_count": len(objects), "candidate_pair_count": 0, "scored_pair_count": 0, "completed": False})
55
+ for obj in objects:
56
+ upsert_space_object(db, obj)
57
+ db.commit()
58
+ candidates = generate_candidate_pairs(objects)
59
+ g = build_graph([(a["object_id"], b["object_id"]) for a, b in candidates])
60
+ count = 0
61
+ for a, b in candidates:
62
+ graph_feats = pair_graph_features(g, a["object_id"], b["object_id"])
63
+ result = score_pair(db, a, b, graph_feats)
64
+ hist = get_pair_history(db, result["pair_id"], limit=20)
65
+ recurrence = len(hist) + 1
66
+ trend_delta = result["final_score"] - hist[-1].final_score if hist else 0.0
67
+ save_pair_score(db, {
68
+ "pair_id": result["pair_id"],
69
+ "primary_object_id": a["object_id"],
70
+ "secondary_object_id": b["object_id"],
71
+ "latest_run_id": run_id,
72
+ "risk_score": result["risk_score"],
73
+ "anomaly_score": result["anomaly_score"],
74
+ "final_score": result["final_score"],
75
+ "risk_label": result["risk_label"],
76
+ "recurrence_count": recurrence,
77
+ "trend_delta_24h": trend_delta,
78
+ "top_factors_json": dumps(result["top_factors"]),
79
+ "feature_payload_json": dumps(result["features"] | {"analyst_summary": result["analyst_summary"]}),
80
+ })
81
+ insert_pair_history(db, {"history_id": new_id("hist"), "pair_id": result["pair_id"], "run_id": run_id, "risk_score": result["risk_score"], "anomaly_score": result["anomaly_score"], "final_score": result["final_score"]})
82
+ count += 1
83
+ from app.models import ScoringRun
84
+ run = db.get(ScoringRun, run_id)
85
+ run.candidate_pair_count = len(candidates)
86
+ run.scored_pair_count = count
87
+ run.completed = True
88
+ db.add(run)
89
+ db.commit()
90
+ return {"run_id": run_id, "object_count": len(objects), "candidate_pair_count": len(candidates), "scored_pair_count": count}
app/utils.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json, uuid
2
+ from datetime import datetime
3
+
4
+ def safe_float(v, d=0.0):
5
+ try:
6
+ return d if v in (None, "") else float(v)
7
+ except Exception:
8
+ return d
9
+
10
+ def safe_int(v, d=0):
11
+ try:
12
+ return d if v in (None, "") else int(v)
13
+ except Exception:
14
+ return d
15
+
16
+ def dumps(x):
17
+ return json.dumps(x, default=str)
18
+
19
+ def now_iso():
20
+ return datetime.utcnow().isoformat()
21
+
22
+ def new_id(prefix):
23
+ return f"{prefix}_{uuid.uuid4().hex[:16]}"
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ pydantic-settings==2.5.2
4
+ sqlalchemy==2.0.35
5
+ requests==2.32.3
6
+ pandas==2.2.3
7
+ numpy==2.1.2
8
+ scikit-learn==1.5.2
9
+ xgboost==2.1.1
10
+ joblib==1.4.2
11
+ networkx==3.3
12
+ python-multipart==0.0.9