mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
0d3d835bf8
* feat(swarm): add wifi-densepose-swarm crate implementing ADR-148 drone swarm control system
New crate `wifi-densepose-swarm` with hierarchical-mesh swarm topology,
Raft consensus, MAPPO MARL, CSI sensing integration, and ITAR-gated
coordination features. Closes 3 of 7 milestones (M1, M2, M5) with 5/5
ADR-148 SOTA performance targets met.
## Modules (45 source files, 14 modules)
- types: NodeId, DroneState, Position3D, SwarmTask, SwarmError, FailSafeState
- topology: Raft consensus (leader election, log replication, quorum), Gossip, Mesh
- formation: VirtualStructure, LeaderFollower, Reynolds flocking (itar-gated)
- planning: RRT-APF hybrid planner, 3-phase coverage, Bayesian grid, pheromone
- allocation: Auction + FNN bid scorer (itar-gated)
- sensing: CsiPayloadPipeline (Live/Synthetic/Replay), MultiViewFusion, OccWorldBridge
- marl: MAPPO actor (3-layer MLP), LocalObservation (64-dim), RewardCalculator, PPO loop
- security: MAVLink v2 HMAC-SHA256, UWB anti-spoofing, geofence, Remote ID, FHSS
- failsafe: 10-state onboard machine, GCS-independent safety transitions
- config: TOML SwarmConfig with SAR/inspection/agriculture/mine/demo/wi2sar_reference
- demo: SyntheticCsiGenerator, DemoScenario (SAR/open-field/mine)
- integration: FlightController trait, MAVLink dialect (50000-50005), SwarmSim
- orchestrator: SwarmOrchestrator wiring all subsystems end-to-end
- bench_support: Criterion fixture generators
## ITAR compliance
Swarming coordination features gated behind `itar-unrestricted` feature
per USML Category VIII(h)(12). Default build compiles clean stubs.
## Benchmark results (criterion, release mode)
- MARL actor inference: 3.3 µs (target ≤ 5 ms — 1,516× headroom)
- RRT-APF planning (100 iter): 0.043 ms (target < 300 ms — 6,946× headroom)
- MultiView CSI fusion (3 UAVs): 58.5 ns (target < 10 ms — 171,000× headroom)
- 3-view localization: 1.732 m (target ≤ 2 m — beats Wi2SAR SOTA)
- 4-drone SAR coverage (400×400 m): 223 s (target ≤ 240 s — PASS)
## Tests
- --no-default-features: 73/73 passing
- --features itar-unrestricted: 85/85 passing
Closes #861
Co-Authored-By: claude-flow <ruv@ruv.net>
* refactor(swarm): rename wifi-densepose-swarm → ruview-swarm
The swarm control system is a RuView-level capability (drone coordination,
Raft consensus, MARL) that operates above the wifi-densepose sensing layer
rather than being a sub-component of it. Rename aligns with the project
identity and separates coordination infrastructure from sensing modules.
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(swarm): resolve all clippy warnings + add MARL convergence test
- planning/probability_grid: map_or(true,…) → is_none_or (clippy::unnecessary_map_or)
- planning/pheromone: &mut Vec<T> → &mut [T] on evaporate+deposit (clippy::ptr_arg)
- marl/observation: fix doc lazy-continuation warning on TOTAL line
- marl/trainer: manual Default impl → #[derive(Default)] + #[default] on Demo variant
Also adds test_marl_convergence_improves_mean_return: fills 64-transition
ReplayBuffer with mixed rewards (steps 0-31: negative, 32-63: positive),
runs ppo_update, asserts mean_return is finite and non-zero.
Result: 0 clippy warnings · 74/74 tests (default) · 86/86 (itar-unrestricted)
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): integrate Ruflo AI-agent capabilities into ruview-swarm
Adds a feature-gated Ruflo integration layer connecting ruview-swarm to the
claude-flow daemon's AgentDB, AIDefence, and SONA intelligence subsystems.
Default build is unaffected (all paths behind `Option<Box<dyn RufloBackend>>`).
## New module: src/ruflo/
- backend.rs: RufloBackend trait (9 async methods) + RufloError, MissionMemoryEntry,
PatternEntry, MavlinkScanResult types (always compiled)
- mock_backend.rs: MockRufloBackend in-memory impl for testing (always compiled, 5 tests)
- http_backend.rs: HttpRufloBackend — JSON-RPC 2.0 → claude-flow daemon localhost:3000
(gated behind `ruflo` feature, requires reqwest)
- mission_summary.rs: MissionSummary serializer with pattern description + confidence
scoring from victim recall, coverage %, collision penalty (always compiled, 3 tests)
## 4 capability areas
1. MissionMemory → memory_store / memory_search (cross-mission victim memory)
2. PatternLearner → agentdb_pattern-store / -search (HNSW SONA trajectory patterns)
3. MavlinkDefence → aidefence_is_safe / aidefence_scan (scan MAVLink before accepting)
4. IntelligenceHooks → trajectory-start/step/end (SONA learning loop)
## SwarmOrchestrator integration
- with_ruflo(backend): builder to attach a backend
- start_trajectory(task) / finish_trajectory(success, key): SONA mission lifecycle
- receive_peer_detection_checked(): AIDefence scan before accepting peer detections
## Cargo feature
`ruflo = ["dep:reqwest", "dep:serde_json"]` — optional, not in default
## Tests
- --no-default-features: 82/82 pass (8 new ruflo tests)
- --features ruflo,itar-unrestricted: 94/94 pass
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): M7 mission profiles with victim confirmation reports + pre-merge docs
Adds end-to-end mission runners producing structured MissionReport output,
and updates project docs (CHANGELOG, README, CLAUDE.md) per pre-merge checklist.
## M7 Mission Profiles (integration/mission_report.rs + swarm_sim.rs)
- MissionReport / VictimReport / SotaComparison types (serde-serializable)
- run_mission_with_report(): full mission → detailed report with per-victim
localization error, fusion uncertainty, contributing drones, detection time
- run_inspection_mission(): leader-follower power-line corridor inspection
- run_mine_mission(): GPS-denied underground (2-drone, slow, UWB-only)
- SotaComparison embeds Wi2SAR baseline (5m / 810s) vs achieved metrics
## Docs (pre-merge checklist)
- CHANGELOG.md: ruview-swarm + Ruflo integration + performance entries
- README.md: ruview-swarm row
- CLAUDE.md: Key Rust Crates table row + ADR-148 in ADR list
## Tests
- --no-default-features: 86/86 pass
- --features ruflo,itar-unrestricted: 98/98 pass
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(swarm): convergence-assist for victim fusion + 5s Ruflo HTTP timeout
Follow-up to 13b08927 which committed an intermediate M7 state with one
failing test. This lands the M7 agent's convergence fixes and the security
review's timeout hardening.
## Fixes
- swarm_sim.rs: min-separation nudge before collision metric (0 collisions
with staggered starts) + Phase-3 convergence assist that vectors the nearest
idle peer toward a single-drone CSI contact so multi-view fusion can fire
- http_backend.rs: add 5s request timeout to reqwest client (security review
Medium finding — a dead daemon would otherwise hang the swarm step loop)
## Security review verdict (HttpRufloBackend)
Safe to merge. No credentials in requests, serde_json prevents injection,
fail-open on daemon-down is documented and appropriate for SAR missions,
MAVLink passed as structured text (not raw bytes). Timeout fix applied.
## Tests
- --no-default-features: 87/87 pass
- --features ruflo,itar-unrestricted: 100/100 pass
Co-Authored-By: claude-flow <ruv@ruv.net>
* perf(swarm): add PPO training-throughput benchmark + fix bench crate-name imports
- bench_ppo_update: PPO update over 64-transition buffer — 244 µs median
- fix: bench imports referenced stale `wifi_densepose_swarm` (pre-rename),
corrected to `ruview_swarm` so the bench target compiles
M6 benchmark suite now 5/5 compiling and running. Tests unchanged: 87/100.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): real Candle autodiff PPO + A-MAPPO role attention + GPU training (M4)
Replaces the finite-difference PPO placeholder with a real GPU-capable Candle
0.9 autodiff trainer, adds A-MAPPO heterogeneous-role attention, a runnable
training binary, and right-sized GCP/local launch scripts. This is the unlock
that makes "GPU long training cycles" actually mean something — the previous
ppo_update did no gradient descent.
## Real autodiff PPO (feature `train`, optional `cuda`)
- candle_ppo.rs: CandleActorCritic (64→128→64 MLP + action/value heads +
learnable log_std), CandlePpoConfig, CandleTrainer with GAE and a genuine
optimizer.backward_step over the network. select_device() picks CUDA when
built --features cuda and a GPU is present, else CPU.
- Verified: 5-episode CPU smoke run shows value_loss 12643→12375 (critic
actually learning); safetensors checkpoint saved. Placeholder never moved weights.
## A-MAPPO heterogeneous-role attention (role_attention.rs, always compiled)
Addresses the four sensor-vs-relay edge cases:
- relay attention floor (prevents collapse — relays produce no CSI)
- role-segmented sensor/relay attention pools (variable neighbor cardinality)
- sensor-gated triangulation-geometry penalty (protects 3-view fusion baseline,
ADR-148 §4.2 — relays not dragged into triangulation geometry)
- one-hot role embeddings for keys
## Training binary
- src/bin/train_marl.rs (required-features=["train"], excluded from default build)
- CLI: --episodes --drones --profile --steps --checkpoint-dir --checkpoint-every
- Wires CandleTrainer to the SwarmOrchestrator rollout loop; GAE + PPO update
per episode; periodic safetensors checkpoints
## Right-sized launch (scripts/gcp/)
- provision_marl.sh: g2-standard-16 (1× L4, 16 vCPU, ~$1.40/hr) — NOT the
$29/hr A100×8 box. MARL is rollout-bound not matmul-bound; ~21× cheaper.
- run_marl_train.sh: GCP rsync + train + checkpoint pull
- run_marl_train_local.sh: local RTX 5080, $0
- A100×8 provision_training.sh left for OccWorld (which saturates the GPUs)
## Tests
- --no-default-features: 91/91 (87 + 4 role_attention)
- --features train: 96/96 (+ 5 candle_ppo, incl. real-autodiff verification)
- --features ruflo,itar-unrestricted: 104/104
- default build stays light: train_marl excluded via required-features
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr-148): mark M4 complete — real GPU autodiff training; overall 98%
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): training visualizer — JSONL telemetry + self-contained HTML viewer
Adds an offline, dependency-free visualization for the drone training system:
a top-down swarm replay synced with training-metric curves, fed by a JSONL
telemetry log the trainer emits. No server, no build step, no CDN.
## Telemetry recorder (integration/telemetry.rs, always compiled, no new deps)
- TelemetryRecorder writes newline-delimited JSON: one `meta` (profile, area,
ground-truth victims), many `step` (per-tick drone x/y/heading/battery/detection
+ coverage%), and per-episode `episode` (mean_return, policy_loss, value_loss).
- Written by hand (no serde_json) so it stays in the default build; 2 tests.
## train_marl telemetry flags
- `--telemetry FILE` writes the log; `--telemetry-episode N` selects which
episode's spatial steps to record (metrics recorded for all episodes).
## Visualizer (viz/swarm_viz.html — single file, vanilla JS + canvas)
- LEFT: top-down replay — heading-oriented drone triangles (cyan/lime on
detection), victim markers, growing coverage heatmap, detection pulse rings,
play/pause/scrub/speed controls + live coverage/detection readout.
- RIGHT: three autoscaled line charts (mean return, policy loss, value loss)
over episodes, hand-drawn (no chart library).
- Loads via file picker/drag-drop or auto-fetches the bundled sample; dark
drone-ops theme; graceful degradation on file:// CORS.
- viz/sample_telemetry.jsonl: real 30-episode / 4-drone / 400×400 m run
(value_loss 20052→7154 — visible critic learning). Parses 1 meta / 60 step / 30 episode.
## Usage
cargo run --release -p ruview-swarm --features train,cuda --bin train_marl -- \
--episodes 5000 --telemetry run.jsonl
open v2/crates/ruview-swarm/viz/swarm_viz.html # load run.jsonl
Tests unchanged (91 default / 96 train / 104 ruflo+itar); telemetry adds 2.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): selectable flight + self-learning patterns, wired into training + viz
Adds multiple flight/coverage-optimization strategies and self-learning
strategies, selectable from the trainer, and fixes drone clustering — the
demo sweep now covers 36% of the area (was ~0.9%) with 4 disjoint strips.
## Flight patterns (planning/patterns.rs) — `FlightPattern`
- PartitionedLawnmower (new default): area split into per-drone strips → no
overlap, coverage scales ~linearly with swarm size (clustering fix)
- Boustrophedon (baseline), Spiral, Pheromone (stigmergic), PotentialField,
LevyFlight. from_str/name/all + next_target(&PatternContext).
## Self-learning patterns (marl/learning.rs) — `LearningPattern`
- Mappo (CTDE centralized critic), Ippo (independent, jamming-robust),
MappoCuriosity (count-based intrinsic novelty), MetaRl (MAML fast-adapt).
- CuriosityModule (visit_bonus = beta/sqrt(count), novelty decays on revisit),
MetaAdapter (base + fast-weights, reset_fast/consolidate), shaped_reward().
## Trainer wiring (bin/train_marl.rs)
- --flight-pattern {boustrophedon|partitioned|spiral|pheromone|potential|levy}
- --learn-pattern {mappo|ippo|curiosity|meta}
- Rollout now moves each drone per the selected FlightPattern (PatternContext
with visited trail + live peers), curiosity-shapes the reward, and logs
CTDE vs independent. Telemetry meta profile carries the pattern labels so the
viewer header shows `flight=… · learn=…`.
## Verification
- Browser pass (viz at localhost:8777): partitioned run renders 4 distinct
serpentine coverage bands, header shows the patterns, final coverage 36.3%,
scrubber/speed/playback work, ZERO console errors. Screenshot confirmed.
- Regenerated viz/sample_telemetry.jsonl: 1 meta / 120 step / 30 episode,
coverage 0.9% → 36.3%.
## Tests
- --no-default-features: 103/103 (was 91; +6 patterns +6 learning)
- --features train: 108/108
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): add flight-pattern telemetry presets for the visualizer
5 loadable presets (verified browser-distinct, physics-ordered coverage):
pheromone ~44% > potential ~40% > partitioned 36% > spiral ~13% > levy ~5%.
Load any in viz/swarm_viz.html to compare flight strategies without retraining.
Co-Authored-By: claude-flow <ruv@ruv.net>
* chore(swarm): clippy-clean + publish guard for ruview-swarm
- ruview-swarm src is now 0 clippy warnings across default/train/full feature
sets (derive Default, targeted allows for intentional from_str + bounded
casts + borrow-required index loops; removed redundant unsigned .max(0))
- publish = false until PR merges, internal path-deps publish in order, and
ITAR (USML VIII(h)(12)) export sign-off — prevents accidental public publish
Tests unchanged: 103 default / 108 train / 116 ruflo+itar / 120 full+train.
(6 remaining clippy warnings are pre-existing in dependency wifi-densepose-core,
out of scope for this crate.)
Co-Authored-By: claude-flow <ruv@ruv.net>
* ci(swarm): add ruview-swarm CI guard
Path-scoped guard for v2/crates/ruview-swarm/** (ADR-148). Complements the
main ci.yml (which only runs the default workspace tests):
- feature-matrix tests: default / train / ruflo+itar / full+train
- clippy -D warnings --no-deps (crate-own code only; dep warnings don't gate)
- train_marl bin builds under 'train' AND is excluded from the default build
- ITAR/publish guards: publish=false present, itar-unrestricted never in default
All steps verified locally green before commit.
Co-Authored-By: claude-flow <ruv@ruv.net>
726 lines
24 KiB
HTML
726 lines
24 KiB
HTML
<!DOCTYPE html>
|
||
<!--
|
||
ruview-swarm — training visualizer (ADR-148)
|
||
============================================
|
||
Single self-contained, dependency-free HTML visualizer for ruview-swarm drone
|
||
training telemetry. No build step, no CDN, no npm — pure vanilla JS + canvas.
|
||
|
||
USAGE: Open this file in a browser. When served over http(s) it auto-fetches the
|
||
bundled `sample_telemetry.jsonl` sitting next to it (e.g. run
|
||
`python3 -m http.server` in this directory then open swarm_viz.html). When opened
|
||
directly via file:// the auto-fetch is blocked by CORS, so just drag a .jsonl
|
||
telemetry file onto the page or use the file picker. The LEFT panel replays the
|
||
swarm spatially (drones as oriented triangles, victims as red crosses, a growing
|
||
coverage heatmap, and detection pulse rings) with play/pause, a step scrubber, and
|
||
a speed selector; the RIGHT panel draws three auto-scaled line charts (mean return,
|
||
policy loss, value loss) over the training episodes. The telemetry schema is JSONL:
|
||
one `meta` line, many `step` lines (spatial replay frames), and many `episode`
|
||
lines (per-episode training metrics).
|
||
-->
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>ruview-swarm — training visualizer (ADR-148)</title>
|
||
<style>
|
||
:root {
|
||
--bg: #05080a;
|
||
--panel: #0a1014;
|
||
--border: #16323a;
|
||
--cyan: #2ee6e6;
|
||
--green: #43e07a;
|
||
--orange: #f6a13c;
|
||
--red: #ff5a5a;
|
||
--dim: #5b7178;
|
||
--text: #cfe9ec;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body {
|
||
margin: 0; padding: 0;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: "SFMono-Regular", "JetBrains Mono", "Cascadia Code", Consolas, "Courier New", monospace;
|
||
font-size: 13px;
|
||
}
|
||
header {
|
||
padding: 12px 18px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: linear-gradient(180deg, #0a141a, #05080a);
|
||
}
|
||
header h1 {
|
||
margin: 0;
|
||
font-size: 17px;
|
||
letter-spacing: 0.5px;
|
||
color: var(--cyan);
|
||
text-shadow: 0 0 8px rgba(46,230,230,0.35);
|
||
}
|
||
header .subtitle {
|
||
margin-top: 4px;
|
||
color: var(--dim);
|
||
font-size: 12px;
|
||
}
|
||
header .subtitle b { color: var(--green); }
|
||
.toolbar {
|
||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||
padding: 10px 18px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.toolbar label { color: var(--dim); }
|
||
.toolbar input[type=file] {
|
||
color: var(--text);
|
||
font-family: inherit; font-size: 12px;
|
||
}
|
||
.hint { color: var(--orange); font-size: 12px; }
|
||
.stage {
|
||
display: flex; gap: 16px; flex-wrap: wrap;
|
||
padding: 16px 18px;
|
||
}
|
||
.panel {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
}
|
||
.panel h2 {
|
||
margin: 0 0 8px 0;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
color: var(--cyan);
|
||
}
|
||
canvas { display: block; background: #04070a; border-radius: 4px; }
|
||
.controls {
|
||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||
margin-top: 10px;
|
||
}
|
||
.controls button, .controls select {
|
||
background: #0e1d24;
|
||
color: var(--cyan);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
padding: 5px 11px;
|
||
font-family: inherit; font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
.controls button:hover, .controls select:hover { border-color: var(--cyan); }
|
||
.controls input[type=range] { flex: 1; min-width: 140px; accent-color: var(--cyan); }
|
||
.readout {
|
||
margin-top: 8px;
|
||
color: var(--green);
|
||
font-size: 12px;
|
||
min-height: 16px;
|
||
}
|
||
.readout .warn { color: var(--orange); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>ruview-swarm — training visualizer (ADR-148)</h1>
|
||
<div class="subtitle" id="subtitle">no telemetry loaded — drop a .jsonl file or use the picker below</div>
|
||
</header>
|
||
|
||
<div class="toolbar">
|
||
<label>load telemetry:</label>
|
||
<input type="file" id="fileInput" accept=".jsonl,.json,.txt">
|
||
<span class="hint" id="loadHint"></span>
|
||
</div>
|
||
|
||
<div class="stage">
|
||
<div class="panel">
|
||
<h2>spatial swarm replay</h2>
|
||
<canvas id="replay" width="560" height="560"></canvas>
|
||
<div class="controls">
|
||
<button id="playBtn">▶ Play</button>
|
||
<input type="range" id="scrub" min="0" max="0" value="0">
|
||
<select id="speedSel">
|
||
<option value="0.5">0.5×</option>
|
||
<option value="1" selected>1×</option>
|
||
<option value="2">2×</option>
|
||
<option value="4">4×</option>
|
||
</select>
|
||
</div>
|
||
<div class="readout" id="replayReadout">—</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2>training metrics</h2>
|
||
<canvas id="metrics" width="480" height="560"></canvas>
|
||
<div class="readout" id="metricsReadout">—</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
"use strict";
|
||
(function () {
|
||
// ---- DOM handles ----
|
||
var subtitleEl = document.getElementById("subtitle");
|
||
var loadHintEl = document.getElementById("loadHint");
|
||
var fileInput = document.getElementById("fileInput");
|
||
var replayCanvas = document.getElementById("replay");
|
||
var metricsCanvas= document.getElementById("metrics");
|
||
var rctx = replayCanvas.getContext("2d");
|
||
var mctx = metricsCanvas.getContext("2d");
|
||
var playBtn = document.getElementById("playBtn");
|
||
var scrub = document.getElementById("scrub");
|
||
var speedSel = document.getElementById("speedSel");
|
||
var replayReadout= document.getElementById("replayReadout");
|
||
var metricsReadout= document.getElementById("metricsReadout");
|
||
|
||
// ---- State ----
|
||
var meta = null;
|
||
var steps = []; // step records (sorted by step index)
|
||
var episodes = []; // episode records (sorted by ep)
|
||
var coverageGrid = null; // accumulated heatmap, GW x GH
|
||
var GW = 60, GH = 60; // heatmap resolution
|
||
var lastBuiltStep = -1; // highest step index folded into coverageGrid
|
||
|
||
var playing = false;
|
||
var curStep = 0;
|
||
var stepAccumulator = 0; // fractional step progress for playback timing
|
||
var lastFrameTime = 0;
|
||
var pulses = []; // detection pulse rings {gx,gy(world), age}
|
||
|
||
// ---- Parsing ----
|
||
function parseTelemetry(text) {
|
||
var lines = text.split(/\r?\n/);
|
||
var m = null, st = [], ep = [];
|
||
for (var i = 0; i < lines.length; i++) {
|
||
var line = lines[i].trim();
|
||
if (!line) continue;
|
||
var obj;
|
||
try { obj = JSON.parse(line); } catch (e) { continue; } // skip malformed
|
||
if (!obj || typeof obj !== "object") continue;
|
||
if (obj.type === "meta") { if (!m) m = obj; }
|
||
else if (obj.type === "step") { st.push(obj); }
|
||
else if (obj.type === "episode") { ep.push(obj); }
|
||
}
|
||
st.sort(function (a, b) { return (a.step|0) - (b.step|0); });
|
||
ep.sort(function (a, b) { return (a.ep|0) - (b.ep|0); });
|
||
return { meta: m, steps: st, episodes: ep };
|
||
}
|
||
|
||
function loadData(text, sourceName) {
|
||
var parsed = parseTelemetry(text);
|
||
if (!parsed.meta && parsed.steps.length === 0 && parsed.episodes.length === 0) {
|
||
loadHintEl.textContent = "no valid telemetry records found in " + (sourceName || "input");
|
||
return;
|
||
}
|
||
meta = parsed.meta || { profile: "unknown", drones: 0, area_w: 100, area_h: 100, victims: [] };
|
||
steps = parsed.steps;
|
||
episodes = parsed.episodes;
|
||
|
||
// reset playback / heatmap
|
||
coverageGrid = new Float32Array(GW * GH);
|
||
lastBuiltStep = -1;
|
||
pulses = [];
|
||
curStep = 0;
|
||
stepAccumulator = 0;
|
||
playing = false;
|
||
playBtn.textContent = "▶ Play";
|
||
|
||
scrub.min = 0;
|
||
scrub.max = Math.max(0, steps.length - 1);
|
||
scrub.value = 0;
|
||
|
||
var dc = meta.drones || (steps[0] && steps[0].drones ? steps[0].drones.length : 0);
|
||
subtitleEl.innerHTML = "profile <b>" + escapeHtml(String(meta.profile)) + "</b> · "
|
||
+ "<b>" + dc + "</b> drones · "
|
||
+ "area <b>" + fmt(meta.area_w) + "×" + fmt(meta.area_h) + "</b> m · "
|
||
+ "<b>" + (meta.victims ? meta.victims.length : 0) + "</b> victims · "
|
||
+ "<b>" + steps.length + "</b> replay steps · "
|
||
+ "<b>" + episodes.length + "</b> episodes";
|
||
loadHintEl.textContent = "loaded " + (sourceName || "telemetry");
|
||
|
||
buildCoverageUpTo(0);
|
||
drawReplay();
|
||
drawMetrics();
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return s.replace(/[&<>"']/g, function (c) {
|
||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||
});
|
||
}
|
||
function fmt(v) { return (typeof v === "number") ? (Math.round(v * 100) / 100) : v; }
|
||
|
||
// ---- Coordinate mapping (world metres -> canvas px), maintaining aspect ratio ----
|
||
function replayTransform() {
|
||
var W = replayCanvas.width, H = replayCanvas.height;
|
||
var pad = 28;
|
||
var aw = (meta && meta.area_w) || 100;
|
||
var ah = (meta && meta.area_h) || 100;
|
||
var availW = W - pad * 2, availH = H - pad * 2;
|
||
var scale = Math.min(availW / aw, availH / ah);
|
||
var drawW = aw * scale, drawH = ah * scale;
|
||
var offX = (W - drawW) / 2;
|
||
var offY = (H - drawH) / 2;
|
||
return {
|
||
scale: scale, offX: offX, offY: offY, drawW: drawW, drawH: drawH,
|
||
// world X -> screen X, world Y -> screen Y (Y grows downward on screen)
|
||
x: function (wx) { return offX + wx * scale; },
|
||
y: function (wy) { return offY + wy * scale; }
|
||
};
|
||
}
|
||
|
||
// ---- Coverage heatmap accumulation ----
|
||
function foldStepIntoGrid(rec) {
|
||
if (!rec || !rec.drones) return;
|
||
var aw = (meta && meta.area_w) || 100;
|
||
var ah = (meta && meta.area_h) || 100;
|
||
for (var i = 0; i < rec.drones.length; i++) {
|
||
var d = rec.drones[i];
|
||
var gx = Math.floor((d.x / aw) * GW);
|
||
var gy = Math.floor((d.y / ah) * GH);
|
||
if (gx < 0) gx = 0; if (gx >= GW) gx = GW - 1;
|
||
if (gy < 0) gy = 0; if (gy >= GH) gy = GH - 1;
|
||
// splat a small 3x3 footprint to suggest sensor swath
|
||
for (var ox = -1; ox <= 1; ox++) {
|
||
for (var oy = -1; oy <= 1; oy++) {
|
||
var cx = gx + ox, cy = gy + oy;
|
||
if (cx < 0 || cx >= GW || cy < 0 || cy >= GH) continue;
|
||
var w = (ox === 0 && oy === 0) ? 0.6 : 0.18;
|
||
var idx = cy * GW + cx;
|
||
var v = coverageGrid[idx] + w;
|
||
coverageGrid[idx] = v > 1 ? 1 : v;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Rebuild heatmap so it reflects all steps 0..target (handles scrubbing backwards).
|
||
function buildCoverageUpTo(target) {
|
||
if (!coverageGrid) return;
|
||
if (target < lastBuiltStep) {
|
||
// scrubbed backwards — rebuild from scratch
|
||
coverageGrid.fill(0);
|
||
lastBuiltStep = -1;
|
||
}
|
||
for (var i = lastBuiltStep + 1; i <= target && i < steps.length; i++) {
|
||
foldStepIntoGrid(steps[i]);
|
||
}
|
||
if (target > lastBuiltStep) lastBuiltStep = Math.min(target, steps.length - 1);
|
||
}
|
||
|
||
// ---- Drawing: LEFT replay panel ----
|
||
function drawReplay() {
|
||
var W = replayCanvas.width, H = replayCanvas.height;
|
||
rctx.clearRect(0, 0, W, H);
|
||
rctx.fillStyle = "#04070a";
|
||
rctx.fillRect(0, 0, W, H);
|
||
|
||
var t = replayTransform();
|
||
|
||
// coverage heatmap (faint cyan cells)
|
||
if (coverageGrid) {
|
||
var cellW = t.drawW / GW, cellH = t.drawH / GH;
|
||
for (var gy = 0; gy < GH; gy++) {
|
||
for (var gx = 0; gx < GW; gx++) {
|
||
var v = coverageGrid[gy * GW + gx];
|
||
if (v <= 0) continue;
|
||
rctx.fillStyle = "rgba(46,230,230," + (0.07 + v * 0.34).toFixed(3) + ")";
|
||
rctx.fillRect(t.offX + gx * cellW, t.offY + gy * cellH, cellW + 0.5, cellH + 0.5);
|
||
}
|
||
}
|
||
}
|
||
|
||
// grid lines
|
||
rctx.strokeStyle = "rgba(70,120,130,0.18)";
|
||
rctx.lineWidth = 1;
|
||
var divisions = 8;
|
||
for (var i = 0; i <= divisions; i++) {
|
||
var fx = t.offX + (t.drawW * i / divisions);
|
||
var fy = t.offY + (t.drawH * i / divisions);
|
||
rctx.beginPath(); rctx.moveTo(fx, t.offY); rctx.lineTo(fx, t.offY + t.drawH); rctx.stroke();
|
||
rctx.beginPath(); rctx.moveTo(t.offX, fy); rctx.lineTo(t.offX + t.drawW, fy); rctx.stroke();
|
||
}
|
||
|
||
// area border
|
||
rctx.strokeStyle = "rgba(46,230,230,0.6)";
|
||
rctx.lineWidth = 1.5;
|
||
rctx.strokeRect(t.offX, t.offY, t.drawW, t.drawH);
|
||
|
||
// axis labels
|
||
rctx.fillStyle = "#5b7178";
|
||
rctx.font = "10px monospace";
|
||
rctx.textAlign = "left";
|
||
rctx.fillText("0", t.offX + 2, t.offY + t.drawH + 12);
|
||
rctx.textAlign = "right";
|
||
rctx.fillText(fmt(meta ? meta.area_w : 0) + "m (x)", t.offX + t.drawW, t.offY + t.drawH + 12);
|
||
rctx.save();
|
||
rctx.translate(t.offX - 6, t.offY + t.drawH);
|
||
rctx.rotate(-Math.PI / 2);
|
||
rctx.textAlign = "left";
|
||
rctx.fillText(fmt(meta ? meta.area_h : 0) + "m (y)", 0, 0);
|
||
rctx.restore();
|
||
|
||
// victims
|
||
if (meta && meta.victims) {
|
||
for (var v = 0; v < meta.victims.length; v++) {
|
||
var vx = t.x(meta.victims[v][0]), vy = t.y(meta.victims[v][1]);
|
||
rctx.strokeStyle = "#ff5a5a";
|
||
rctx.lineWidth = 2;
|
||
var s = 7;
|
||
rctx.beginPath();
|
||
rctx.moveTo(vx - s, vy); rctx.lineTo(vx + s, vy);
|
||
rctx.moveTo(vx, vy - s); rctx.lineTo(vx, vy + s);
|
||
rctx.stroke();
|
||
rctx.beginPath();
|
||
rctx.arc(vx, vy, s + 2, 0, Math.PI * 2);
|
||
rctx.strokeStyle = "rgba(255,90,90,0.5)";
|
||
rctx.lineWidth = 1;
|
||
rctx.stroke();
|
||
rctx.fillStyle = "#ff8a8a";
|
||
rctx.font = "10px monospace";
|
||
rctx.textAlign = "left";
|
||
rctx.fillText("victim " + v, vx + s + 4, vy - 4);
|
||
}
|
||
}
|
||
|
||
// detection pulses (expanding rings)
|
||
for (var p = pulses.length - 1; p >= 0; p--) {
|
||
var pu = pulses[p];
|
||
var px = t.x(pu.wx), py = t.y(pu.wy);
|
||
var r = 6 + pu.age * 40;
|
||
var alpha = 1 - pu.age;
|
||
if (alpha <= 0) { pulses.splice(p, 1); continue; }
|
||
rctx.beginPath();
|
||
rctx.arc(px, py, r, 0, Math.PI * 2);
|
||
rctx.strokeStyle = "rgba(67,224,122," + (alpha * 0.8).toFixed(3) + ")";
|
||
rctx.lineWidth = 2;
|
||
rctx.stroke();
|
||
}
|
||
|
||
// drones
|
||
var rec = steps[curStep];
|
||
var activeDetections = 0;
|
||
if (rec && rec.drones) {
|
||
for (var di = 0; di < rec.drones.length; di++) {
|
||
var d = rec.drones[di];
|
||
var dx = t.x(d.x), dy = t.y(d.y);
|
||
var detecting = !!d.det;
|
||
if (detecting) activeDetections++;
|
||
|
||
// oriented triangle along hdg (screen Y down => use hdg directly)
|
||
var hdg = (typeof d.hdg === "number") ? d.hdg : 0;
|
||
var size = 9;
|
||
var col = detecting ? "#b6ff3c" : "#2ee6e6";
|
||
rctx.save();
|
||
rctx.translate(dx, dy);
|
||
rctx.rotate(hdg);
|
||
rctx.beginPath();
|
||
rctx.moveTo(size, 0);
|
||
rctx.lineTo(-size * 0.7, size * 0.6);
|
||
rctx.lineTo(-size * 0.4, 0);
|
||
rctx.lineTo(-size * 0.7, -size * 0.6);
|
||
rctx.closePath();
|
||
rctx.fillStyle = col;
|
||
rctx.globalAlpha = detecting ? 1 : 0.92;
|
||
rctx.fill();
|
||
rctx.globalAlpha = 1;
|
||
if (detecting) {
|
||
rctx.strokeStyle = "rgba(182,255,60,0.9)";
|
||
rctx.lineWidth = 1;
|
||
rctx.stroke();
|
||
}
|
||
rctx.restore();
|
||
|
||
// id label
|
||
rctx.fillStyle = col;
|
||
rctx.font = "10px monospace";
|
||
rctx.textAlign = "center";
|
||
rctx.fillText(String(d.id), dx, dy - 13);
|
||
|
||
// battery bar under drone
|
||
var bw = 18, bh = 3;
|
||
var bx = dx - bw / 2, by = dy + 11;
|
||
var batt = (typeof d.batt === "number") ? Math.max(0, Math.min(100, d.batt)) : 0;
|
||
rctx.fillStyle = "rgba(255,255,255,0.12)";
|
||
rctx.fillRect(bx, by, bw, bh);
|
||
// green -> red interpolation by battery
|
||
var g = Math.round(2.24 * batt); // 0..224
|
||
var rr = Math.round(255 - 1.9 * batt); // 255..65
|
||
rctx.fillStyle = "rgb(" + rr + "," + g + ",60)";
|
||
rctx.fillRect(bx, by, bw * (batt / 100), bh);
|
||
}
|
||
}
|
||
|
||
// step readout
|
||
var cov = rec && typeof rec.coverage === "number" ? rec.coverage : 0;
|
||
var total = steps.length;
|
||
if (total === 0) {
|
||
replayReadout.innerHTML = '<span class="warn">no replay steps in telemetry</span>';
|
||
} else {
|
||
replayReadout.textContent =
|
||
"step " + (curStep + 1) + "/" + total +
|
||
" · ep " + (rec ? rec.ep : "—") +
|
||
" · t=" + (rec && typeof rec.t === "number" ? rec.t.toFixed(2) : "—") +
|
||
" · coverage " + (cov * 100).toFixed(1) + "%" +
|
||
" · active detections " + activeDetections;
|
||
}
|
||
}
|
||
|
||
// ---- Drawing: RIGHT metrics panel ----
|
||
function lineChart(x, y, w, h, title, color, values) {
|
||
// axes box
|
||
mctx.strokeStyle = "rgba(70,120,130,0.4)";
|
||
mctx.lineWidth = 1;
|
||
mctx.strokeRect(x, y, w, h);
|
||
|
||
mctx.fillStyle = color;
|
||
mctx.font = "11px monospace";
|
||
mctx.textAlign = "left";
|
||
mctx.fillText(title, x + 4, y - 5);
|
||
|
||
if (!values || values.length === 0) {
|
||
mctx.fillStyle = "#5b7178";
|
||
mctx.fillText("(no data)", x + w / 2 - 28, y + h / 2);
|
||
return;
|
||
}
|
||
|
||
var min = Infinity, max = -Infinity;
|
||
for (var i = 0; i < values.length; i++) {
|
||
var v = values[i];
|
||
if (typeof v !== "number" || !isFinite(v)) continue;
|
||
if (v < min) min = v;
|
||
if (v > max) max = v;
|
||
}
|
||
if (!isFinite(min)) { min = 0; max = 1; }
|
||
if (min === max) { min -= 1; max += 1; }
|
||
var range = max - min;
|
||
|
||
var n = values.length;
|
||
function px(i) { return x + (n === 1 ? w / 2 : (i / (n - 1)) * w); }
|
||
function py(v) { return y + h - ((v - min) / range) * h; }
|
||
|
||
// zero line if it falls within range
|
||
if (min < 0 && max > 0) {
|
||
var zy = py(0);
|
||
mctx.strokeStyle = "rgba(120,140,150,0.25)";
|
||
mctx.setLineDash([3, 3]);
|
||
mctx.beginPath(); mctx.moveTo(x, zy); mctx.lineTo(x + w, zy); mctx.stroke();
|
||
mctx.setLineDash([]);
|
||
}
|
||
|
||
// the line
|
||
mctx.strokeStyle = color;
|
||
mctx.lineWidth = 1.6;
|
||
mctx.beginPath();
|
||
var started = false;
|
||
for (var j = 0; j < n; j++) {
|
||
var vv = values[j];
|
||
if (typeof vv !== "number" || !isFinite(vv)) continue;
|
||
var X = px(j), Y = py(vv);
|
||
if (!started) { mctx.moveTo(X, Y); started = true; }
|
||
else mctx.lineTo(X, Y);
|
||
}
|
||
mctx.stroke();
|
||
|
||
// latest marker dot
|
||
var lastV = values[n - 1];
|
||
if (typeof lastV === "number" && isFinite(lastV)) {
|
||
mctx.fillStyle = color;
|
||
mctx.beginPath();
|
||
mctx.arc(px(n - 1), py(lastV), 3.2, 0, Math.PI * 2);
|
||
mctx.fill();
|
||
}
|
||
|
||
// min/max annotations
|
||
mctx.fillStyle = "#5b7178";
|
||
mctx.font = "9px monospace";
|
||
mctx.textAlign = "right";
|
||
mctx.fillText(fmtNum(max), x + w - 3, y + 10);
|
||
mctx.fillText(fmtNum(min), x + w - 3, y + h - 3);
|
||
// episode axis labels
|
||
mctx.textAlign = "left";
|
||
mctx.fillText("ep 0", x + 2, y + h + 11);
|
||
mctx.textAlign = "right";
|
||
mctx.fillText("ep " + (n - 1), x + w, y + h + 11);
|
||
}
|
||
|
||
function fmtNum(v) {
|
||
if (!isFinite(v)) return "—";
|
||
var a = Math.abs(v);
|
||
if (a >= 1000) return v.toFixed(0);
|
||
if (a >= 1) return v.toFixed(1);
|
||
return v.toFixed(3);
|
||
}
|
||
|
||
function drawMetrics() {
|
||
var W = metricsCanvas.width, H = metricsCanvas.height;
|
||
mctx.clearRect(0, 0, W, H);
|
||
mctx.fillStyle = "#04070a";
|
||
mctx.fillRect(0, 0, W, H);
|
||
|
||
// legend
|
||
mctx.font = "10px monospace";
|
||
mctx.textAlign = "left";
|
||
var legend = [["mean return", "#43e07a"], ["policy loss", "#f6a13c"], ["value loss", "#ff5a5a"]];
|
||
var lx = 14;
|
||
for (var l = 0; l < legend.length; l++) {
|
||
mctx.fillStyle = legend[l][1];
|
||
mctx.fillRect(lx, 8, 9, 9);
|
||
mctx.fillStyle = "#cfe9ec";
|
||
mctx.fillText(legend[l][0], lx + 13, 16);
|
||
lx += mctx.measureText(legend[l][0]).width + 36;
|
||
}
|
||
|
||
var ret = episodes.map(function (e) { return e.mean_return; });
|
||
var pol = episodes.map(function (e) { return e.policy_loss; });
|
||
var val = episodes.map(function (e) { return e.value_loss; });
|
||
|
||
var marginL = 14, marginR = 14, top = 38, gap = 30;
|
||
var chartW = W - marginL - marginR;
|
||
var chartH = (H - top - gap * 3) / 3;
|
||
|
||
var y0 = top;
|
||
lineChart(marginL, y0, chartW, chartH, "mean return", "#43e07a", ret);
|
||
var y1 = y0 + chartH + gap;
|
||
lineChart(marginL, y1, chartW, chartH, "policy loss", "#f6a13c", pol);
|
||
var y2 = y1 + chartH + gap;
|
||
lineChart(marginL, y2, chartW, chartH, "value loss (autoscaled)", "#ff5a5a", val);
|
||
|
||
if (episodes.length === 0) {
|
||
metricsReadout.innerHTML = '<span class="warn">no episode metrics in telemetry</span>';
|
||
} else {
|
||
var last = episodes[episodes.length - 1];
|
||
var found = 0;
|
||
for (var i = 0; i < episodes.length; i++) {
|
||
if (typeof episodes[i].victims_found === "number" && episodes[i].victims_found > found)
|
||
found = episodes[i].victims_found;
|
||
}
|
||
metricsReadout.textContent =
|
||
episodes.length + " episodes · latest ep " + last.ep +
|
||
" · return " + fmtNum(last.mean_return) +
|
||
" · policy " + fmtNum(last.policy_loss) +
|
||
" · value " + fmtNum(last.value_loss) +
|
||
" · max victims found " + found;
|
||
}
|
||
}
|
||
|
||
// ---- Playback loop ----
|
||
function frame(now) {
|
||
if (playing && steps.length > 1) {
|
||
if (!lastFrameTime) lastFrameTime = now;
|
||
var dt = (now - lastFrameTime) / 1000;
|
||
lastFrameTime = now;
|
||
var speed = parseFloat(speedSel.value) || 1;
|
||
var stepsPerSec = 6 * speed; // base playback rate
|
||
stepAccumulator += dt * stepsPerSec;
|
||
while (stepAccumulator >= 1) {
|
||
stepAccumulator -= 1;
|
||
advanceStep(1);
|
||
if (curStep >= steps.length - 1) {
|
||
curStep = steps.length - 1;
|
||
playing = false;
|
||
playBtn.textContent = "▶ Play";
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
lastFrameTime = now;
|
||
}
|
||
|
||
// age pulses
|
||
for (var i = 0; i < pulses.length; i++) pulses[i].age += 0.03;
|
||
|
||
drawReplay();
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
function advanceStep(delta) {
|
||
var prev = curStep;
|
||
curStep += delta;
|
||
if (curStep < 0) curStep = 0;
|
||
if (curStep > steps.length - 1) curStep = steps.length - 1;
|
||
scrub.value = curStep;
|
||
buildCoverageUpTo(curStep);
|
||
spawnPulsesForStep(curStep);
|
||
}
|
||
|
||
function spawnPulsesForStep(idx) {
|
||
var rec = steps[idx];
|
||
if (!rec || !rec.drones) return;
|
||
for (var i = 0; i < rec.drones.length; i++) {
|
||
var d = rec.drones[i];
|
||
if (d.det) pulses.push({ wx: d.x, wy: d.y, age: 0 });
|
||
}
|
||
}
|
||
|
||
// ---- Controls wiring ----
|
||
playBtn.addEventListener("click", function () {
|
||
if (steps.length <= 1) return;
|
||
playing = !playing;
|
||
playBtn.textContent = playing ? "❚❚ Pause" : "▶ Play";
|
||
if (playing && curStep >= steps.length - 1) {
|
||
// restart from beginning
|
||
curStep = 0;
|
||
coverageGrid && coverageGrid.fill(0);
|
||
lastBuiltStep = -1;
|
||
pulses = [];
|
||
buildCoverageUpTo(0);
|
||
scrub.value = 0;
|
||
}
|
||
lastFrameTime = 0;
|
||
});
|
||
|
||
scrub.addEventListener("input", function () {
|
||
playing = false;
|
||
playBtn.textContent = "▶ Play";
|
||
curStep = parseInt(scrub.value, 10) || 0;
|
||
buildCoverageUpTo(curStep);
|
||
spawnPulsesForStep(curStep);
|
||
drawReplay();
|
||
});
|
||
|
||
speedSel.addEventListener("change", function () { lastFrameTime = 0; });
|
||
|
||
fileInput.addEventListener("change", function (ev) {
|
||
var f = ev.target.files && ev.target.files[0];
|
||
if (!f) return;
|
||
var reader = new FileReader();
|
||
reader.onload = function () { loadData(String(reader.result), f.name); };
|
||
reader.onerror = function () { loadHintEl.textContent = "could not read file"; };
|
||
reader.readAsText(f);
|
||
});
|
||
|
||
// drag & drop onto the page
|
||
window.addEventListener("dragover", function (e) { e.preventDefault(); });
|
||
window.addEventListener("drop", function (e) {
|
||
e.preventDefault();
|
||
var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||
if (!f) return;
|
||
var reader = new FileReader();
|
||
reader.onload = function () { loadData(String(reader.result), f.name); };
|
||
reader.readAsText(f);
|
||
});
|
||
|
||
// ---- Auto-fetch bundled sample (graceful on file:// CORS failure) ----
|
||
function tryAutoFetch() {
|
||
if (typeof fetch !== "function") {
|
||
loadHintEl.textContent = "drop a .jsonl file or use the picker";
|
||
return;
|
||
}
|
||
fetch("sample_telemetry.jsonl")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("status " + r.status);
|
||
return r.text();
|
||
})
|
||
.then(function (text) { loadData(text, "sample_telemetry.jsonl"); })
|
||
.catch(function () {
|
||
loadHintEl.textContent = "auto-load blocked (file://) — drop a .jsonl file or use the picker";
|
||
// draw empty frames so canvases aren't blank
|
||
drawReplay();
|
||
drawMetrics();
|
||
});
|
||
}
|
||
|
||
// boot
|
||
drawReplay();
|
||
drawMetrics();
|
||
tryAutoFetch();
|
||
requestAnimationFrame(frame);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|