Repository navigation
Implement Threat Assessment Layer and Fleet-Scale Swarm Tracking - #3
Merged
Merged
Conversation
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.
…n Matrix to Fleet page
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).
…et-and-new-features
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.
…d pages, and unified API
Trivikram-Narayanan
pushed a commit
that referenced
this pull request
Aug 12, 2026
Implement Threat Assessment Layer and Fleet-Scale Swarm Tracking
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.