Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ace3e40
feat(frontend): Threat Assessment Layer over the ML classifier
Trivikram-Narayanan Jul 26, 2026
013c8d4
feat: fleet-scale swarm tracking (100-1000 drones) + live ONNX model …
Trivikram-Narayanan Jul 30, 2026
4be8edc
feat: live PDF export of real SHAP + LIME explanations
Trivikram-Narayanan Jul 30, 2026
d4692ac
feat: alerting hooks for CRITICAL threats (simulated fan-out + real e…
Trivikram-Narayanan Jul 30, 2026
fa7f06c
feat: Add 3D Radar, Threat Assessment, Model Engine Config & Confusio…
ravindraogg Jul 30, 2026
499a41a
feat: geo/map overlay with Leaflet + OpenStreetMap
Trivikram-Narayanan Jul 30, 2026
a26098b
Merge remote-tracking branch 'origin/fleet-and-new-features' into fle…
Trivikram-Narayanan Jul 30, 2026
569e19d
feat: click-to-inspect map details + city search/relocate
Trivikram-Narayanan Jul 30, 2026
5389d93
feat: real 3D map view (MapLibre GL + OpenFreeMap, no API key)
Trivikram-Narayanan Jul 30, 2026
61c42d6
fix: 3D buildings not rendering -- initial zoom sat on the extrusion'…
Trivikram-Narayanan Jul 30, 2026
5723f6c
feat: real geolocation origin + shared physics presets + real buildin…
Trivikram-Narayanan Jul 30, 2026
41d29c7
fix: real geolocation origin + shared physics presets + real building…
Trivikram-Narayanan Jul 30, 2026
ef16b7b
feat: /benchmark page -- all model variants x all SNR scenarios, one …
Trivikram-Narayanan Jul 30, 2026
ce15e0f
fix: raise benchmark samples/class cap, show real cost instead of gue…
Trivikram-Narayanan Jul 30, 2026
3b382b5
fix: 3D buildings never rendering -- maplibre-gl's worker never loaded
Trivikram-Narayanan Jul 30, 2026
ef6b339
fix: target markers invisible in 3D map -- cssText clobbered MapLibre…
Trivikram-Narayanan Jul 30, 2026
0d1aee8
fix(hardware): update radar simulator hardware angle mapping and obst…
PanatiNitesh Jul 30, 2026
47a7340
docs: update README.md to reflect complete codebase features, fronten…
ravindraogg Aug 1, 2026
bf828be
Merge branch 'main' into fleet-and-new-features
ravindraogg Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "radarsense-frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
"port": 3000,
"autoPort": true
}
]
}
270 changes: 188 additions & 82 deletions README.md

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# RadarSense — Feature Inventory & Roadmap

_Generated 2026-07-30. Recap of what already exists in the repo, plus a prioritized
backlog of what to add next._

## 1. What's already built

**Radar DSP & real-sensor pipeline**
- Real 77GHz I/Q → range-Doppler FFT → CA-CFAR → Kalman tracking (`radarsense/radar_dsp.py`)
- 4 real sensors bridged into the frozen 11-feature schema: SAAB 77GHz FMCW, UPM RAD-DAR 8.75GHz,
TSMS-Drone 2.5GHz CW, DIAT-µSAT X-band (`real_data_bridge.py`, `raddar_experiments.py`,
`tsms_experiments.py`, `diat_usat_bridge.py`)
- Multi-target tracker (OS-CFAR + GNN association + Kalman bank) validated on **3** synthetic
targets per scene — see gap #1 below (`radarsense/multi_target_tracking.py`)
- Multi-sensor fusion module (`radarsense/multi_sensor_fusion.py`)
- Live acoustic (phone-mic) drone demo as a second real sensor modality (`acoustic_demo.py`)

**Classification core**
- 6-model bake-off (LogReg/RF/BalancedRF/XGBoost/CatBoost/SVM), 5-fold CV (`train_baseline.py`)
- Minority-class optimization: SMOTE vs class-weighting vs combined, F2-tuned threshold
(`optimize.py`)
- Isotonic calibration + reliability diagrams (`calibrate.py`)
- Three feature-schema variants in production: classic 11-feature, 31-feature, 42-feature
combined (`feat31_*.py`, `feat42_*.py`)
- Split-conformal uncertainty (90% coverage guarantee)

**Explainability**
- SHAP summary + per-detection waterfall (`explain.py`, `feat31_explain.py`, `feat42_explain.py`,
`shap_corrected.py`)
- LIME tabular explainer for classical + quantum models (`ensemble_decision_explainer.py`,
`feat42_explain.py`) — **offline only**, writes JSON/plots, not exposed live
- Frontend currently only has a lightweight **client-side approximation** of SHAP
(`getShapContributions` in `frontend/app/page.tsx`), not the real backend SHAP/LIME output

