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>
This commit is contained in:
parent
d60410326f
commit
5450bfdc60
|
|
@ -12,6 +12,7 @@
|
|||
//! 8× A100 box for this workload at ~1/20th the cost. See scripts/gcp/.
|
||||
|
||||
use ruview_swarm::config::SwarmConfig;
|
||||
use ruview_swarm::integration::telemetry::{DroneFrame, TelemetryRecorder};
|
||||
use ruview_swarm::marl::candle_ppo::{CandlePpoConfig, CandleTrainer};
|
||||
use ruview_swarm::marl::observation::LocalObservation;
|
||||
use ruview_swarm::marl::reward::{RewardCalculator, RewardContext};
|
||||
|
|
@ -25,6 +26,8 @@ struct Args {
|
|||
steps_per_episode: usize,
|
||||
checkpoint_dir: String,
|
||||
checkpoint_every: usize,
|
||||
telemetry: Option<String>,
|
||||
telemetry_episode: usize,
|
||||
}
|
||||
|
||||
impl Default for Args {
|
||||
|
|
@ -36,6 +39,8 @@ impl Default for Args {
|
|||
steps_per_episode: 200,
|
||||
checkpoint_dir: "./marl-checkpoints".to_string(),
|
||||
checkpoint_every: 100,
|
||||
telemetry: None,
|
||||
telemetry_episode: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +76,14 @@ fn parse_args() -> Args {
|
|||
args.checkpoint_every = next().parse().unwrap_or(args.checkpoint_every);
|
||||
i += 1;
|
||||
}
|
||||
"--telemetry" => {
|
||||
args.telemetry = Some(next());
|
||||
i += 1;
|
||||
}
|
||||
"--telemetry-episode" => {
|
||||
args.telemetry_episode = next().parse().unwrap_or(args.telemetry_episode);
|
||||
i += 1;
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!(
|
||||
"train_marl — ruview-swarm MARL training (ADR-148 M4)\n\
|
||||
|
|
@ -80,7 +93,9 @@ fn parse_args() -> Args {
|
|||
--profile NAME sar|inspection|mine|agriculture (default sar)\n \
|
||||
--steps N steps per episode (default 200)\n \
|
||||
--checkpoint-dir D checkpoint output dir (default ./marl-checkpoints)\n \
|
||||
--checkpoint-every N save every N episodes (default 100)"
|
||||
--checkpoint-every N save every N episodes (default 100)\n \
|
||||
--telemetry FILE write JSONL telemetry for viz/swarm_viz.html\n \
|
||||
--telemetry-episode N which episode's steps to record spatially (default 0)"
|
||||
);
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
|
@ -123,6 +138,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
Position3D { x: cfg.mission.area_width_m * 0.6, y: cfg.mission.area_height_m * 0.45, z: 0.0 },
|
||||
];
|
||||
|
||||
// Optional telemetry recorder for the visualizer.
|
||||
let mut telem = match &args.telemetry {
|
||||
Some(path) => {
|
||||
let mut rec = TelemetryRecorder::create(path)?;
|
||||
rec.meta(
|
||||
&args.profile,
|
||||
args.drones,
|
||||
cfg.mission.area_width_m,
|
||||
cfg.mission.area_height_m,
|
||||
&victims,
|
||||
)?;
|
||||
println!("telemetry → {path} (spatial steps from episode {})", args.telemetry_episode);
|
||||
Some(rec)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut best_return = f32::MIN;
|
||||
|
||||
for episode in 0..args.episodes {
|
||||
|
|
@ -204,6 +236,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
value_buf.push(0.0); // bootstrap value (critic learns this)
|
||||
done_buf.push(is_last);
|
||||
}
|
||||
|
||||
// Record spatial telemetry for the selected episode only (keeps the
|
||||
// log small — one episode of step frames + metrics for every episode).
|
||||
if let Some(rec) = telem.as_mut() {
|
||||
if episode == args.telemetry_episode {
|
||||
let scan_w = cfg.planning.csi_scan_width_m;
|
||||
let frames: Vec<DroneFrame> = drones
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let detected = victims
|
||||
.iter()
|
||||
.any(|v| d.state.position.distance_to(v) < scan_w);
|
||||
DroneFrame::from_state(&d.state, detected)
|
||||
})
|
||||
.collect();
|
||||
let coverage = drones
|
||||
.iter()
|
||||
.map(|d| d.probability_grid.coverage_pct())
|
||||
.sum::<f64>()
|
||||
/ drones.len().max(1) as f64;
|
||||
let _ = rec.step(episode, step, step as f64, &frames, coverage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PPO update on the episode's rollout.
|
||||
|
|
@ -223,6 +278,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
best_return = mean_return;
|
||||
}
|
||||
|
||||
// Per-episode training-metric telemetry (every episode).
|
||||
if let Some(rec) = telem.as_mut() {
|
||||
let _ = rec.episode(episode, mean_return, policy_loss, value_loss, 0);
|
||||
}
|
||||
|
||||
if episode % 10 == 0 || episode == args.episodes - 1 {
|
||||
println!(
|
||||
"ep {:>5}/{} mean_return={:>8.3} best={:>8.3} policy_loss={:>8.4} value_loss={:>8.4}",
|
||||
|
|
@ -244,6 +304,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(rec) = telem.as_mut() {
|
||||
rec.flush()?;
|
||||
if let Some(path) = &args.telemetry {
|
||||
println!("telemetry written: {path} — open viz/swarm_viz.html and load it");
|
||||
}
|
||||
}
|
||||
|
||||
println!("training complete. best mean_return={best_return:.3}");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
pub mod mavlink_messages;
|
||||
pub mod mission_report;
|
||||
pub mod swarm_sim;
|
||||
pub mod telemetry;
|
||||
|
||||
pub use mission_report::{MissionReport, SotaComparison, VictimReport};
|
||||
pub use telemetry::{DroneFrame, TelemetryRecorder};
|
||||
|
||||
pub use mavlink_messages::{
|
||||
SwarmNodeState, SwarmCsiReport, SwarmClusterHeartbeat, SwarmVictimConfirmed, SwarmMsgId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
//! JSONL telemetry recorder for the swarm training/sim visualizer.
|
||||
//!
|
||||
//! Emits newline-delimited JSON records consumed by `viz/swarm_viz.html`:
|
||||
//! - one `meta` record (mission profile, area, ground-truth victims)
|
||||
//! - many `step` records (per-tick drone positions, coverage, detections)
|
||||
//! - optional `episode` records (per-episode training metrics)
|
||||
//!
|
||||
//! Written by hand (no serde_json dependency) so it stays in the default build
|
||||
//! and never affects the test/CI surface. The schema is flat and the only
|
||||
//! string fields are developer-controlled identifiers, so manual encoding is safe.
|
||||
|
||||
use crate::types::{DroneState, Position3D};
|
||||
use std::fs::File;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// Records swarm telemetry to a JSONL file for offline visualization.
|
||||
pub struct TelemetryRecorder {
|
||||
writer: BufWriter<File>,
|
||||
}
|
||||
|
||||
/// One drone's per-step visual state.
|
||||
pub struct DroneFrame {
|
||||
pub id: u32,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub heading_rad: f64,
|
||||
pub battery_pct: f32,
|
||||
pub detected: bool,
|
||||
}
|
||||
|
||||
impl DroneFrame {
|
||||
pub fn from_state(state: &DroneState, detected: bool) -> Self {
|
||||
Self {
|
||||
id: state.id.0,
|
||||
x: state.position.x,
|
||||
y: state.position.y,
|
||||
heading_rad: state.heading_rad,
|
||||
battery_pct: state.battery_pct,
|
||||
detected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryRecorder {
|
||||
/// Open a telemetry file for writing.
|
||||
pub fn create<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
|
||||
let file = File::create(path)?;
|
||||
Ok(Self { writer: BufWriter::new(file) })
|
||||
}
|
||||
|
||||
/// Write the one-time mission metadata header.
|
||||
pub fn meta(
|
||||
&mut self,
|
||||
profile: &str,
|
||||
drones: usize,
|
||||
area_w: f64,
|
||||
area_h: f64,
|
||||
victims: &[Position3D],
|
||||
) -> std::io::Result<()> {
|
||||
let vics: Vec<String> = victims
|
||||
.iter()
|
||||
.map(|v| format!("[{:.2},{:.2}]", v.x, v.y))
|
||||
.collect();
|
||||
writeln!(
|
||||
self.writer,
|
||||
r#"{{"type":"meta","profile":"{}","drones":{},"area_w":{:.2},"area_h":{:.2},"victims":[{}]}}"#,
|
||||
sanitize(profile),
|
||||
drones,
|
||||
area_w,
|
||||
area_h,
|
||||
vics.join(",")
|
||||
)
|
||||
}
|
||||
|
||||
/// Write one simulation step (all drones at this tick).
|
||||
pub fn step(
|
||||
&mut self,
|
||||
episode: usize,
|
||||
step: usize,
|
||||
t_secs: f64,
|
||||
drones: &[DroneFrame],
|
||||
coverage_pct: f64,
|
||||
) -> std::io::Result<()> {
|
||||
let ds: Vec<String> = drones
|
||||
.iter()
|
||||
.map(|d| {
|
||||
format!(
|
||||
r#"{{"id":{},"x":{:.2},"y":{:.2},"hdg":{:.3},"batt":{:.1},"det":{}}}"#,
|
||||
d.id, d.x, d.y, d.heading_rad, d.battery_pct, d.detected
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
writeln!(
|
||||
self.writer,
|
||||
r#"{{"type":"step","ep":{},"step":{},"t":{:.2},"coverage":{:.4},"drones":[{}]}}"#,
|
||||
episode,
|
||||
step,
|
||||
t_secs,
|
||||
coverage_pct,
|
||||
ds.join(",")
|
||||
)
|
||||
}
|
||||
|
||||
/// Write one episode's training metrics.
|
||||
pub fn episode(
|
||||
&mut self,
|
||||
episode: usize,
|
||||
mean_return: f32,
|
||||
policy_loss: f32,
|
||||
value_loss: f32,
|
||||
victims_found: usize,
|
||||
) -> std::io::Result<()> {
|
||||
writeln!(
|
||||
self.writer,
|
||||
r#"{{"type":"episode","ep":{},"mean_return":{:.4},"policy_loss":{:.4},"value_loss":{:.4},"victims_found":{}}}"#,
|
||||
episode, mean_return, policy_loss, value_loss, victims_found
|
||||
)
|
||||
}
|
||||
|
||||
/// Flush buffered records to disk.
|
||||
pub fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip characters that would break the flat JSON string field.
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars().filter(|c| *c != '"' && *c != '\\' && *c != '\n').collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{NodeId, Velocity3D};
|
||||
|
||||
fn tmp_path(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_records_valid_jsonl() {
|
||||
let path = tmp_path("ruview_telemetry_test.jsonl");
|
||||
{
|
||||
let mut rec = TelemetryRecorder::create(&path).unwrap();
|
||||
rec.meta("sar", 2, 400.0, 400.0, &[Position3D { x: 80.0, y: 120.0, z: 0.0 }])
|
||||
.unwrap();
|
||||
let state = DroneState {
|
||||
id: NodeId(0),
|
||||
position: Position3D { x: 10.5, y: 20.25, z: -30.0 },
|
||||
velocity: Velocity3D::default(),
|
||||
heading_rad: 1.57,
|
||||
altitude_agl_m: 30.0,
|
||||
battery_pct: 88.0,
|
||||
link_quality: 0.9,
|
||||
timestamp_ms: 0,
|
||||
};
|
||||
rec.step(0, 0, 0.0, &[DroneFrame::from_state(&state, true)], 0.05)
|
||||
.unwrap();
|
||||
rec.episode(0, 103.7, -61.2, 12643.3, 1).unwrap();
|
||||
rec.flush().unwrap();
|
||||
}
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "meta + step + episode = 3 records");
|
||||
assert!(lines[0].contains(r#""type":"meta""#));
|
||||
assert!(lines[1].contains(r#""type":"step""#));
|
||||
assert!(lines[1].contains(r#""det":true"#));
|
||||
assert!(lines[2].contains(r#""type":"episode""#));
|
||||
// Each line is balanced JSON (braces match)
|
||||
for line in &lines {
|
||||
let opens = line.matches('{').count();
|
||||
let closes = line.matches('}').count();
|
||||
assert_eq!(opens, closes, "balanced braces in: {line}");
|
||||
}
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_strips_quotes() {
|
||||
assert_eq!(sanitize("sa\"r\n"), "sar");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
{"type":"meta","profile":"sar","drones":4,"area_w":400.00,"area_h":400.00,"victims":[[80.00,120.00],[240.00,180.00]]}
|
||||
{"type":"episode","ep":0,"mean_return":123.6877,"policy_loss":4.3082,"value_loss":20052.5762,"victims_found":0}
|
||||
{"type":"episode","ep":1,"mean_return":123.6877,"policy_loss":-35.5269,"value_loss":19947.0527,"victims_found":0}
|
||||
{"type":"episode","ep":2,"mean_return":123.6877,"policy_loss":-74.4405,"value_loss":19841.1973,"victims_found":0}
|
||||
{"type":"episode","ep":3,"mean_return":123.6877,"policy_loss":-116.1118,"value_loss":19727.8633,"victims_found":0}
|
||||
{"type":"episode","ep":4,"mean_return":123.6877,"policy_loss":-162.6557,"value_loss":19607.3828,"victims_found":0}
|
||||
{"type":"episode","ep":5,"mean_return":123.6877,"policy_loss":-215.9290,"value_loss":19478.0371,"victims_found":0}
|
||||
{"type":"episode","ep":6,"mean_return":123.6877,"policy_loss":-279.5587,"value_loss":19331.7852,"victims_found":0}
|
||||
{"type":"episode","ep":7,"mean_return":123.6877,"policy_loss":-357.7819,"value_loss":19170.1621,"victims_found":0}
|
||||
{"type":"episode","ep":8,"mean_return":123.6877,"policy_loss":-452.8956,"value_loss":18987.3477,"victims_found":0}
|
||||
{"type":"episode","ep":9,"mean_return":123.6877,"policy_loss":-565.1311,"value_loss":18776.0879,"victims_found":0}
|
||||
{"type":"episode","ep":10,"mean_return":123.6877,"policy_loss":-693.7755,"value_loss":18532.1562,"victims_found":0}
|
||||
{"type":"episode","ep":11,"mean_return":123.6877,"policy_loss":-840.3882,"value_loss":18255.1113,"victims_found":0}
|
||||
{"type":"episode","ep":12,"mean_return":123.6877,"policy_loss":-1007.8695,"value_loss":17941.9434,"victims_found":0}
|
||||
{"type":"episode","ep":13,"mean_return":123.6877,"policy_loss":-1198.8174,"value_loss":17589.8066,"victims_found":0}
|
||||
{"type":"episode","ep":14,"mean_return":123.6877,"policy_loss":-1415.8530,"value_loss":17194.4316,"victims_found":0}
|
||||
{"type":"episode","ep":15,"mean_return":123.6877,"policy_loss":-1662.0295,"value_loss":16753.6250,"victims_found":0}
|
||||
{"type":"episode","ep":16,"mean_return":123.6877,"policy_loss":-1940.0228,"value_loss":16267.0869,"victims_found":0}
|
||||
{"type":"episode","ep":17,"mean_return":123.6877,"policy_loss":-2252.8604,"value_loss":15734.1992,"victims_found":0}
|
||||
{"type":"episode","ep":18,"mean_return":123.6877,"policy_loss":-2603.2175,"value_loss":15156.0713,"victims_found":0}
|
||||
{"type":"episode","ep":19,"mean_return":123.6877,"policy_loss":-2993.5083,"value_loss":14534.6807,"victims_found":0}
|
||||
{"type":"episode","ep":20,"mean_return":123.6877,"policy_loss":-3427.2791,"value_loss":13871.4297,"victims_found":0}
|
||||
{"type":"episode","ep":21,"mean_return":123.6877,"policy_loss":-3908.3892,"value_loss":13167.9365,"victims_found":0}
|
||||
{"type":"episode","ep":22,"mean_return":123.6877,"policy_loss":-4439.9995,"value_loss":12428.7100,"victims_found":0}
|
||||
{"type":"episode","ep":23,"mean_return":123.6877,"policy_loss":-5023.8579,"value_loss":11662.3135,"victims_found":0}
|
||||
{"type":"episode","ep":24,"mean_return":123.6877,"policy_loss":-5660.2412,"value_loss":10880.2861,"victims_found":0}
|
||||
{"type":"episode","ep":25,"mean_return":123.6877,"policy_loss":-6351.8081,"value_loss":10091.8340,"victims_found":0}
|
||||
{"type":"episode","ep":26,"mean_return":123.6877,"policy_loss":-7099.0146,"value_loss":9310.4336,"victims_found":0}
|
||||
{"type":"episode","ep":27,"mean_return":123.6877,"policy_loss":-7901.1260,"value_loss":8551.1855,"victims_found":0}
|
||||
{"type":"episode","ep":28,"mean_return":123.6877,"policy_loss":-8758.6396,"value_loss":7828.0469,"victims_found":0}
|
||||
{"type":"step","ep":29,"step":0,"t":0.00,"coverage":0.0002,"drones":[{"id":0,"x":4.34,"y":4.34,"hdg":-2.356,"batt":100.0,"det":false},{"id":1,"x":202.01,"y":9.71,"hdg":-3.105,"batt":100.0,"det":false},{"id":2,"x":9.71,"y":202.01,"hdg":-1.607,"batt":100.0,"det":false},{"id":3,"x":204.34,"y":204.34,"hdg":-2.356,"batt":100.0,"det":false}]}
|
||||
{"type":"step","ep":29,"step":1,"t":1.00,"coverage":0.0003,"drones":[{"id":0,"x":7.50,"y":2.50,"hdg":-0.528,"batt":99.9,"det":false},{"id":1,"x":194.01,"y":9.42,"hdg":-3.105,"batt":99.9,"det":false},{"id":2,"x":9.42,"y":194.01,"hdg":-1.607,"batt":99.9,"det":false},{"id":3,"x":198.69,"y":198.69,"hdg":-2.356,"batt":99.9,"det":false}]}
|
||||
{"type":"step","ep":29,"step":2,"t":2.00,"coverage":0.0005,"drones":[{"id":0,"x":12.50,"y":2.50,"hdg":0.000,"batt":99.9,"det":false},{"id":1,"x":186.02,"y":9.13,"hdg":-3.105,"batt":99.9,"det":false},{"id":2,"x":9.13,"y":186.02,"hdg":-1.607,"batt":99.9,"det":false},{"id":3,"x":193.03,"y":193.03,"hdg":-2.356,"batt":99.9,"det":false}]}
|
||||
{"type":"step","ep":29,"step":3,"t":3.00,"coverage":0.0006,"drones":[{"id":0,"x":17.50,"y":2.50,"hdg":0.000,"batt":99.9,"det":false},{"id":1,"x":178.02,"y":8.84,"hdg":-3.105,"batt":99.9,"det":false},{"id":2,"x":8.84,"y":178.02,"hdg":-1.607,"batt":99.9,"det":false},{"id":3,"x":187.37,"y":187.37,"hdg":-2.356,"batt":99.9,"det":false}]}
|
||||
{"type":"step","ep":29,"step":4,"t":4.00,"coverage":0.0008,"drones":[{"id":0,"x":22.50,"y":2.50,"hdg":0.000,"batt":99.8,"det":false},{"id":1,"x":170.03,"y":8.56,"hdg":-3.105,"batt":99.8,"det":false},{"id":2,"x":8.56,"y":170.03,"hdg":-1.607,"batt":99.8,"det":false},{"id":3,"x":181.72,"y":181.72,"hdg":-2.356,"batt":99.8,"det":false}]}
|
||||
{"type":"step","ep":29,"step":5,"t":5.00,"coverage":0.0009,"drones":[{"id":0,"x":27.50,"y":2.50,"hdg":0.000,"batt":99.8,"det":false},{"id":1,"x":162.03,"y":8.27,"hdg":-3.105,"batt":99.8,"det":false},{"id":2,"x":8.27,"y":162.03,"hdg":-1.607,"batt":99.8,"det":false},{"id":3,"x":176.06,"y":176.06,"hdg":-2.356,"batt":99.8,"det":false}]}
|
||||
{"type":"step","ep":29,"step":6,"t":6.00,"coverage":0.0011,"drones":[{"id":0,"x":32.50,"y":2.50,"hdg":0.000,"batt":99.8,"det":false},{"id":1,"x":154.04,"y":7.98,"hdg":-3.105,"batt":99.8,"det":false},{"id":2,"x":7.98,"y":154.04,"hdg":-1.607,"batt":99.8,"det":false},{"id":3,"x":170.40,"y":170.40,"hdg":-2.356,"batt":99.8,"det":false}]}
|
||||
{"type":"step","ep":29,"step":7,"t":7.00,"coverage":0.0013,"drones":[{"id":0,"x":37.50,"y":2.50,"hdg":0.000,"batt":99.7,"det":false},{"id":1,"x":146.04,"y":7.69,"hdg":-3.105,"batt":99.7,"det":false},{"id":2,"x":7.69,"y":146.04,"hdg":-1.607,"batt":99.7,"det":false},{"id":3,"x":164.75,"y":164.75,"hdg":-2.356,"batt":99.7,"det":false}]}
|
||||
{"type":"step","ep":29,"step":8,"t":8.00,"coverage":0.0014,"drones":[{"id":0,"x":42.50,"y":2.50,"hdg":0.000,"batt":99.7,"det":false},{"id":1,"x":138.05,"y":7.40,"hdg":-3.105,"batt":99.7,"det":false},{"id":2,"x":7.40,"y":138.05,"hdg":-1.607,"batt":99.7,"det":false},{"id":3,"x":159.09,"y":159.09,"hdg":-2.356,"batt":99.7,"det":false}]}
|
||||
{"type":"step","ep":29,"step":9,"t":9.00,"coverage":0.0016,"drones":[{"id":0,"x":47.50,"y":2.50,"hdg":0.000,"batt":99.7,"det":false},{"id":1,"x":130.05,"y":7.11,"hdg":-3.105,"batt":99.7,"det":false},{"id":2,"x":7.11,"y":130.05,"hdg":-1.607,"batt":99.7,"det":false},{"id":3,"x":153.43,"y":153.43,"hdg":-2.356,"batt":99.7,"det":false}]}
|
||||
{"type":"step","ep":29,"step":10,"t":10.00,"coverage":0.0017,"drones":[{"id":0,"x":52.50,"y":2.50,"hdg":0.000,"batt":99.6,"det":false},{"id":1,"x":122.06,"y":6.82,"hdg":-3.105,"batt":99.6,"det":false},{"id":2,"x":6.82,"y":122.06,"hdg":-1.607,"batt":99.6,"det":false},{"id":3,"x":147.77,"y":147.77,"hdg":-2.356,"batt":99.6,"det":false}]}
|
||||
{"type":"step","ep":29,"step":11,"t":11.00,"coverage":0.0019,"drones":[{"id":0,"x":57.50,"y":2.50,"hdg":0.000,"batt":99.6,"det":false},{"id":1,"x":114.06,"y":6.53,"hdg":-3.105,"batt":99.6,"det":false},{"id":2,"x":6.53,"y":114.06,"hdg":-1.607,"batt":99.6,"det":false},{"id":3,"x":142.12,"y":142.12,"hdg":-2.356,"batt":99.6,"det":false}]}
|
||||
{"type":"step","ep":29,"step":12,"t":12.00,"coverage":0.0020,"drones":[{"id":0,"x":62.50,"y":2.50,"hdg":0.000,"batt":99.6,"det":false},{"id":1,"x":106.07,"y":6.24,"hdg":-3.105,"batt":99.6,"det":false},{"id":2,"x":6.24,"y":106.07,"hdg":-1.607,"batt":99.6,"det":false},{"id":3,"x":136.46,"y":136.46,"hdg":-2.356,"batt":99.6,"det":false}]}
|
||||
{"type":"step","ep":29,"step":13,"t":13.00,"coverage":0.0022,"drones":[{"id":0,"x":67.50,"y":2.50,"hdg":0.000,"batt":99.5,"det":false},{"id":1,"x":98.07,"y":5.95,"hdg":-3.105,"batt":99.5,"det":false},{"id":2,"x":5.95,"y":98.07,"hdg":-1.607,"batt":99.5,"det":false},{"id":3,"x":130.80,"y":130.80,"hdg":-2.356,"batt":99.5,"det":false}]}
|
||||
{"type":"step","ep":29,"step":14,"t":14.00,"coverage":0.0023,"drones":[{"id":0,"x":72.50,"y":2.50,"hdg":0.000,"batt":99.5,"det":false},{"id":1,"x":90.08,"y":5.67,"hdg":-3.105,"batt":99.5,"det":false},{"id":2,"x":5.67,"y":90.08,"hdg":-1.607,"batt":99.5,"det":false},{"id":3,"x":125.15,"y":125.15,"hdg":-2.356,"batt":99.5,"det":false}]}
|
||||
{"type":"step","ep":29,"step":15,"t":15.00,"coverage":0.0025,"drones":[{"id":0,"x":77.50,"y":2.50,"hdg":0.000,"batt":99.5,"det":false},{"id":1,"x":82.08,"y":5.38,"hdg":-3.105,"batt":99.5,"det":false},{"id":2,"x":5.38,"y":82.08,"hdg":-1.607,"batt":99.5,"det":false},{"id":3,"x":119.49,"y":119.49,"hdg":-2.356,"batt":99.5,"det":false}]}
|
||||
{"type":"step","ep":29,"step":16,"t":16.00,"coverage":0.0027,"drones":[{"id":0,"x":82.50,"y":2.50,"hdg":0.000,"batt":99.4,"det":false},{"id":1,"x":74.09,"y":5.09,"hdg":-3.105,"batt":99.4,"det":false},{"id":2,"x":5.09,"y":74.09,"hdg":-1.607,"batt":99.4,"det":false},{"id":3,"x":113.83,"y":113.83,"hdg":-2.356,"batt":99.4,"det":false}]}
|
||||
{"type":"step","ep":29,"step":17,"t":17.00,"coverage":0.0028,"drones":[{"id":0,"x":87.50,"y":2.50,"hdg":0.000,"batt":99.4,"det":false},{"id":1,"x":66.09,"y":4.80,"hdg":-3.105,"batt":99.4,"det":false},{"id":2,"x":4.80,"y":66.09,"hdg":-1.607,"batt":99.4,"det":false},{"id":3,"x":108.18,"y":108.18,"hdg":-2.356,"batt":99.4,"det":false}]}
|
||||
{"type":"step","ep":29,"step":18,"t":18.00,"coverage":0.0030,"drones":[{"id":0,"x":92.50,"y":2.50,"hdg":0.000,"batt":99.4,"det":false},{"id":1,"x":58.10,"y":4.51,"hdg":-3.105,"batt":99.4,"det":false},{"id":2,"x":4.51,"y":58.10,"hdg":-1.607,"batt":99.4,"det":false},{"id":3,"x":102.52,"y":102.52,"hdg":-2.356,"batt":99.4,"det":false}]}
|
||||
{"type":"step","ep":29,"step":19,"t":19.00,"coverage":0.0031,"drones":[{"id":0,"x":97.50,"y":2.50,"hdg":0.000,"batt":99.3,"det":false},{"id":1,"x":50.10,"y":4.22,"hdg":-3.105,"batt":99.3,"det":false},{"id":2,"x":4.22,"y":50.10,"hdg":-1.607,"batt":99.3,"det":false},{"id":3,"x":96.86,"y":96.86,"hdg":-2.356,"batt":99.3,"det":false}]}
|
||||
{"type":"step","ep":29,"step":20,"t":20.00,"coverage":0.0033,"drones":[{"id":0,"x":102.50,"y":2.50,"hdg":0.000,"batt":99.3,"det":false},{"id":1,"x":42.11,"y":3.93,"hdg":-3.105,"batt":99.3,"det":false},{"id":2,"x":3.93,"y":42.11,"hdg":-1.607,"batt":99.3,"det":false},{"id":3,"x":91.21,"y":91.21,"hdg":-2.356,"batt":99.3,"det":false}]}
|
||||
{"type":"step","ep":29,"step":21,"t":21.00,"coverage":0.0034,"drones":[{"id":0,"x":107.50,"y":2.50,"hdg":0.000,"batt":99.3,"det":false},{"id":1,"x":34.11,"y":3.64,"hdg":-3.105,"batt":99.3,"det":false},{"id":2,"x":3.64,"y":34.11,"hdg":-1.607,"batt":99.3,"det":false},{"id":3,"x":85.55,"y":85.55,"hdg":-2.356,"batt":99.3,"det":false}]}
|
||||
{"type":"step","ep":29,"step":22,"t":22.00,"coverage":0.0036,"drones":[{"id":0,"x":112.50,"y":2.50,"hdg":0.000,"batt":99.2,"det":false},{"id":1,"x":26.12,"y":3.35,"hdg":-3.105,"batt":99.2,"det":false},{"id":2,"x":3.35,"y":26.12,"hdg":-1.607,"batt":99.2,"det":false},{"id":3,"x":79.89,"y":79.89,"hdg":-2.356,"batt":99.2,"det":false}]}
|
||||
{"type":"step","ep":29,"step":23,"t":23.00,"coverage":0.0037,"drones":[{"id":0,"x":117.50,"y":2.50,"hdg":0.000,"batt":99.2,"det":false},{"id":1,"x":18.13,"y":3.06,"hdg":-3.105,"batt":99.2,"det":false},{"id":2,"x":3.06,"y":18.13,"hdg":-1.607,"batt":99.2,"det":false},{"id":3,"x":74.24,"y":74.24,"hdg":-2.356,"batt":99.2,"det":false}]}
|
||||
{"type":"step","ep":29,"step":24,"t":24.00,"coverage":0.0039,"drones":[{"id":0,"x":122.50,"y":2.50,"hdg":0.000,"batt":99.2,"det":false},{"id":1,"x":10.13,"y":2.78,"hdg":-3.105,"batt":99.2,"det":false},{"id":2,"x":2.78,"y":10.13,"hdg":-1.607,"batt":99.2,"det":false},{"id":3,"x":68.58,"y":68.58,"hdg":-2.356,"batt":99.2,"det":false}]}
|
||||
{"type":"step","ep":29,"step":25,"t":25.00,"coverage":0.0041,"drones":[{"id":0,"x":127.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":1,"x":2.50,"y":2.50,"hdg":-3.105,"batt":99.1,"det":false},{"id":2,"x":2.50,"y":2.50,"hdg":-1.607,"batt":99.1,"det":false},{"id":3,"x":62.92,"y":62.92,"hdg":-2.356,"batt":99.1,"det":false}]}
|
||||
{"type":"step","ep":29,"step":26,"t":26.00,"coverage":0.0042,"drones":[{"id":0,"x":132.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":1,"x":7.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":2,"x":7.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":3,"x":57.26,"y":57.26,"hdg":-2.356,"batt":99.1,"det":false}]}
|
||||
{"type":"step","ep":29,"step":27,"t":27.00,"coverage":0.0044,"drones":[{"id":0,"x":137.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":1,"x":15.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":2,"x":12.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":3,"x":51.61,"y":51.61,"hdg":-2.356,"batt":99.1,"det":false}]}
|
||||
{"type":"step","ep":29,"step":28,"t":28.00,"coverage":0.0045,"drones":[{"id":0,"x":142.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":1,"x":22.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":2,"x":17.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":3,"x":45.95,"y":45.95,"hdg":-2.356,"batt":99.0,"det":false}]}
|
||||
{"type":"step","ep":29,"step":29,"t":29.00,"coverage":0.0046,"drones":[{"id":0,"x":147.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":1,"x":30.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":2,"x":22.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":3,"x":40.29,"y":40.29,"hdg":-2.356,"batt":99.0,"det":false}]}
|
||||
{"type":"step","ep":29,"step":30,"t":30.00,"coverage":0.0048,"drones":[{"id":0,"x":152.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":1,"x":37.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":2,"x":27.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":3,"x":34.64,"y":34.64,"hdg":-2.356,"batt":99.0,"det":false}]}
|
||||
{"type":"step","ep":29,"step":31,"t":31.00,"coverage":0.0049,"drones":[{"id":0,"x":157.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":1,"x":45.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":2,"x":32.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":3,"x":28.98,"y":28.98,"hdg":-2.356,"batt":98.9,"det":false}]}
|
||||
{"type":"step","ep":29,"step":32,"t":32.00,"coverage":0.0051,"drones":[{"id":0,"x":162.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":1,"x":53.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":2,"x":37.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":3,"x":23.32,"y":23.32,"hdg":-2.356,"batt":98.9,"det":false}]}
|
||||
{"type":"step","ep":29,"step":33,"t":33.00,"coverage":0.0052,"drones":[{"id":0,"x":167.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":1,"x":61.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":2,"x":42.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":3,"x":17.67,"y":17.67,"hdg":-2.356,"batt":98.9,"det":false}]}
|
||||
{"type":"step","ep":29,"step":34,"t":34.00,"coverage":0.0054,"drones":[{"id":0,"x":172.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":1,"x":69.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":2,"x":47.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":3,"x":12.01,"y":12.01,"hdg":-2.356,"batt":98.8,"det":false}]}
|
||||
{"type":"step","ep":29,"step":35,"t":35.00,"coverage":0.0055,"drones":[{"id":0,"x":177.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":1,"x":72.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":2,"x":52.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":3,"x":6.35,"y":6.35,"hdg":-2.356,"batt":98.8,"det":false}]}
|
||||
{"type":"step","ep":29,"step":36,"t":36.00,"coverage":0.0056,"drones":[{"id":0,"x":182.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":1,"x":77.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":2,"x":57.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":3,"x":2.50,"y":2.50,"hdg":-2.356,"batt":98.8,"det":false}]}
|
||||
{"type":"step","ep":29,"step":37,"t":37.00,"coverage":0.0058,"drones":[{"id":0,"x":187.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":1,"x":82.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":2,"x":62.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":3,"x":7.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false}]}
|
||||
{"type":"step","ep":29,"step":38,"t":38.00,"coverage":0.0059,"drones":[{"id":0,"x":192.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":1,"x":87.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":2,"x":67.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":3,"x":12.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false}]}
|
||||
{"type":"step","ep":29,"step":39,"t":39.00,"coverage":0.0061,"drones":[{"id":0,"x":197.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":1,"x":92.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":2,"x":72.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":3,"x":17.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false}]}
|
||||
{"type":"step","ep":29,"step":40,"t":40.00,"coverage":0.0062,"drones":[{"id":0,"x":202.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":1,"x":97.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":2,"x":77.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":3,"x":22.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false}]}
|
||||
{"type":"step","ep":29,"step":41,"t":41.00,"coverage":0.0064,"drones":[{"id":0,"x":207.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":1,"x":102.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":2,"x":82.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":3,"x":27.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false}]}
|
||||
{"type":"step","ep":29,"step":42,"t":42.00,"coverage":0.0066,"drones":[{"id":0,"x":212.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":1,"x":107.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":2,"x":87.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":3,"x":32.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false}]}
|
||||
{"type":"step","ep":29,"step":43,"t":43.00,"coverage":0.0067,"drones":[{"id":0,"x":217.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":1,"x":112.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":2,"x":92.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":3,"x":37.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false}]}
|
||||
{"type":"step","ep":29,"step":44,"t":44.00,"coverage":0.0069,"drones":[{"id":0,"x":222.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":1,"x":117.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":2,"x":97.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":3,"x":42.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false}]}
|
||||
{"type":"step","ep":29,"step":45,"t":45.00,"coverage":0.0070,"drones":[{"id":0,"x":227.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":1,"x":122.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":2,"x":102.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":3,"x":47.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false}]}
|
||||
{"type":"step","ep":29,"step":46,"t":46.00,"coverage":0.0072,"drones":[{"id":0,"x":232.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":1,"x":127.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":2,"x":107.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":3,"x":52.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false}]}
|
||||
{"type":"step","ep":29,"step":47,"t":47.00,"coverage":0.0073,"drones":[{"id":0,"x":237.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":1,"x":132.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":2,"x":112.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":3,"x":57.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false}]}
|
||||
{"type":"step","ep":29,"step":48,"t":48.00,"coverage":0.0075,"drones":[{"id":0,"x":242.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":1,"x":137.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":2,"x":117.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":3,"x":62.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false}]}
|
||||
{"type":"step","ep":29,"step":49,"t":49.00,"coverage":0.0077,"drones":[{"id":0,"x":247.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":1,"x":142.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":2,"x":122.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":3,"x":67.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false}]}
|
||||
{"type":"step","ep":29,"step":50,"t":50.00,"coverage":0.0078,"drones":[{"id":0,"x":252.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":1,"x":147.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":2,"x":127.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":3,"x":72.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false}]}
|
||||
{"type":"step","ep":29,"step":51,"t":51.00,"coverage":0.0080,"drones":[{"id":0,"x":257.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":1,"x":152.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":2,"x":132.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":3,"x":77.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false}]}
|
||||
{"type":"step","ep":29,"step":52,"t":52.00,"coverage":0.0081,"drones":[{"id":0,"x":262.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":1,"x":157.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":2,"x":137.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":3,"x":82.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false}]}
|
||||
{"type":"step","ep":29,"step":53,"t":53.00,"coverage":0.0083,"drones":[{"id":0,"x":267.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":1,"x":162.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":2,"x":142.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":3,"x":87.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false}]}
|
||||
{"type":"step","ep":29,"step":54,"t":54.00,"coverage":0.0084,"drones":[{"id":0,"x":272.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":1,"x":167.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":2,"x":147.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":3,"x":92.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false}]}
|
||||
{"type":"step","ep":29,"step":55,"t":55.00,"coverage":0.0086,"drones":[{"id":0,"x":277.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":1,"x":172.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":2,"x":152.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":3,"x":97.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false}]}
|
||||
{"type":"step","ep":29,"step":56,"t":56.00,"coverage":0.0087,"drones":[{"id":0,"x":282.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":1,"x":177.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":2,"x":157.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":3,"x":102.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false}]}
|
||||
{"type":"step","ep":29,"step":57,"t":57.00,"coverage":0.0089,"drones":[{"id":0,"x":287.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":1,"x":182.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":2,"x":162.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":3,"x":107.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false}]}
|
||||
{"type":"step","ep":29,"step":58,"t":58.00,"coverage":0.0091,"drones":[{"id":0,"x":292.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":1,"x":187.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":2,"x":167.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":3,"x":112.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false}]}
|
||||
{"type":"step","ep":29,"step":59,"t":59.00,"coverage":0.0092,"drones":[{"id":0,"x":297.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":1,"x":192.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":2,"x":172.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":3,"x":117.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false}]}
|
||||
{"type":"episode","ep":29,"mean_return":123.6877,"policy_loss":-9671.3232,"value_loss":7154.7334,"victims_found":0}
|
||||
|
|
@ -0,0 +1,725 @@
|
|||
<!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>
|
||||
Loading…
Reference in New Issue