Upload 24 files
Browse files- Dockerfile +10 -0
- README.md +5 -4
- app/__init__.py +0 -0
- app/config.py +14 -0
- app/database.py +17 -0
- app/explanations.py +27 -0
- app/feature_engineering.py +67 -0
- app/graph_features.py +26 -0
- app/ingestion.py +8 -0
- app/main.py +25 -0
- app/ml.py +33 -0
- app/models.py +54 -0
- app/repository.py +42 -0
- app/routers/__init__.py +0 -0
- app/routers/admin.py +32 -0
- app/routers/health.py +8 -0
- app/routers/objects.py +10 -0
- app/routers/pairs.py +31 -0
- app/scripts/bootstrap_demo.py +28 -0
- app/scripts/seed_synthetic.py +36 -0
- app/scripts/train_baseline.py +35 -0
- app/services.py +90 -0
- app/utils.py +23 -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
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
---
|
| 2 |
title: Space Risk Intelligence API
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
| 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
|