Upload 26 files
Browse files- Dockerfile +10 -0
- README.md +11 -0
- app/__init__.py +0 -0
- app/analytics.py +17 -0
- app/config.py +12 -0
- app/database.py +11 -0
- app/explanations.py +19 -0
- app/feature_engineering.py +28 -0
- app/graph_features.py +12 -0
- app/ingestion.py +7 -0
- app/main.py +17 -0
- app/ml.py +19 -0
- app/models.py +51 -0
- app/repository.py +30 -0
- app/routers/__init__.py +0 -0
- app/routers/admin.py +28 -0
- app/routers/analytics.py +11 -0
- app/routers/health.py +5 -0
- app/routers/objects.py +16 -0
- app/routers/pairs.py +35 -0
- app/scripts/bootstrap_demo.py +18 -0
- app/scripts/seed_synthetic.py +16 -0
- app/scripts/train_baseline.py +23 -0
- app/services.py +43 -0
- app/utils.py +9 -0
- requirements.txt +12 -0
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
|