**Robustness / security**
- Certified adversarial robustness via randomized smoothing (83% provably un-evadable)
- Physical-cost-constrained evasion study (100% unconstrained → ~22% achievable)
- Micro-Doppler masking attack + tunable masking defense/rescue (`masking_rescue.py`, wired into
`/predict/classic`)
- Model-extraction attack + defense (`model_extraction_attack.py`, `extraction_defense*.py`)
- Adversarial training (`adversarial_training.py`)

**Quantum research**
- QSVC fixed-kernel ablation vs classical RBF/linear (`quantum.py`)
- Trainable VQC, two SDKs: Qiskit (COBYLA/SPSA) and PennyLane (gradient descent rescue)
(`vqc.py`, `vqc_improved.py`, `vqc_pennylane.py`)
- Real IBM hardware runs, error-mitigated (`ibm_hardware_kernel*.py`)
- Classical/quantum stacking ensemble (`classical_quantum_ensemble*.py`)
- Honest negative result reported throughout (classical wins at tested scales)

**Deployment**
- ONNX export, 1.0000 parity, 0.03ms median latency (`export_onnx.py`)
- Unified FastAPI server exposing all 4 model variants: classic/31/42/quantum
(`api/app.py`, plus standalone `server.py`, `quantum_server.py`, `feat31_server.py`,
`feat42_server.py`)
- Next.js live simulator: client-side ONNX inference (onnxruntime-web), SNR presets, target
subtype presets, theme toggle, session stats (`frontend/app/page.tsx`,
`RadarSimulator.tsx` — 2.7k lines)
- `hard/page.tsx` — 3D radar visualization variant
- Threat Assessment Layer: deterministic doctrine engine on top of the classifier output —
proximity/closing/TTI/altitude/evasion/uncertainty scoring, CRITICAL/ELEVATED/MONITOR/CLEAR
levels (`frontend/app/components/threatAssessment.ts`)
- Arduino Nano hardware-in-the-loop demo

**Testing / CI**
- `pytest tests -q` + GitHub Actions CI (`tests/`, `.github/workflows/ci.yml`)

---

## 2. Gaps / new features to add

### 🔴 High priority (explicitly requested — keep these)

1. **Fleet-scale swarm detection (100–1000 simultaneous drones)**
Current `multi_target_tracking.py` is only validated at N=3 targets on a synthetic scene, and
the frontend renders individually-spawned targets one at a time — neither the tracker nor the
UI has been proven at swarm scale. Needed:
- Stress-test the OS-CFAR + GNN + Kalman-bank tracker at 100 / 500 / 1000 simultaneous targets;
measure cardinality error, spurious-track rate, and association latency at scale (greedy GNN
is O(n²) per frame — likely needs spatial gating/binning or a faster assignment algorithm,
e.g. k-d tree gating or Hungarian-on-gated-subsets, once target count gets large)
- Backend: a **fleet/session aggregation layer** — dedupe tracks, roll up per-fleet stats
(count by class, count by threat level, closest/most-urgent track), swarm-pattern detection
(many tracks with correlated heading/velocity = coordinated swarm behavior, not independent
birds/aircraft — a distinct threat signature worth flagging on its own)
- Frontend: a **swarm view mode** — individual icons stop scaling past some threshold (~30–50
tracks); switch to clustered/density rendering (heatmap or aggregated cluster markers) with
drill-down, plus virtualized list rendering for the detection table so 1000 rows doesn't
tank the UI
- API: batch endpoint already exists (`/batch` per `api/server.py`) for bulk classification —
but no endpoint yet for ingesting/tracking a whole scene's worth of detections per frame and
returning fleet-level state

2. **PDF export of SHAP / LIME explanations**
SHAP and LIME both exist today (`explain.py`, `feat42_explain.py`,
`ensemble_decision_explainer.py`) but only as offline scripts that write JSON + matplotlib
PNGs — nothing generates a shareable report, and the frontend's "SHAP" panel is actually a
client-side approximation, not the real backend output. Needed:
- Backend: an export endpoint (e.g. `/explain/{detection_id}/pdf`) that renders the real SHAP
waterfall + LIME feature-weight breakdown for a given detection into a PDF (reportlab or
weasyprint; matplotlib figures embed cleanly into either)
- Bundle explainability report generation for a whole session (all detections in a run), not
just one detection at a time
- Frontend: an "Export PDF Report" button on the detection/inspection panel that calls the
endpoint and downloads the file
- Decide whether to wire the frontend to real backend SHAP values instead of the client-side
approximation now that there's a reason to (the PDF must be trustworthy, not the JS heuristic)

### 🟡 Other suggested features

