Adisri99 commited on
Commit
1ce499f
·
verified ·
1 Parent(s): 33a969b

Upload 26 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 ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
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/analytics.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import defaultdict
2
+ from app.repository import list_high_risk_pairs, latest_runs
3
+ def shell_analytics(db, limit:int=10):
4
+ rows=list_high_risk_pairs(db, limit=500); buckets=defaultdict(lambda: {"shell_key":"","pair_count":0,"avg_final_score":0.0,"max_final_score":0.0,"high_or_critical":0})
5
+ for r in rows:
6
+ key=r.shell_key or "unknown"; b=buckets[key]; b["shell_key"]=key; b["pair_count"]+=1; b["avg_final_score"]+=r.final_score; b["max_final_score"]=max(b["max_final_score"], r.final_score)
7
+ if r.risk_label in ("high","critical"): b["high_or_critical"]+=1
8
+ out=[];
9
+ for b in buckets.values():
10
+ b["avg_final_score"]=b["avg_final_score"]/max(1,b["pair_count"]); out.append(b)
11
+ out.sort(key=lambda x:x["avg_final_score"], reverse=True); return out[:limit]
12
+ def diagnostics_summary(db):
13
+ rows=list_high_risk_pairs(db, limit=500); runs=latest_runs(db, limit=10); labels={"low":0,"medium":0,"high":0,"critical":0}
14
+ if not rows: return {"pair_count":0,"avg_final_score":0.0,"max_final_score":0.0,"high_plus_critical":0,"label_distribution":labels,"recent_runs":len(runs)}
15
+ scores=[r.final_score for r in rows]
16
+ for r in rows: labels[r.risk_label]=labels.get(r.risk_label,0)+1
17
+ return {"pair_count":len(rows),"avg_final_score":sum(scores)/len(scores),"max_final_score":max(scores),"high_plus_critical":labels["high"]+labels["critical"],"label_distribution":labels,"recent_runs":len(runs)}
app/config.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+ class Settings(BaseSettings):
3
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
4
+ APP_NAME:str="Space Risk Intelligence API"
5
+ APP_ENV:str="dev"
6
+ DATABASE_URL:str="sqlite:///./space_risk.db"
7
+ CELESTRAK_URL:str="https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json"
8
+ ALLOWED_ORIGINS:str="http://localhost:5173,http://localhost:3000"
9
+ TOP_K_ALERTS:int=25
10
+ MAX_OBJECTS_PER_RUN:int=600
11
+ MAX_CANDIDATE_PAIRS:int=2500
12
+ settings=Settings()
app/database.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.orm import DeclarativeBase, sessionmaker
3
+ from app.config import settings
4
+ connect_args={"check_same_thread":False} if settings.DATABASE_URL.startswith("sqlite") else {}
5
+ engine=create_engine(settings.DATABASE_URL, connect_args=connect_args)
6
+ SessionLocal=sessionmaker(bind=engine, autocommit=False, autoflush=False)
7
+ class Base(DeclarativeBase): pass
8
+ def get_db():
9
+ db=SessionLocal()
10
+ try: yield db
11
+ finally: db.close()
app/explanations.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def build_top_factors(features, anomaly_score, final_score):
2
+ factors=[]
3
+ if features.get("close_approach_proxy",0)>0.5: factors.append("small orbital separation proxy")
4
+ if features.get("same_shell",0)>=1: factors.append("same orbital shell")
5
+ if features.get("graph_local_density",0)>0.2: factors.append("dense interaction neighborhood")
6
+ if features.get("recurrence_count",0)>=3: factors.append("repeated appearance across scoring windows")
7
+ if features.get("trend_delta_score",0)>0.1: factors.append("risk trend increasing over time")
8
+ if anomaly_score>0.6: factors.append("unusual conjunction pattern")
9
+ if final_score>0.9: factors.append("high blended system score")
10
+ return factors[:5] or ["general risk elevation from orbital similarity"]
11
+ def analyst_summary(features, top_factors, final_score):
12
+ text="This pair is prioritized because "+", ".join(top_factors[:3])+"." if top_factors else "This pair is prioritized because multiple similarity signals are elevated."
13
+ if features.get("recurrence_count",0)>=3: text+=" The pair has appeared repeatedly in recent scoring windows."
14
+ if features.get("graph_local_density",0)>0.2: text+=" The surrounding interaction neighborhood is congested."
15
+ if final_score>0.9: text+=" This pair should be reviewed first."
16
+ return text
17
+ def structured_explanation(features, top_factors, final_score, action):
18
+ return {"why_risky":top_factors[:3],"what_changed":"Risk increased recently." if features.get("trend_delta_score",0)>0.1 else "No major recent change detected.","context":"Pair sits in a dense local interaction neighborhood." if features.get("graph_local_density",0)>0.2 else "Pair context is not highly congested.","recommended_action":action,"final_score":final_score}
19
+ def recommended_action(label): return {"critical":"immediate analyst review","high":"prioritize analyst review","medium":"monitor and rescore on next cycle"}.get(label,"low priority monitoring")
app/feature_engineering.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.utils import safe_float, safe_int
2
+ FEATURE_COLUMNS=["delta_mean_motion","delta_inclination","delta_eccentricity","delta_raan","delta_bstar","launch_year_gap","same_object_type","same_shell","shell_density_proxy","close_approach_proxy","persistence_proxy","recurrence_count","trend_delta_score","score_volatility_proxy","graph_degree_sum","graph_common_neighbors","graph_jaccard","graph_local_density"]
3
+ FEATURE_LABELS={"delta_mean_motion":"Mean motion difference","delta_inclination":"Inclination difference","delta_eccentricity":"Eccentricity difference","delta_raan":"RAAN difference","delta_bstar":"BSTAR difference","launch_year_gap":"Launch year gap","same_object_type":"Same object type","same_shell":"Same orbital shell","shell_density_proxy":"Shell density proxy","close_approach_proxy":"Close approach proxy","persistence_proxy":"Persistence proxy","recurrence_count":"Recurrence count","trend_delta_score":"Trend delta","score_volatility_proxy":"Score volatility","graph_degree_sum":"Graph degree sum","graph_common_neighbors":"Common neighbors","graph_jaccard":"Graph jaccard","graph_local_density":"Graph local density"}
4
+ def normalize_object(raw):
5
+ name=raw.get("OBJECT_NAME") or raw.get("object_name") or "UNKNOWN"
6
+ norad=raw.get("NORAD_CAT_ID") or raw.get("norad_cat_id")
7
+ intl=raw.get("OBJECT_ID") or raw.get("object_id") or ""
8
+ launch_year=int(intl[:4]) if len(intl)>=4 and intl[:4].isdigit() else None
9
+ return {"object_id":str(norad or name),"norad_cat_id":safe_int(norad,0) or None,"object_name":name,"object_type":raw.get("OBJECT_TYPE") or raw.get("object_type"),"mean_motion":safe_float(raw.get("MEAN_MOTION") or raw.get("mean_motion")),"inclination":safe_float(raw.get("INCLINATION") or raw.get("inclination")),"eccentricity":safe_float(raw.get("ECCENTRICITY") or raw.get("eccentricity")),"raan":safe_float(raw.get("RA_OF_ASC_NODE") or raw.get("raan")),"bstar":safe_float(raw.get("BSTAR") or raw.get("bstar")),"launch_year":launch_year}
10
+ def orbital_shell_key(obj):
11
+ mm=safe_float(obj.get("mean_motion")); inc=safe_float(obj.get("inclination")); ecc=safe_float(obj.get("eccentricity"))
12
+ return f"mm:{int(mm)}|inc:{int(inc//5)*5}|ecc:{int(ecc*1000)//10}"
13
+ def base_pair_features(a,b):
14
+ mm1,mm2=safe_float(a.get("mean_motion")),safe_float(b.get("mean_motion"))
15
+ inc1,inc2=safe_float(a.get("inclination")),safe_float(b.get("inclination"))
16
+ ecc1,ecc2=safe_float(a.get("eccentricity")),safe_float(b.get("eccentricity"))
17
+ raan1,raan2=safe_float(a.get("raan")),safe_float(b.get("raan"))
18
+ b1,b2=safe_float(a.get("bstar")),safe_float(b.get("bstar"))
19
+ ly1,ly2=safe_int(a.get("launch_year")),safe_int(b.get("launch_year"))
20
+ same_type=1 if (a.get("object_type") or "")==(b.get("object_type") or "") else 0
21
+ same_shell=1 if orbital_shell_key(a)==orbital_shell_key(b) else 0
22
+ delta_mm=abs(mm1-mm2); delta_inc=abs(inc1-inc2); delta_ecc=abs(ecc1-ecc2); delta_raan=abs(raan1-raan2); delta_bstar=abs(b1-b2)
23
+ launch_gap=abs(ly1-ly2) if ly1 and ly2 else 25
24
+ shell_density_proxy=max(0.0,10.0-delta_mm)+max(0.0,8.0-delta_inc/2.0)
25
+ close_approach_proxy=1.0/(1.0+delta_mm+delta_inc/10.0+delta_ecc*50.0+delta_raan/60.0)
26
+ persistence_proxy=1.0 if same_shell else 0.25
27
+ return {"delta_mean_motion":delta_mm,"delta_inclination":delta_inc,"delta_eccentricity":delta_ecc,"delta_raan":delta_raan,"delta_bstar":delta_bstar,"launch_year_gap":float(launch_gap),"same_object_type":float(same_type),"same_shell":float(same_shell),"shell_density_proxy":float(shell_density_proxy),"close_approach_proxy":float(close_approach_proxy),"persistence_proxy":float(persistence_proxy)}
28
+ def combine_features(a,b,trend,graph): return {**base_pair_features(a,b),**trend,**graph}
app/graph_features.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import networkx as nx
2
+ def build_graph(candidate_pairs):
3
+ g=nx.Graph()
4
+ for a,b in candidate_pairs: g.add_edge(a,b)
5
+ return g
6
+ def pair_graph_features(g,a,b):
7
+ degree_sum=float(g.degree(a)+g.degree(b))
8
+ common=len(list(nx.common_neighbors(g,a,b))) if a in g and b in g else 0
9
+ na=set(g.neighbors(a)) if a in g else set(); nb=set(g.neighbors(b)) if b in g else set()
10
+ union=len(na|nb); inter=len(na&nb); jaccard=float(inter/union) if union else 0.0
11
+ nodes=set([a,b])|na|nb; sub=g.subgraph(nodes); possible=max(1,len(nodes)*(len(nodes)-1)/2); density=float(sub.number_of_edges()/possible)
12
+ return {"graph_degree_sum":degree_sum,"graph_common_neighbors":float(common),"graph_jaccard":jaccard,"graph_local_density":density}
app/ingestion.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from app.config import settings
3
+ def fetch_celestrak_json():
4
+ r=requests.get(settings.CELESTRAK_URL, timeout=60)
5
+ r.raise_for_status()
6
+ body=r.json()
7
+ return body[: settings.MAX_OBJECTS_PER_RUN] if isinstance(body, list) else []
app/main.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.routers.analytics import router as analytics_router
10
+ from app.scripts.bootstrap_demo import bootstrap_if_needed
11
+ Base.metadata.create_all(bind=engine); bootstrap_if_needed()
12
+ app=FastAPI(title=settings.APP_NAME, docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json")
13
+ origins=[x.strip() for x in settings.ALLOWED_ORIGINS.split(",") if x.strip()]
14
+ app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
15
+ @app.get("/")
16
+ def root(): return {"status":"ok","message":"Space Risk Intelligence API is running","docs":"/docs","health":"/health"}
17
+ app.include_router(health_router); app.include_router(objects_router); app.include_router(pairs_router); app.include_router(admin_router); app.include_router(analytics_router)
app/ml.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, FEATURE_LABELS
7
+ BASE_DIR=Path(__file__).resolve().parents[1]
8
+ MODEL_DIR=BASE_DIR/"models"; MODEL_DIR.mkdir(exist_ok=True)
9
+ BASELINE_PATH=MODEL_DIR/"baseline_model.joblib"; ANOMALY_PATH=MODEL_DIR/"anomaly_model.joblib"; FEATURE_COLUMNS_PATH=MODEL_DIR/"feature_columns.json"
10
+ def train_models(X,y):
11
+ 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); clf.fit(X,y)
12
+ anomaly=IsolationForest(n_estimators=180,contamination=0.08,random_state=42); anomaly.fit(X)
13
+ joblib.dump(clf,BASELINE_PATH); joblib.dump(anomaly,ANOMALY_PATH); FEATURE_COLUMNS_PATH.write_text(json.dumps(FEATURE_COLUMNS), encoding="utf-8"); return str(BASELINE_PATH)
14
+ def predict_local(feature_vector):
15
+ clf=joblib.load(BASELINE_PATH); anomaly=joblib.load(ANOMALY_PATH); x=np.array([feature_vector]); risk=float(clf.predict_proba(x)[0][1]); raw=float(anomaly.decision_function(x)[0]); anomaly_score=float(max(0.0,min(1.0,1.0-((raw+0.5)/1.0)))); final=0.72*risk+0.18*anomaly_score+0.10*min(1.0,feature_vector[10] if len(feature_vector)>10 else 0.0); return risk, anomaly_score, float(max(0.0,min(1.0,final)))
16
+ def feature_importance():
17
+ clf=joblib.load(BASELINE_PATH); vals=clf.feature_importances_.tolist(); out=[]
18
+ for name,value in sorted(zip(FEATURE_COLUMNS, vals), key=lambda x:x[1], reverse=True): out.append({"feature":name,"label":FEATURE_LABELS.get(name,name),"importance":float(value)})
19
+ return out
app/models.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from sqlalchemy import String, Float, Integer, DateTime, Text, Boolean
3
+ from sqlalchemy.orm import Mapped, mapped_column
4
+ from app.database import Base
5
+ class SpaceObject(Base):
6
+ __tablename__="space_objects"
7
+ object_id:Mapped[str]=mapped_column(String(64), primary_key=True)
8
+ norad_cat_id:Mapped[int|None]=mapped_column(Integer, index=True, nullable=True)
9
+ object_name:Mapped[str]=mapped_column(String(255), index=True)
10
+ object_type:Mapped[str|None]=mapped_column(String(64), nullable=True)
11
+ mean_motion:Mapped[float|None]=mapped_column(Float, nullable=True)
12
+ inclination:Mapped[float|None]=mapped_column(Float, nullable=True)
13
+ eccentricity:Mapped[float|None]=mapped_column(Float, nullable=True)
14
+ raan:Mapped[float|None]=mapped_column(Float, nullable=True)
15
+ bstar:Mapped[float|None]=mapped_column(Float, nullable=True)
16
+ launch_year:Mapped[int|None]=mapped_column(Integer, nullable=True)
17
+ inserted_at:Mapped[datetime]=mapped_column(DateTime, default=datetime.utcnow)
18
+ class PairScore(Base):
19
+ __tablename__="pair_scores"
20
+ pair_id:Mapped[str]=mapped_column(String(128), primary_key=True)
21
+ primary_object_id:Mapped[str]=mapped_column(String(64), index=True)
22
+ secondary_object_id:Mapped[str]=mapped_column(String(64), index=True)
23
+ latest_run_id:Mapped[str]=mapped_column(String(64), index=True)
24
+ risk_score:Mapped[float]=mapped_column(Float, index=True)
25
+ anomaly_score:Mapped[float]=mapped_column(Float, index=True)
26
+ final_score:Mapped[float]=mapped_column(Float, index=True)
27
+ risk_label:Mapped[str]=mapped_column(String(32), index=True)
28
+ recurrence_count:Mapped[int]=mapped_column(Integer, default=1)
29
+ trend_delta_24h:Mapped[float|None]=mapped_column(Float, nullable=True)
30
+ shell_key:Mapped[str|None]=mapped_column(String(128), index=True, nullable=True)
31
+ top_factors_json:Mapped[str]=mapped_column(Text)
32
+ feature_payload_json:Mapped[str]=mapped_column(Text)
33
+ updated_at:Mapped[datetime]=mapped_column(DateTime, default=datetime.utcnow)
34
+ class PairScoreHistory(Base):
35
+ __tablename__="pair_score_history"
36
+ history_id:Mapped[str]=mapped_column(String(128), primary_key=True)
37
+ pair_id:Mapped[str]=mapped_column(String(128), index=True)
38
+ run_id:Mapped[str]=mapped_column(String(64), index=True)
39
+ risk_score:Mapped[float]=mapped_column(Float)
40
+ anomaly_score:Mapped[float]=mapped_column(Float)
41
+ final_score:Mapped[float]=mapped_column(Float)
42
+ created_at:Mapped[datetime]=mapped_column(DateTime, default=datetime.utcnow)
43
+ class ScoringRun(Base):
44
+ __tablename__="scoring_runs"
45
+ run_id:Mapped[str]=mapped_column(String(64), primary_key=True)
46
+ source:Mapped[str]=mapped_column(String(64))
47
+ object_count:Mapped[int]=mapped_column(Integer, default=0)
48
+ candidate_pair_count:Mapped[int]=mapped_column(Integer, default=0)
49
+ scored_pair_count:Mapped[int]=mapped_column(Integer, default=0)
50
+ completed:Mapped[bool]=mapped_column(Boolean, default=False)
51
+ created_at:Mapped[datetime]=mapped_column(DateTime, default=datetime.utcnow)
app/repository.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session
2
+ from sqlalchemy import select, desc, or_
3
+ from app.models import SpaceObject, PairScore, PairScoreHistory, ScoringRun
4
+ def upsert_space_object(db:Session,payload):
5
+ obj=db.get(SpaceObject,payload["object_id"])
6
+ if obj:
7
+ for k,v in payload.items(): setattr(obj,k,v)
8
+ db.add(obj)
9
+ else: db.add(SpaceObject(**payload))
10
+ def list_objects(db:Session,limit=100): return db.scalars(select(SpaceObject).limit(limit)).all()
11
+ def get_object(db:Session,object_id): return db.get(SpaceObject,object_id)
12
+ def save_pair_score(db:Session,payload):
13
+ row=db.get(PairScore,payload["pair_id"])
14
+ if row:
15
+ for k,v in payload.items(): setattr(row,k,v)
16
+ db.add(row)
17
+ else: db.add(PairScore(**payload))
18
+ def insert_pair_history(db:Session,payload): db.add(PairScoreHistory(**payload))
19
+ def list_high_risk_pairs(db:Session,limit=50): return db.scalars(select(PairScore).order_by(desc(PairScore.final_score)).limit(limit)).all()
20
+ def get_pair(db:Session,pair_id): return db.get(PairScore,pair_id)
21
+ def get_pair_history(db:Session,pair_id,limit=20): return db.scalars(select(PairScoreHistory).where(PairScoreHistory.pair_id==pair_id).order_by(desc(PairScoreHistory.created_at)).limit(limit)).all()
22
+ def create_run(db:Session,payload): db.add(ScoringRun(**payload))
23
+ def latest_runs(db:Session,limit=10): return db.scalars(select(ScoringRun).order_by(desc(ScoringRun.created_at)).limit(limit)).all()
24
+ def get_run(db:Session,run_id): return db.get(ScoringRun,run_id)
25
+ def object_pairs(db:Session,object_id:str,limit=25):
26
+ stmt=select(PairScore).where(or_(PairScore.primary_object_id==object_id, PairScore.secondary_object_id==object_id)).order_by(desc(PairScore.final_score)).limit(limit)
27
+ return db.scalars(stmt).all()
28
+ def pairs_in_same_shell(db:Session,shell_key:str,exclude_pair_id:str,limit=20):
29
+ stmt=select(PairScore).where(PairScore.shell_key==shell_key, PairScore.pair_id!=exclude_pair_id).order_by(desc(PairScore.final_score)).limit(limit)
30
+ return db.scalars(stmt).all()
app/routers/__init__.py ADDED
File without changes
app/routers/admin.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from fastapi import APIRouter, Depends
3
+ from app.database import get_db
4
+ from app.feature_engineering import normalize_object
5
+ from app.ingestion import fetch_celestrak_json
6
+ from app.repository import list_high_risk_pairs, latest_runs, get_run
7
+ from app.services import scoring_cycle, demo_objects
8
+ from app.scripts.train_baseline import run_training
9
+ router=APIRouter(prefix="/api/v1", tags=["admin"])
10
+ @router.post("/ingest/celestrak")
11
+ def ingest_celestrak(db=Depends(get_db)): return {"status":"ok", **scoring_cycle(db, [normalize_object(x) for x in fetch_celestrak_json()], source="celestrak")}
12
+ @router.post("/score/demo-cycle")
13
+ def score_demo_cycle(db=Depends(get_db)): return {"status":"ok", **scoring_cycle(db, demo_objects(db), source="demo")}
14
+ @router.post("/train/baseline")
15
+ def train_baseline():
16
+ model_path,rows,metrics=run_training(); return {"status":"ok","model_path":model_path,"rows_used":rows,"metrics":metrics}
17
+ @router.get("/alerts/live")
18
+ def alerts_live(limit:int=25, db=Depends(get_db)):
19
+ rows=list_high_risk_pairs(db, limit); 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]
20
+ @router.get("/runs")
21
+ def runs(db=Depends(get_db)): 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)]
22
+ @router.get("/runs/{run_id}")
23
+ def run_detail(run_id:str, db=Depends(get_db)):
24
+ r=get_run(db, run_id)
25
+ if not r: return {"detail":"Run not found"}
26
+ rows=[x for x in list_high_risk_pairs(db,200) if x.latest_run_id==run_id]; labels={"low":0,"medium":0,"high":0,"critical":0}
27
+ for x in rows: labels[x.risk_label]=labels.get(x.risk_label,0)+1
28
+ 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,"label_distribution":labels,"top_pairs":[{"pair_id":x.pair_id,"final_score":x.final_score,"risk_label":x.risk_label} for x in sorted(rows, key=lambda z:z.final_score, reverse=True)[:20]]}
app/routers/analytics.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from app.database import get_db
3
+ from app.analytics import shell_analytics, diagnostics_summary
4
+ from app.ml import feature_importance
5
+ router=APIRouter(prefix="/api/v1", tags=["analytics"])
6
+ @router.get("/analytics/shells")
7
+ def get_shell_analytics(limit:int=10, db=Depends(get_db)): return shell_analytics(db, limit)
8
+ @router.get("/diagnostics/summary")
9
+ def get_diagnostics_summary(db=Depends(get_db)): return diagnostics_summary(db)
10
+ @router.get("/model/feature-importance")
11
+ def get_feature_importance(): return feature_importance()
app/routers/health.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from app.config import settings
3
+ router=APIRouter()
4
+ @router.get("/health")
5
+ def health(): return {"status":"ok","app":settings.APP_NAME}
app/routers/objects.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from app.database import get_db
4
+ from app.repository import list_objects, get_object, object_pairs
5
+ router=APIRouter(prefix="/api/v1/objects", tags=["objects"])
6
+ @router.get("")
7
+ def get_objects(limit:int=100, db=Depends(get_db)): return list_objects(db, limit)
8
+ @router.get("/{object_id}")
9
+ def get_object_detail(object_id:str, db=Depends(get_db)):
10
+ obj=get_object(db, object_id)
11
+ if not obj: raise HTTPException(status_code=404, detail="Object not found")
12
+ return obj
13
+ @router.get("/{object_id}/pairs")
14
+ def get_object_related_pairs(object_id:str, limit:int=25, db=Depends(get_db)):
15
+ rows=object_pairs(db, object_id, limit)
16
+ 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]
app/routers/pairs.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from app.database import get_db
4
+ from app.feature_engineering import normalize_object
5
+ from app.services import score_pair
6
+ from app.repository import list_high_risk_pairs, get_pair, get_pair_history, pairs_in_same_shell, object_pairs
7
+ router=APIRouter(prefix="/api/v1", tags=["pairs"])
8
+ @router.post("/score/pair")
9
+ def score_pair_route(payload:dict, db=Depends(get_db)): return score_pair(db, normalize_object(payload["primary"]), normalize_object(payload["secondary"]))
10
+ @router.get("/pairs/high-risk")
11
+ def high_risk(limit:int=50, db=Depends(get_db)):
12
+ rows=list_high_risk_pairs(db, limit)
13
+ 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,"shell_key":r.shell_key,"top_factors":json.loads(r.top_factors_json)} for r in rows]
14
+ @router.get("/pairs/{pair_id}")
15
+ def pair_detail(pair_id:str, db=Depends(get_db)):
16
+ row=get_pair(db, pair_id)
17
+ if not row: raise HTTPException(status_code=404, detail="Pair not found")
18
+ payload=json.loads(row.feature_payload_json)
19
+ return {"pair_id":row.pair_id,"primary_object_id":row.primary_object_id,"secondary_object_id":row.secondary_object_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,"shell_key":row.shell_key,"top_factors":json.loads(row.top_factors_json),"features":payload,"analyst_summary":payload.get("analyst_summary",""),"structured_explanation":payload.get("structured_explanation",{})}
20
+ @router.get("/pairs/{pair_id}/history")
21
+ def pair_history(pair_id:str, limit:int=20, db=Depends(get_db)):
22
+ rows=get_pair_history(db, pair_id, limit)
23
+ 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]
24
+ @router.get("/pairs/{pair_id}/neighbors")
25
+ def pair_neighbors(pair_id:str, limit:int=20, db=Depends(get_db)):
26
+ row=get_pair(db, pair_id)
27
+ if not row: raise HTTPException(status_code=404, detail="Pair not found")
28
+ shell_rows=pairs_in_same_shell(db, row.shell_key or "unknown", row.pair_id, limit)
29
+ object_related=object_pairs(db, row.primary_object_id, limit)+object_pairs(db, row.secondary_object_id, limit)
30
+ seen={pair_id}; related=[]
31
+ for r in shell_rows+object_related:
32
+ if r.pair_id in seen: continue
33
+ seen.add(r.pair_id); related.append({"pair_id":r.pair_id,"final_score":r.final_score,"risk_label":r.risk_label,"top_factors":json.loads(r.top_factors_json)})
34
+ if len(related)>=limit: break
35
+ return related
app/scripts/bootstrap_demo.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ def bootstrap_if_needed():
8
+ Base.metadata.create_all(bind=engine); model_path=Path(__file__).resolve().parents[2]/"models"/"baseline_model.joblib"
9
+ db=SessionLocal()
10
+ try: has_objects=bool(list_objects(db,5))
11
+ finally: db.close()
12
+ if not has_objects: seed_main()
13
+ if not model_path.exists(): run_training()
14
+ db=SessionLocal()
15
+ try:
16
+ if not latest_runs(db,1): scoring_cycle(db, demo_objects(db), source="bootstrap-demo")
17
+ finally: db.close()
18
+ if __name__=="__main__": bootstrap_if_needed()
app/scripts/seed_synthetic.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from app.database import Base, engine, SessionLocal
3
+ from app.repository import upsert_space_object
4
+ def synthetic_objects(n=300):
5
+ items=[]
6
+ for i in range(n):
7
+ shell=random.choice([(15.2,53),(14.9,98),(13.8,74),(2.0,0),(12.5,55)]); mm,inc=shell
8
+ items.append({"object_id":f"OBJ-{i+1}","norad_cat_id":10000+i,"object_name":f"SIM_OBJECT_{i+1}","object_type":random.choice(["PAYLOAD","DEBRIS","ROCKET BODY"]),"mean_motion":round(random.gauss(mm,0.12),5),"inclination":round(random.gauss(inc,1.4),5),"eccentricity":round(abs(random.gauss(0.001,0.002)),6),"raan":round(random.uniform(0,360),4),"bstar":round(random.uniform(0.00001,0.005),8),"launch_year":random.randint(1998,2025)})
9
+ return items
10
+ def main():
11
+ Base.metadata.create_all(bind=engine); db=SessionLocal()
12
+ try:
13
+ for item in synthetic_objects(): upsert_space_object(db,item)
14
+ db.commit(); print("Synthetic objects loaded.")
15
+ finally: db.close()
16
+ if __name__=="__main__": main()
app/scripts/train_baseline.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ def run_training():
10
+ db=SessionLocal()
11
+ try:
12
+ objs=list_objects(db,5000); 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]
13
+ finally: db.close()
14
+ pairs=[tuple(random.sample(objects,2)) for _ in range(4000)]; g=build_graph([(a["object_id"],b["object_id"]) for a,b in pairs[:1000]]); rows=[]; raw_scores=[]
15
+ for a,b in pairs:
16
+ 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))}
17
+ f=combine_features(a,b,trend,pair_graph_features(g,a["object_id"],b["object_id"]))
18
+ 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)
19
+ y=1 if score>0.48 else 0; rows.append({**f,"label":y}); raw_scores.append(score)
20
+ df=pd.DataFrame(rows); path=train_models(df[FEATURE_COLUMNS].values, df["label"].values); auc=float(roc_auc_score(df["label"].values, np.array(raw_scores)))
21
+ return path, len(df), {"pseudo_auc":round(auc,4),"rows":int(len(df))}
22
+ if __name__=="__main__":
23
+ p,r,m=run_training(); print(json.dumps({"model_path":p,"rows":r,"metrics":m}, indent=2))
app/services.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from itertools import combinations
2
+ from app.config import settings
3
+ from app.feature_engineering import combine_features, orbital_shell_key, FEATURE_COLUMNS
4
+ from app.graph_features import build_graph, pair_graph_features
5
+ from app.repository import upsert_space_object, save_pair_score, insert_pair_history, create_run, get_pair_history, list_objects
6
+ from app.ml import predict_local
7
+ from app.utils import new_id, dumps
8
+ from app.explanations import build_top_factors, analyst_summary, structured_explanation, recommended_action
9
+ def demo_objects(db,limit=200):
10
+ rows=list_objects(db,limit=limit)
11
+ 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]
12
+ def generate_candidate_pairs(objects):
13
+ grouped={}
14
+ for obj in objects: grouped.setdefault(orbital_shell_key(obj), []).append(obj)
15
+ candidates=[]
16
+ for group in grouped.values():
17
+ if len(group)<2: continue
18
+ for a,b in combinations(group[:120],2):
19
+ candidates.append((a,b))
20
+ if len(candidates)>=settings.MAX_CANDIDATE_PAIRS: return candidates
21
+ return candidates
22
+ def _trend_features(db,pair_id):
23
+ hist=get_pair_history(db,pair_id,limit=10)
24
+ if len(hist)<2: return {"recurrence_count":float(len(hist)),"trend_delta_score":0.0,"score_volatility_proxy":0.0}
25
+ scores=[h.final_score for h in hist]; avg=sum(scores)/len(scores); vol=sum(abs(x-avg) for x in scores)/len(scores)
26
+ return {"recurrence_count":float(len(hist)),"trend_delta_score":float(scores[0]-scores[-1]),"score_volatility_proxy":float(vol)}
27
+ def score_pair(db,a,b,graph_feats=None):
28
+ pair_id=f"{a['object_id']}__{b['object_id']}"; trend=_trend_features(db,pair_id); graph=graph_feats or {"graph_degree_sum":0.0,"graph_common_neighbors":0.0,"graph_jaccard":0.0,"graph_local_density":0.0}
29
+ features=combine_features(a,b,trend,graph); vector=[float(features.get(c,0.0)) for c in FEATURE_COLUMNS]; risk,anomaly,final=predict_local(vector)
30
+ label="critical" if final>=0.9 else "high" if final>=0.75 else "medium" if final>=0.45 else "low"
31
+ top=build_top_factors(features,anomaly,final); action=recommended_action(label); summary=analyst_summary(features,top,final); structured=structured_explanation(features,top,final,action)
32
+ return {"pair_id":pair_id,"risk_score":risk,"anomaly_score":anomaly,"final_score":final,"risk_label":label,"top_factors":top,"analyst_summary":summary,"structured_explanation":structured,"recommended_action":action,"features":features}
33
+ def scoring_cycle(db,objects,source="demo"):
34
+ run_id=new_id("run"); create_run(db,{"run_id":run_id,"source":source,"object_count":len(objects),"candidate_pair_count":0,"scored_pair_count":0,"completed":False})
35
+ for obj in objects: upsert_space_object(db,obj)
36
+ db.commit(); candidates=generate_candidate_pairs(objects); graph=build_graph([(a["object_id"],b["object_id"]) for a,b in candidates]); count=0
37
+ for a,b in candidates:
38
+ result=score_pair(db,a,b,pair_graph_features(graph,a["object_id"],b["object_id"])); hist=get_pair_history(db,result["pair_id"],limit=20); recurrence=len(hist)+1; trend_delta=result["final_score"]-hist[-1].final_score if hist else 0.0
39
+ save_pair_score(db,{"pair_id":result["pair_id"],"primary_object_id":a["object_id"],"secondary_object_id":b["object_id"],"latest_run_id":run_id,"risk_score":result["risk_score"],"anomaly_score":result["anomaly_score"],"final_score":result["final_score"],"risk_label":result["risk_label"],"recurrence_count":recurrence,"trend_delta_24h":trend_delta,"shell_key":orbital_shell_key(a),"top_factors_json":dumps(result["top_factors"]),"feature_payload_json":dumps(result["features"]|{"analyst_summary":result["analyst_summary"],"structured_explanation":result["structured_explanation"]})})
40
+ 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"]}); count+=1
41
+ from app.models import ScoringRun
42
+ run=db.get(ScoringRun,run_id); run.candidate_pair_count=len(candidates); run.scored_pair_count=count; run.completed=True; db.add(run); db.commit()
43
+ 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,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import json, uuid
2
+ def safe_float(v,d=0.0):
3
+ try: return d if v in (None,"") else float(v)
4
+ except Exception: return d
5
+ def safe_int(v,d=0):
6
+ try: return d if v in (None,"") else int(v)
7
+ except Exception: return d
8
+ def dumps(x): return json.dumps(x, default=str)
9
+ def new_id(prefix): 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
+ numpy==2.1.2
7
+ pandas==2.2.3
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