Skip to content

Implement Threat Assessment Layer and Fleet-Scale Swarm Tracking - #3

Merged
ravindraogg merged 19 commits into
mainfrom
fleet-and-new-features
Aug 1, 2026
Merged

ravindraogg merged 19 commits into
mainfrom
fleet-and-new-features

Conversation

@ravindraogg

Copy link
Copy Markdown
Member

No description provided.

Trivikram-Narayanan and others added 19 commits July 26, 2026 17:58
Adds a deterministic doctrine layer (threatAssessment.ts) that turns a
track's classified identity + kinematics (range, closing velocity, TTI,
altitude, micro-Doppler suppression) into a threat level (CLEAR/MONITOR/
ELEVATED/CRITICAL/UNCERTAIN), 0-100 score and rationale.

- Scope: per-track threat pill + HIGHEST-THREAT banner
- Inspector: full breakdown card (factor bars, range/closing/TTI, rationale)
- Low-confidence drones escalate to UNCERTAIN (never silently dismissed)
- Micro-Doppler suppression on a drone flags evasion, tying into the
  masking-defense work
- Read-only over lastPrediction; does not touch the ONNX inference path
…wiring

Fleet-scale tracking (radarsense/fleet_tracking.py):
- Generalizes multi_target_tracking.py's 3-target validation to a real
  1024x768 range-Doppler swarm scene -- a single 256-bin Doppler axis
  can't hold 1000 separable targets.
- Vectorized OS-CFAR (sliding-window, whole grid at once) replacing the
  per-bin Python loop that isn't fast enough at this grid size.
- KD-tree-gated track association (scipy.spatial.cKDTree) replacing the
  O(n^2) pairwise scan, plus KD-tree 2D non-max suppression for peak
  picking.
- Fleet-aggregation layer: confirmed/tentative counts, association
  latency, and a coordinated-swarm flag from track heading coherence.
- Measured stress test (100/500/1000 targets): cardinality error 0/1/14,
  association latency ~4.6/20/40ms mean, completeness 86-89%.
- New API endpoints (api/fleet.py): session-based frame-by-frame tracking
  plus a stress-test report endpoint, mounted into api/app.py.
- New /fleet frontend page + FleetPanel.tsx: live swarm demo (up to 300
  targets, real backend calls) and the validated stress-test table.
- Tests added to tests/test_core.py for the vectorized CFAR, cardinality
  at moderate scale, and swarm-pattern detection.

ONNX wiring fix:
- frontend/public/models/ never existed, so the live simulator's
  auto-load fetches for feat31_model.onnx / radarsense.onnx 404'd and it
  silently ran in synthetic-stub demo mode. Copied the trained artifacts
  from artifacts/onnx/ (already git-tracked) into frontend/public/models/
  and fixed a bug where the fallback model was always mislabeled as
  feat31_model.onnx regardless of which file actually loaded.

Also adds TODO.md (feature roadmap) and .claude/launch.json (frontend
dev-server preview config).
radarsense/explain_live.py: the deployment counterpart to explain.py
(which only ever explains one fixed, pre-picked test-set row offline).
This explains whatever detection the caller sends, live -- same
artifacts/models/final_model.joblib CatBoost model already validated by
explain.py's physics-sanity check, same SHAP TreeExplainer, and a
LimeTabularExplainer over the real training distribution.

api/explain.py: mounts /explain (JSON bundle) and /explain/pdf (a
reportlab-rendered, multi-page PDF report: prediction summary, feature
vector, SHAP bar chart + table, LIME bar chart + table). Verified the
actual rendered PDF pages, not just a byte count -- fixed one layout bug
(KeepTogether around each chart+table section, so a heading can't be
orphaned at the bottom of a page).

Frontend: Target Track Inspector's "Live SHAP Top Contributors" panel
gets an "Export PDF" button. Adds Target.lastDetectedFeatures11 --
always the 11 frozen-schema features regardless of which ONNX model
(11/31/42-feature) is active for live display, since the explain
endpoint needs that exact vector. Verified live in the browser: real
network round-trip to /explain/pdf, 200 OK, correct PDF bytes.

3 new tests (physics-sanity agreement, LIME feature coverage, PDF
endpoint content-type/magic-bytes/size). Full suite: 19/19 passing.
…mail)

api/alerting.py: dispatches a simulated notification fan-out (fictional
role titles, RFC2606 example.com addresses, 555-01xx numbers -- no real
SMS/email ever sent to this roster) whenever a track's threat level goes
CRITICAL, rate-limited per track (30s cooldown). Two real, opt-in side
channels, both off by default and clearly separated from the simulated
roster: a personal email via Gmail SMTP (RADARSENSE_SMTP_PASS, an App
Password the operator generates and sets themselves -- never handled in
code beyond reading the env var) and a generic webhook
(RADARSENSE_ALERT_WEBHOOK_URL) for wiring a real Slack/Discord channel.
Real SMS is intentionally not implemented -- it needs a paid third-party
provider account; the phone number is shown as an explicitly-simulated
entry instead.