- **Real SHAP/LIME in the live API**, not just offline scripts — an `/explain` endpoint the
frontend and PDF exporter both call, replacing the JS approximation
- **Persistent track/session history** — currently everything is in-memory in the browser tab;
no database to query "what did we see last Tuesday" or replay a session
- **Alerting/notification hooks** — CRITICAL threat level currently only changes UI color; no
webhook/email/SMS integration for unattended operation
- **WebSocket streaming** instead of polling/local simulation loop, for when a real sensor feed
(or the fleet-scale scenario above) needs push updates rather than client-side generation
- **Auth / role-based access** on the FastAPI server — currently open CORS, no auth, fine for a
demo but not for anything resembling deployment
- **Geo/map integration** — the scope is a synthetic 2D polar display; no real-world coordinate
system or map overlay
- **Drift-monitoring dashboard** — `feature_drift.py` exists as a script; not surfaced anywhere
operators would see it live
- **Frontend E2E tests** — `tests/` covers the Python pipeline; no Playwright/Cypress coverage of
`RadarSimulator.tsx`
- **Containerization** — no Dockerfile/docker-compose for the API + frontend + (optional) quantum
backend, despite `start_all.sh` running them as local processes

---

## 3. Suggested sequencing

1. PDF export (contained, no architecture change, high demo value)
2. Fleet-scale stress test + backend aggregation (validates the swarm claim before touching UI)
3. Fleet swarm view in frontend (depends on #2's aggregation shape)
4. Real SHAP/LIME live endpoint (unlocks trustworthy PDF content + removes the JS approximation)
5. Everything else, prioritized by whether there's a demo/judging deadline driving it
204 changes: 204 additions & 0 deletions api/alerting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
"""Alerting hooks: dispatch a simulated notification fan-out when a track's
threat level goes CRITICAL, and (optionally) a real webhook call.

IMPORTANT -- what this does and does NOT do:
- The "recipient roster" below is entirely fictional/demo data: generic
role titles (not real named officials or real agencies), RFC 2606
reserved example.com addresses, and 555-01xx phone numbers (the
universally-recognized fake-number convention). NO real SMS or email is
ever sent to anyone -- every dispatch is logged as SIMULATED.
- The one REAL side effect this module can have is an optional generic
webhook POST, and only if the operator sets RADARSENSE_ALERT_WEBHOOK_URL
themselves (e.g. to their own Slack/Discord incoming-webhook URL) -- that
is the actual integration point a real deployment would wire up, kept
separate from the simulated roster so the two are never confused.

Rate-limited per track (COOLDOWN_S) so one persistent CRITICAL track doesn't
spam the log every polling tick.

Real personal email delivery (separate from the simulated roster above): if
RADARSENSE_SMTP_PASS is set (a Gmail App Password -- myaccount.google.com ->
Security -> App Passwords -- generated and stored by the operator, never
handled by this code beyond reading the env var), dispatch_alert also sends
a real email via Gmail SMTP to RADARSENSE_ALERT_EMAIL_TO. SMS to a real
number is intentionally NOT implemented -- it needs a paid third-party
provider account (Twilio etc.) the operator would have to set up themselves;
until then the personal phone number is shown in the response as a labeled,
explicitly-simulated entry, same as the fictional roster.
"""

import os
import smtplib
import time
import uuid
from collections import OrderedDict
from email.message import EmailMessage

import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

router = APIRouter(prefix="/alert", tags=["alert"])

COOLDOWN_S = 30
MAX_HISTORY = 200
WEBHOOK_URL = os.environ.get("RADARSENSE_ALERT_WEBHOOK_URL", "").strip()
WEBHOOK_TIMEOUT_S = 4.0

SMTP_HOST = os.environ.get("RADARSENSE_SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.environ.get("RADARSENSE_SMTP_PORT", "587"))
SMTP_USER = os.environ.get("RADARSENSE_SMTP_USER", "trivikramnarayanan@gmail.com")
SMTP_PASS = os.environ.get("RADARSENSE_SMTP_PASS", "").strip()
ALERT_EMAIL_TO = os.environ.get("RADARSENSE_ALERT_EMAIL_TO", "trivikramnarayanan@gmail.com")
PERSONAL_PHONE = os.environ.get("RADARSENSE_ALERT_PHONE", "7358084532")

# Fictional demo roster -- see module docstring. Titles are generic/regional,
# not tied to any real agency; contacts use IANA-reserved example.com and the
# 555-01xx fake-number block on purpose.
ROSTER = [
{"role": "Regional Air Defense Command", "channel": "sms",
"contact": "+1-555-0101 (demo)"},
{"role": "Regional Air Defense Command", "channel": "email",
"contact": "air-defense-command@alerts.example.com"},
{"role": "Counter-UAS Coordination Cell", "channel": "sms",
"contact": "+1-555-0102 (demo)"},
{"role": "Counter-UAS Coordination Cell", "channel": "email",
"contact": "counter-uas-cell@alerts.example.com"},
{"role": "Civil Administration Liaison", "channel": "email",
"contact": "civil-liaison@alerts.example.com"},
{"role": "Local Law Enforcement Control Room", "channel": "sms",
"contact": "+1-555-0103 (demo)"},
]

_history: "OrderedDict[str, dict]" = OrderedDict()
_last_dispatch: dict = {} # track_id -> last dispatch epoch seconds


class DispatchRequest(BaseModel):
track_id: str
predicted_class: str
threat_level: str
threat_score: float
range_m: float
closing_mps: float
tti_sec: float | None = None
rationale: str = ""


@router.get("/roster")
def get_roster():
return {"roster": ROSTER,
"note": "Simulated demo roster -- fictional roles, RFC2606 example.com "
"addresses and 555-01xx numbers. No real SMS/email is ever sent."}


@router.post("/dispatch")
def dispatch_alert(req: DispatchRequest):
now = time.time()
last = _last_dispatch.get(req.track_id, 0)
if now - last < COOLDOWN_S:
return {"dispatched": False,
"reason": f"cooldown active ({COOLDOWN_S - (now - last):.0f}s remaining)"}
_last_dispatch[req.track_id] = now

alert_id = uuid.uuid4().hex[:12]
recipients = [
{**r, "status": "simulated_sent", "simulated": True}
for r in ROSTER
]

webhook_result = None
if WEBHOOK_URL:
webhook_result = _fire_webhook(req, alert_id)

email_result = _send_email(req, alert_id) if SMTP_PASS else \
{"attempted": False, "reason": "RADARSENSE_SMTP_PASS not set -- see api/alerting.py docstring"}

personal_contacts = [
{"role": "Operator (personal)", "channel": "email", "contact": ALERT_EMAIL_TO,
"simulated": False, "status": "sent" if email_result.get("ok") else "not_sent",
"detail": email_result},
{"role": "Operator (personal)", "channel": "sms", "contact": PERSONAL_PHONE,
"simulated": True, "status": "simulated_sent",
"detail": {"reason": "real SMS needs a provider account (Twilio etc.) -- not configured"}},
]

record = {
"alert_id": alert_id,
"timestamp": now,
"track_id": req.track_id,
"predicted_class": req.predicted_class,
"threat_level": req.threat_level,
"threat_score": req.threat_score,
"range_m": req.range_m,
"closing_mps": req.closing_mps,
"tti_sec": req.tti_sec,
"rationale": req.rationale,
"recipients": recipients,
"personal_contacts": personal_contacts,
"webhook": webhook_result,
}
_history[alert_id] = record
while len(_history) > MAX_HISTORY:
_history.popitem(last=False)

return {"dispatched": True, "alert": record}


def _send_email(req: DispatchRequest, alert_id: str) -> dict:
"""Real email via Gmail SMTP + App Password. Only runs if SMTP_PASS is
set (see module docstring) -- never attempted otherwise."""
msg = EmailMessage()
msg["Subject"] = f"[RadarSense] CRITICAL threat — {req.predicted_class.upper()} track {req.track_id}"
msg["From"] = SMTP_USER
msg["To"] = ALERT_EMAIL_TO
lines = [
f"RadarSense threat alert (alert_id {alert_id})",
"",
f"Track: {req.track_id}",
f"Predicted class: {req.predicted_class}",
f"Threat level: {req.threat_level} (score {req.threat_score:.0f})",
f"Range: {req.range_m:.0f} m",
f"Closing speed: {req.closing_mps:.1f} m/s",
]
if req.tti_sec is not None:
lines.append(f"Time to intercept: {req.tti_sec:.0f}s")
lines.append(f"Rationale: {req.rationale}")
msg.set_content("\n".join(lines) + "\n")
try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASS)
server.send_message(msg)
return {"attempted": True, "ok": True}
except Exception as e:
return {"attempted": True, "ok": False, "error": str(e)}


def _fire_webhook(req: DispatchRequest, alert_id: str) -> dict:
payload = {
"text": f"[RadarSense] CRITICAL threat — track {req.track_id} "
f"({req.predicted_class}), range {req.range_m:.0f}m, "
f"closing {req.closing_mps:.1f}m/s. {req.rationale}",
"alert_id": alert_id,
}
try:
resp = httpx.post(WEBHOOK_URL, json=payload, timeout=WEBHOOK_TIMEOUT_S)
return {"attempted": True, "status_code": resp.status_code, "ok": resp.is_success}
except Exception as e:
return {"attempted": True, "ok": False, "error": str(e)}


@router.get("/history")
def get_history(limit: int = 50):
items = list(_history.values())[-limit:]
items.reverse()
return {"count": len(_history), "alerts": items,
"webhook_configured": bool(WEBHOOK_URL)}


@router.delete("/history")
def clear_history():
_history.clear()
_last_dispatch.clear()
return {"cleared": True}
Loading
Loading