spaxel/mothership/cmd/sim/verify.go
jedarden 23582797c9 ci: add golangci-lint static analysis gate
- Add .golangci.yml config at repo root with enabled linters:
  - errcheck, staticcheck, gosimple, govet, ineffassign, unused
- Fix pre-existing errcheck findings in cmd/sim/verify.go:
  - Check error return from file.Close() in error path
  - Check error return from defer resp.Body.Close()
- Add golangci-lint step to Argo CI workflow as parallel gate

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 12:57:55 -04:00

196 lines
5.2 KiB
Go

package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"strings"
"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 with headers for walker positions and link deltaRMS
func NewCSVWriter(filename string) (*CSVWriter, error) {
file, err := os.Create(filename)
if err != nil {
return nil, err
}
writer := csv.NewWriter(file)
header := []string{
"timestamp_ms",
"walker_id",
"x", "y", "z",
"vx", "vy", "vz",
"link_id",
"delta_rms",
}
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 including walker positions and per-link deltaRMS
func (w *CSVWriter) WriteRow(walkers []*Walker, nodes []*VirtualNode, walls []Wall) {
timestamp := time.Since(w.created).Milliseconds()
for _, walker := range walkers {
// Write walker position row (no link-specific data)
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),
"", // link_id — empty for position-only rows
"", // delta_rms — empty for position-only rows
}
if err := w.writer.Write(row); err != nil {
log.Printf("[SIM] CSV write error: %v", err)
}
// Write deltaRMS rows for each node pair link
for _, tx := range nodes {
for _, rx := range nodes {
if tx.ID >= rx.ID {
continue // avoid duplicate link pairs
}
deltaRMS := computeWalkerDeltaRMS(tx.Position, rx.Position, walker.Position)
linkID := fmt.Sprintf("%s:%s", macToString(tx.MAC), macToString(rx.MAC))
linkRow := []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),
"", "", "", // velocity empty for link rows
linkID,
fmt.Sprintf("%.6f", deltaRMS),
}
if err := w.writer.Write(linkRow); err != nil {
log.Printf("[SIM] CSV write error: %v", err)
}
}
}
}
}
// Close flushes and closes the CSV file
func (w *CSVWriter) Close() error {
w.writer.Flush()
return w.file.Close()
}
// verifyBlobs verifies that the mothership detected the expected number of blobs.
// It queries GET /api/blobs and checks blob_count == walker_count within ±1 tolerance.
func verifyBlobs(expectedWalkers int, walkers []*Walker, space *Space) (err error) {
wsURL, err := url.Parse(*flagMothership)
if err != nil {
return fmt.Errorf("invalid mothership URL: %w", err)
}
httpURL := *wsURL
if httpURL.Scheme == "ws" {
httpURL.Scheme = "http"
} else if httpURL.Scheme == "wss" {
httpURL.Scheme = "https"
}
log.Printf("[SIM] Waiting 2 seconds for pipeline to settle...")
time.Sleep(2 * time.Second)
blobsURL := strings.TrimSuffix(httpURL.String(), "/ws")
blobsURL = strings.TrimSuffix(blobsURL, "/")
blobsURL += "/api/blobs"
resp, err := http.Get(blobsURL)
if err != nil {
return fmt.Errorf("failed to query blobs: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
if err == nil {
err = fmt.Errorf("failed to close response body: %w", closeErr)
}
}
}()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("blobs API returned status %d: %s", resp.StatusCode, string(body))
}
var blobs []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&blobs); err != nil {
return fmt.Errorf("failed to decode blobs response: %w", err)
}
blobCount := len(blobs)
// Check blob count within ±1 tolerance
tolerance := 1
minExpected := expectedWalkers - tolerance
maxExpected := expectedWalkers + tolerance
if blobCount < minExpected || blobCount > maxExpected {
return fmt.Errorf("FAIL: expected %d blobs (±%d), got %d", expectedWalkers, tolerance, blobCount)
}
// If walkers are in room bounds, check each walker has a blob within 2m
if allWalkersInRoom(walkers, space) && len(blobs) > 0 {
matched := 0
for _, walker := range walkers {
for _, blob := range blobs {
bx, _ := blob["x"].(float64)
by, _ := blob["y"].(float64)
bz, _ := blob["z"].(float64)
dx := walker.Position.X - bx
dy := walker.Position.Y - by
dz := walker.Position.Z - bz
if math.Sqrt(dx*dx+dy*dy+dz*dz) <= 2.0 {
matched++
break
}
}
}
log.Printf("[SIM] %d/%d walkers have a blob within 2m", matched, len(walkers))
}
log.Printf("[SIM] PASS: %d blobs detected for %d walkers", blobCount, expectedWalkers)
return nil
}
// allWalkersInRoom checks if all walkers are within room bounds
func allWalkersInRoom(walkers []*Walker, space *Space) bool {
for _, w := range walkers {
if w.Position.X < 0 || w.Position.X > space.Width ||
w.Position.Y < 0 || w.Position.Y > space.Depth ||
w.Position.Z < 0 || w.Position.Z > space.Height {
return false
}
}
return true
}