Fixed a real bug while writing the email body: a string-literal-adjacent
ternary (`f"a" f"b" if cond else "" f"c"`) that would have silently
dropped most of the message depending on whether tti_sec was set --
replaced with an explicit line list.

Frontend: main page auto-dispatches when the Threat Assessment panel
hits CRITICAL and shows a compact dispatch indicator; new /alerts page
(AlertCenterPanel.tsx) shows the roster, real-channel config status, and
dispatch history, with a manual "Send Test Alert" button. Added to nav
across all pages.

2 new tests (roster is fictional-only, dispatch + cooldown). Full suite:
21/21 passing. Verified live in the browser: test dispatch round-trips
through the real API and appears in history.
frontend/app/components/GeoMapPanel.tsx + LeafletMapInner.tsx: a real-world
coordinate view of the radar picture. Converts each target's local (east,
north) metres -- same x/y convention RadarSimulator.tsx already uses -- to
lat/lon around a configurable origin via the standard equirectangular
approximation, rendered on free OpenStreetMap tiles (no API key). Origin
defaults to 0,0 (Null Island), a deliberately generic placeholder rather
than any specific real site. Colored div-icon markers by class, matching
the existing red/blue/amber/purple palette.

Self-contained demo scene (own small simulated target set), same scoping
choice as FleetPanel -- there's no existing state channel to mirror the
main simulator's live targets without a larger refactor, documented as
such in the component.

New /map page + nav link added across all pages. Verified live: markers
render with correct positions/colors (checked via DOM inspection since
this sandbox's browser blocks tile.openstreetmap.org specifically --
confirmed reachable via curl from the shell, so this is a test-environment
restriction, not a product issue).
GeoMapPanel/LeafletMapInner:
- Added altitude (AGL) to the simulated targets, class-banded similar to
  RadarSimulator.tsx's SUBTYPE_PRESETS ranges.
- Clicking a marker now opens a detail panel: latitude, longitude,
  elevation, range, bearing, and speed -- not just the small Leaflet
  popup from before.
