spaxel/mothership/cmd/sim/verify.go
jedarden b7f2b67241 feat: implement CSI simulator Go CLI for hardware-free testing
Implemented mothership/cmd/sim with full CSI simulation capabilities:
- Virtual nodes connect via WebSocket with hello/health/role protocol
- Synthetic CSI binary frame generation with proper header format
- Walker simulation with random walk motion and wall bouncing
- BLE advertisement simulation (optional)
- Blob count verification mode for CI integration
- CSV ground truth export for offline analysis
- Comprehensive test coverage

The simulator enables integration testing without ESP32 hardware:
  sim --mothership localhost:8080 --nodes 4 --walkers 2 --rate 20 --duration 60
  sim --verify --nodes 2 --walkers 1 --duration 10 --seed 42

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 16:50:40 -04:00

129 lines
2.8 KiB
Go

package main
import (
"encoding/csv"
"fmt"
"math"
"os"
"time"
)
// CSVWriter writes ground truth data to a CSV file
type CSVWriter struct {
file *os.File
writer *csv.Writer
created time.Time
}
// NewCSVWriter creates a new CSV writer
func NewCSVWriter(filename string) (*CSVWriter, error) {
file, err := os.Create(filename)
if err != nil {
return nil, err
}
writer := csv.NewWriter(file)
// Write header
header := []string{
"timestamp_ms",
"walker_id",
"x", "y", "z",
"vx", "vy", "vz",
}
if err := writer.Write(header); err != nil {
file.Close()
return nil, err
}
return &CSVWriter{
file: file,
writer: writer,
created: time.Now(),
}, nil
}
// WriteRow writes a row of ground truth data
func (w *CSVWriter) WriteRow(walkers []*Walker, nodes []*VirtualNode) {
timestamp := time.Since(w.created).Milliseconds()
for _, walker := range walkers {
row := []string{
fmt.Sprintf("%d", timestamp),
fmt.Sprintf("%d", walker.ID),
fmt.Sprintf("%.3f", walker.Position.X),
fmt.Sprintf("%.3f", walker.Position.Y),
fmt.Sprintf("%.3f", walker.Position.Z),
fmt.Sprintf("%.3f", walker.Velocity.X),
fmt.Sprintf("%.3f", walker.Velocity.Y),
fmt.Sprintf("%.3f", walker.Velocity.Z),
}
if err := w.writer.Write(row); err != nil {
fmt.Printf("[SIM] CSV write error: %v\n", err)
}
}
}
// Close flushes and closes the CSV file
func (w *CSVWriter) Close() error {
w.writer.Flush()
if err := w.file.Close(); err != nil {
return err
}
return nil
}
// verifyWalkersInRoom checks if all walkers are within room bounds
func verifyWalkersInRoom(walkers []*Walker, space *Space) bool {
for _, walker := range walkers {
if walker.Position.X < 0 || walker.Position.X > space.Width {
return false
}
if walker.Position.Y < 0 || walker.Position.Y > space.Depth {
return false
}
if walker.Position.Z < 0 || walker.Position.Z > space.Height {
return false
}
}
return true
}
// computeBlobAccuracy checks if blobs are within expected distance of walkers
func computeBlobAccuracy(blobs []map[string]interface{}, walkers []*Walker) (bool, float64) {
if len(blobs) == 0 || len(walkers) == 0 {
return false, 0
}
maxDistance := 0.0
matched := 0
for _, blob := range blobs {
blobX, _ := blob["x"].(float64)
blobY, _ := blob["y"].(float64)
blobZ, _ := blob["z"].(float64)
minDist := math.MaxFloat64
for _, walker := range walkers {
dx := blobX - walker.Position.X
dy := blobY - walker.Position.Y
dz := blobZ - walker.Position.Z
dist := math.Sqrt(dx*dx + dy*dy + dz*dz)
if dist < minDist {
minDist = dist
}
}
if minDist <= 2.0 { // Within 2 meters
matched++
}
if minDist > maxDistance {
maxDistance = minDist
}
}
// At least 50% of walkers should have a blob within 2m
accuracy := float64(matched) / float64(len(walkers))
return accuracy >= 0.5, maxDistance
}