spaxel/mothership/cmd/sim/generator.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

230 lines
5.6 KiB
Go

package main
import (
"encoding/binary"
"math"
"math/rand"
)
const (
// WiFi physical constants
wavelength = 0.123 // meters (2.4 GHz)
halfWavelength = wavelength / 2.0
subcarrierSpacing = 312.5e3 // Hz
c = 3e8 // speed of light m/s
// CSI frame constants
magic = 0xABCDEF01
version = 1
nSub = 64 // number of subcarriers for HT20
)
// generateCSIFrame generates a synthetic CSI binary frame
func generateCSIFrame(tx, rx *VirtualNode, walkers []*Walker, walls []Wall, frameNum int, rng *rand.Rand) []byte {
// Calculate combined CSI from all walkers
amplitude, phaseBase := computeCSIForWalkers(tx, rx, walkers, walls)
// Compute RSSI from amplitude
rssi := amplitudeToRSSI(amplitude)
// Create frame buffer
frame := make([]byte, headerSize+nSub*2)
// Write header
binary.LittleEndian.PutUint32(frame[0:4], magic)
frame[4] = version
copy(frame[5:11], tx.MAC[:])
copy(frame[11:17], rx.MAC[:])
binary.LittleEndian.PutUint64(frame[17:25], uint64(frameNum*50000)) // timestamp in microseconds
frame[25] = byte(rssi)
frame[26] = 0xFF // noise floor (invalid marker)
frame[27] = nSub
// Generate I/Q pairs for each subcarrier
for k := 0; k < nSub; k++ {
// Phase for this subcarrier
phase := phaseBase + float64(k)*0.1
// Add temporal variation
phase += 0.1 * math.Sin(2*math.Pi*float64(frameNum)/100.0)
// Normalize phase to [-π, π]
for phase > math.Pi {
phase -= 2 * math.Pi
}
for phase < -math.Pi {
phase += 2 * math.Pi
}
// Add frequency-selective fading
freqFading := 0.8 + 0.4*math.Sin(2*math.Pi*float64(k)/16.0)
subAmplitude := amplitude * freqFading
// Generate I/Q with noise
i, q := generateIQPair(subAmplitude, phase, rng)
// Write to payload (interleaved I,Q)
offset := headerSize + k*2
frame[offset] = byte(i)
frame[offset+1] = byte(q)
}
return frame
}
// computeCSIForWalkers computes the combined CSI amplitude and phase from all walkers
func computeCSIForWalkers(tx, rx *VirtualNode, walkers []*Walker, walls []Wall) (float64, float64) {
if len(walkers) == 0 {
// No walkers, return baseline noise
return 0.001, 0.0
}
var totalAmplitude float64
var totalPhase float64
var weight float64
for _, walker := range walkers {
// Distance from TX to walker
d1 := distance(tx.Position, walker.Position)
// Distance from walker to RX
d2 := distance(walker.Position, rx.Position)
// Direct TX-RX distance
dDirect := distance(tx.Position, rx.Position)
// Path length excess for Fresnel zone calculation
excess := d1 + d2 - dDirect
if excess < 0 {
excess = 0
}
// Fresnel zone number
zoneNumber := int(math.Ceil(excess / halfWavelength))
if zoneNumber < 1 {
zoneNumber = 1
}
// Zone decay (inverse square)
decay := 1.0 / math.Pow(float64(zoneNumber), 2.0)
// Path loss
pathLoss := 40.0 + 20.0*math.Log10(d1+d2)
// Wall attenuation
wallLoss := computeWallLoss(tx.Position, walker.Position, walls)
wallLoss += computeWallLoss(walker.Position, rx.Position, walls)
// Total loss in dB
totalLossDB := pathLoss + wallLoss
// Convert to linear amplitude
amplitude := math.Pow(10.0, -totalLossDB/20.0)
// Scale to reasonable values
amplitude *= 1000.0 * decay
// Phase at this position
phase := 2 * math.Pi * (d1+d2) / wavelength
// Accumulate (incoherent sum for amplitude, weighted average for phase)
totalAmplitude += amplitude
totalPhase += phase * decay
weight += decay
}
// Normalize
if weight > 0 {
totalPhase /= weight
}
return totalAmplitude, totalPhase
}
// distance computes Euclidean distance between two points
func distance(a, b Point) float64 {
dx := a.X - b.X
dy := a.Y - b.Y
dz := a.Z - b.Z
return math.Sqrt(dx*dx + dy*dy + dz*dz)
}
// computeWallLoss computes wall attenuation for a path
func computeWallLoss(from, to Point, walls []Wall) float64 {
totalLoss := 0.0
for _, wall := range walls {
if pathIntersectsWall(from.X, from.Y, to.X, to.Y, wall.X1, wall.Y1, wall.X2, wall.Y2) {
totalLoss += wall.Attenuation
}
}
return totalLoss
}
// pathIntersectsWall checks if a path intersects a wall segment (2D)
func pathIntersectsWall(x1, y1, x2, y2, wx1, wy1, wx2, wy2 float64) bool {
// Compute orientations
ccw := func(ax, ay, bx, by, cx, cy float64) float64 {
return (bx-ax)*(cy-ay) - (by-ay)*(cx-ax)
}
o1 := ccw(x1, y1, x2, y2, wx1, wy1)
o2 := ccw(x1, y1, x2, y2, wx2, wy2)
o3 := ccw(wx1, wy1, wx2, wy2, x1, y1)
o4 := ccw(wx1, wy1, wx2, wy2, x2, y2)
// Check for intersection
return o1*o2 < 0 && o3*o4 < 0
}
// amplitudeToRSSI converts amplitude to RSSI in dBm
func amplitudeToRSSI(amplitude float64) int8 {
// Convert amplitude to dBm (reference: amplitude 1.0 = -30 dBm)
amplitudeDBm := -30.0 + 20.0*math.Log10(amplitude)
// Clamp to realistic range
if amplitudeDBm < -90 {
amplitudeDBm = -90
}
if amplitudeDBm > -30 {
amplitudeDBm = -30
}
return int8(amplitudeDBm)
}
// generateIQPair generates a synthetic I/Q pair with Gaussian noise
func generateIQPair(amplitude, phase float64, rng *rand.Rand) (int8, int8) {
// Box-Muller transform for Gaussian noise
u1 := rng.Float64()
u2 := rng.Float64()
z0 := math.Sqrt(-2.0*math.Log(u1)) * math.Cos(2.0*math.Pi*u2)
z1 := math.Sqrt(-2.0*math.Log(u1)) * math.Sin(2.0*math.Pi*u2)
noiseI := z0 * *flagNoiseSigma
noiseQ := z1 * *flagNoiseSigma
// Convert to I/Q
i := amplitude*math.Cos(phase) + noiseI
q := amplitude*math.Sin(phase) + noiseQ
// Scale to int8 range
scale := 127.0 / 10.0 // Scale factor
i *= scale
q *= scale
// Clamp to int8 range [-127, 127]
if i > 127 {
i = 127
}
if i < -127 {
i = -127
}
if q > 127 {
q = 127
}
if q < -127 {
q = -127
}
return int8(i), int8(q)
}