- City search via Nominatim (OSM's free geocoder, no API key) -- type a
  city name, pick a result, the origin (and demo scene, if running) jumps
  there.
- "Pick on map" mode: click anywhere on the tile layer to set that point
  as the origin directly, no typing needed.

Verified live: click-to-inspect populates real computed values (checked
against the underlying x/y), city search round-trips through the real
Nominatim API and correctly updates the origin fields, and re-running the
demo after a jump spawns targets around the new location.
Adds a 2D/3D toggle to the Geo Overlay panel. 3D uses MapLibre GL JS --
an open-source Mapbox GL fork that needs no API key or account -- against
OpenFreeMap's free vector tiles, giving an actual pitched/tiltable view
with extruded 3D buildings, not a paid-provider dependency (Mapbox/Cesium
would need a signup). Marker size in 3D also scales with each target's
altitude, so elevation reads visually as well as numerically in the
detail panel from the previous commit.

Verified: TypeScript compiles clean, MapLibre initializes correctly
(WebGL context present, nav controls and attribution render, markers
position correctly at the right screen coordinates -- checked via DOM
inspection). Could not get a clean visual screenshot of the vector-tile
terrain/buildings actually painting inside this session's sandboxed test
browser specifically -- the underlying OpenFreeMap style/tile endpoints
are independently confirmed reachable via curl and via in-page fetch, and
the same "blocked at first, then rendered" pattern happened with the 2D
raster tiles earlier in this branch's work, so this reads as a WebGL/
worker-fetch quirk of the automation sandbox, not the implementation.
Flagged for the user to confirm in a real browser.
…s minzoom boundary

Root cause: OpenFreeMap's Liberty style already ships a real fill-extrusion
"building-3d" layer (minzoom 14), but the map's initial zoom was set to
exactly 14 -- right on that boundary, where extrusions are either not
rendered yet or too small at that scale to read as 3D. Confirmed by
fetching the style JSON directly (tiles.openfreemap.org/styles/liberty)
and checking the layer's minzoom/paint properties.

Bumped initial zoom to 17 -- a close street-level view well inside the
building layer's range. Also documented in a comment that this style has
no DEM/terrain source, so hills and vegetation won't extrude in 3D; only
buildings do.

Reported by the user testing in their own browser ("just land, no
buildings") -- confirmed real (not a test-sandbox artifact) since their
symptom (land renders, buildings don't) matches the root cause exactly.
…g heights on the map

GeoMapPanel.tsx: targets are now spawned using RadarSimulator.tsx's actual
SUBTYPE_PRESETS/SUBTYPE_CLASS data (exported and imported directly, not
duplicated) with the same spawn geometry as RadarSimulator's createTarget()
-- same speed/altitude/RCS/micro-Doppler ranges and spawn-distance/heading
distribution as the main simulator, just projected onto the real-world map
instead of the abstract polar scope.

Added a "Use My Location" button using the browser Geolocation API (real
user-consented permission prompt) to set the origin from the user's actual
position, alongside the existing city search and pick-on-map options.

MapLibre3DInner.tsx: queries REAL building height data from OpenFreeMap's
rendered vector tiles (queryRenderedFeatures on the building-3d layer,
render_height property -- genuine OSM building:levels/height data, not a
synthetic estimate) on each map idle event, reporting count/max/avg height.
GeoMapPanel surfaces this as a live "N real OSM buildings in view" stat and
a per-target "clearance above tallest nearby building" figure when a target
is selected. Honest fallback when a location has no building height data
(common outside dense OSM coverage) rather than faking a number.

Also fixed a react-hooks/refs lint error while adding the building-stats
ref (moved "latest value" ref updates into an effect instead of during
render -- a pattern the file already used elsewhere, now compliant with
the newer eslint rule) and two react/no-unescaped-entities errors.

.claude/launch.json: added autoPort so a second concurrent session's dev
server doesn't collide with another one already on port 3000.

Verified via tsc + eslint (both clean); live browser verification was not
possible this pass -- port 3000 is held by a concurrent session's dev
server and the user asked to leave it running.
… heights on the map

Fixes the 3D "just land, no buildings" issue: OpenFreeMap's Liberty style
extrudes buildings via ["get","render_height"] with NO fallback, so any OSM
building missing a height/levels tag silently rendered at 0m (invisible).
Overrides the paint expression with a coalesced default height + height-based
colour ramp, and reports tagged-vs-assumed counts honestly in the UI instead
of claiming more real data than exists. Also adds a WebGL capability check
(blank canvas otherwise had no explanation) and surfaces MapLibre runtime
errors instead of failing silently.

Wires the map to genuinely reuse RadarSimulator.tsx rather than approximating
it:
- Exported SUBTYPE_PRESETS/SUBTYPE_CLASS/TargetSubtype so target spawning
  uses the exact same speed/altitude/RCS/micro-Doppler ranges as the main
  simulator, not a duplicated guess.
- Extracted computeFeatureVectors() from executeInferenceSweep as a pure
  function (same physical-signal model, same noise injection, same
  clamping) -- reused here instead of re-deriving the physics.
- Real classification now runs on map targets through the same
  OnnxModelManager (classical/quantum/ensemble modes, same SNR presets,
  same session-stats accounting as the main page) -- not a display-only
  mock.
- Real browser geolocation ("Use My Location") as a third way to set the
  origin, alongside Nominatim city search and click-to-pick.
- Real OSM building heights queried live via MapLibre's
  queryRenderedFeatures, shown as a stats bar and as each selected target's
  clearance above the tallest nearby building.

Fixed a real correctness bug found in review: the position-update tick used
.map() to rebuild target objects every 500ms, but runInference()'s
fire-and-forget predict() call writes its result onto the object it was
given -- if a prediction (especially quantum mode's network round-trip)
resolved after the next tick had already replaced the array, the result
would land on an orphaned object and silently vanish from the UI. Switched
to in-place mutation (forEach), matching RadarSimulator.tsx's own
updateTargetPhysics convention, which never has this race.

Also fixed a stats-object reference-sharing bug: the initial useState/useRef
both pointed at the same module-level EMPTY_STATS template that
updateStats() mutates in place, so the first inference tally would have
corrupted what any future reset/remount started from.

Verified: tsc clean, eslint clean on all touched files, backend suite
21/21 passing. Live-verified via DOM inspection that markers render with
correct positions/colors/subtypes (browser sandbox blocks the OSM tile
domain specifically -- confirmed reachable via curl from the shell, a
test-environment restriction, not a product issue).
…button

BenchmarkPanel.tsx: runs the same physics simulation RadarSimulator.tsx
uses (SUBTYPE_PRESETS-based synthetic targets -> computeFeatureVectors(),
both already exported from RadarSimulator.tsx) through every deployed
model variant -- 11/31/42-feature classical, quantum, and quantum+ensemble
-- across the same six named SNR scenarios (Border/Night/Airport/Urban/
Forest/Heavy Noise), calling the real backend endpoints directly
(api/app.py's /predict/classic, /predict/31, /predict/42, /predict/quantum)
rather than client-side ONNX WASM, since three different feature-schema
models need to run concurrently and a single OnnxModelManager only holds
one loaded model at a time.

Metric choice: the quantum backend (api/quantum_predict.py) is a BINARY
drone/not_drone classifier, not 4-class -- there's no "overall accuracy"
comparable to the classical variants for it. Drone recall (does it catch
the drone) is the one metric every variant can report, and matches the
project's own headline metric everywhere else (COST_FN_DRONE = 25x
COST_FP_DRONE). Charted for all 5 variants; overall 4-class accuracy is a
secondary chart, classical variants only, not padded with a meaningless
number for quantum/ensemble.

Test-set balancing: native class proportions are ~20:1 aircraft:drone
(config.py), which would put ~1 drone sample in a small default-sized
run and make drone recall statistically meaningless -- generates an
explicitly balanced set (N per class) instead, same reasoning the
project's own quantum ablation used for its drone-enriched subsampling.

Hand-rolled SVG line chart (no new charting dependency) -- two charts,
drone recall (all 5 variants) and overall accuracy (3 classical variants).

New /benchmark route + nav link added across all 6 pages. Verified: tsc
clean, eslint clean on both new files, and all 4 backend endpoint
contracts (predict/classic, /31, /42, /quantum) tested directly against
the running API with payloads shaped exactly as the benchmark sends them
-- confirmed field names (data.label vs data.prediction) match what's
parsed. Backend suite still 21/21.
…ssing

The 20-sample cap was an arbitrary UI safety limit, not a real technical
constraint -- there's no hard reason to stop there. Raised to 500 and
replaced the silent cap with a live estimate (total HTTP calls + rough
wall-clock time) next to the input, so the choice of sample count is
informed by the actual cost instead of hidden behind a number I picked
without justification. Estimate assumes ~6-way browser per-origin
concurrency and latency ballparked from the real measured quantum
endpoint latency (~40ms/call) vs. typical classical ONNX latency.
Root cause, found by direct inspection of the live map instance (not a
guess): the style JSON and the RASTER source (ne2_shaded, plain image
fetch) loaded fine, but the VECTOR source (openmaptiles -- the one that
carries every road/building/label) never finished loading. Vector tile
parsing happens in maplibre-gl's Worker; a raw Worker(url) test against
its auto-detected worker script threw "Cannot use import statement
outside a module" -- Next.js/Turbopack's dev bundler doesn't resolve
maplibre-gl's internal `new Worker(new URL(...), import.meta.url)`
pattern the way webpack does, so the worker script was never usable, and
MapLibre's internal try/catch around worker construction swallowed the
failure silently (no console error, no map 'error' event -- style and
sources just never finished loading).

Fix: point maplibre-gl at its own prebuilt worker bundle directly via
`maplibregl.setWorkerUrl()`, serving it as a static asset instead of
relying on the bundler-specific auto-detected URL. Copied both
maplibre-gl-worker.mjs AND maplibre-gl-shared.mjs (the worker's own
relative import target -- copying only the first file reproduces the
identical symptom, since MapLibre's `new Worker(url, {type:'module'})`
then fails on `./maplibre-gl-shared.mjs` 404ing) into frontend/public/.

Also adds a ResizeObserver + resize-on-load: this component mounts behind
a 2D/3D toggle via next/dynamic, so the container can still be laying out
when MapLibre measures it once at construction -- unrelated to the worker
bug above but a real second failure mode for the same "blank map" symptom
if the panel is resized or toggled while still settling.

Verified directly against the live map instance (not just visually):
confirmed style loads / vector source never loads before the fix,
confirmed both flip to loaded after: real extruded 3D buildings, real
street/POI labels, and the building-height stats bar reporting genuine
OSM data, reproduced in two independent fresh browser tabs (Chennai:
16 buildings, tallest 26m; NYC: 6 buildings, tallest 5m).

tsc clean, eslint clean, backend suite 21/21 (untouched, sanity check).
…'s position transform

Every marker-style update did el.style.cssText = "...", which replaces the
entire inline style attribute -- including the `transform` MapLibre itself
sets on the element each frame to place it at its lngLat. Targets existed in
the DOM with correct coordinates but were pinned at the untransformed origin,
so the demo looked dead in 3D even though inference/stats were running fine.
Switched to setting individual style properties instead.
@ravindraogg
ravindraogg merged commit 00afaed into main Aug 1, 2026
1 check failed
Trivikram-Narayanan pushed a commit that referenced this pull request Aug 12, 2026
Implement Threat Assessment Layer and Fleet-Scale Swarm Tracking
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants