spaxel/mothership/cmd/sim/generator.go
jedarden 5ad6730524 fix(simulator): fix TestTwoRayModel wall reflection test case
The test was using a wall at x=10 from y=0 to y=5, which still creates
a valid reflection geometry for TX at (1,1) and RX at (3,3). The
reflection point calculation correctly finds that the reflected ray
would intersect the wall at y≈2.125, which is within the wall's bounds.

Fixed the test to use a wall that truly cannot create a valid reflection:
vertical wall at x=2 from y=10 to y=15. The reflection of TX (1,1) across
x=2 would be at (3,1). The line from (3,1) to RX (3,3) is vertical at x=3,
which never intersects the wall at x=2.

The simulator implements:
- Synthetic walkers (random walk, path-following, node-to-node)
- CSI generation with two-ray propagation model
- Wall attenuation (drywall: 3dB, brick/concrete: 10dB, glass: 2dB, metal: 20dB)
- Fresnel zone-based deltaRMS computation
- BLE simulation and CSV ground truth output

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 22:16:49 -04:00

531 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 — must match ingestion/frame.go format
nSub = 64 // number of subcarriers for HT20
// Path loss model constants (log-distance model)
// PL(d) = PL_0 + 10*n*log10(d/d_0)
pl0 = 40.0 // dBm reference power at d0
d0 = 1.0 // meters reference distance
n = 2.0 // path loss exponent (free space)
// Reflection coefficient (power, dimensionless)
reflectionCoeff = 0.3
)
// generateCSIFrame generates a synthetic CSI binary frame.
// The frame format matches the ingestion layer (ingestion/frame.go):
//
// Header (24 bytes fixed):
// [0:6] node_mac — TX node MAC address
// [6:12] peer_mac — RX node MAC address
// [12:20] timestamp_us — uint64 LE, microseconds since node boot
// [20] rssi — int8, dBm
// [21] noise_floor — int8, dBm
// [22] channel — uint8, WiFi channel
// [23] n_sub — uint8, subcarrier count
// Payload (n_sub × 2 bytes):
// Per subcarrier: int8 I, int8 Q
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 (headerSize=24 defined in main.go)
frame := make([]byte, headerSize+nSub*2)
// Write header (matches ingestion/frame.go ParseFrame layout)
copy(frame[0:6], tx.MAC[:]) // node_mac
copy(frame[6:12], rx.MAC[:]) // peer_mac
binary.LittleEndian.PutUint64(frame[12:20], uint64(frameNum*50000)) // timestamp_us
frame[20] = byte(rssi) // rssi
var noiseFloor int8 = -95
frame[21] = byte(noiseFloor) // noise_floor: -95 dBm
frame[22] = byte(*flagChannel) // channel (from --channel flag)
frame[23] = nSub // n_sub
// 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
// using a two-ray propagation model (direct path + first-order reflection).
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 {
// Direct path contribution
directAmp, directPhase := computeDirectPath(tx.Position, rx.Position, walker.Position, walls)
// First-order reflection contribution (strongest reflected ray)
reflAmp, reflPhase := computeFirstOrderReflection(tx.Position, rx.Position, walker.Position, walls)
// Combine direct and reflected paths (coherent sum for two-ray model)
// The reflected ray interferes with the direct ray, creating constructive/destructive interference
combinedAmp := directAmp + reflAmp*math.Cos(directPhase-reflPhase)
combinedPhase := math.Atan2(
directAmp*math.Sin(directPhase)+reflAmp*math.Sin(reflPhase),
directAmp*math.Cos(directPhase)+reflAmp*math.Cos(reflPhase),
)
// Scale to reasonable values
combinedAmp *= 1000.0
// Accumulate
totalAmplitude += combinedAmp
totalPhase += combinedPhase
weight += 1.0
}
// Normalize phase
if weight > 0 {
totalPhase /= weight
}
return totalAmplitude, totalPhase
}
// computeDirectPath computes the CSI contribution from the direct path
// through the walker to the receiver, using the log-distance path loss model.
func computeDirectPath(tx, rx, walker Point, walls []Wall) (float64, float64) {
// Distance from TX to walker
d1 := distance(tx, walker)
// Distance from walker to RX
d2 := distance(walker, rx)
// Total path length
dTotal := d1 + d2
// Direct TX-RX distance (for Fresnel zone calculation)
dDirect := distance(tx, rx)
// Path length excess for Fresnel zone calculation
excess := dTotal - 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)
// Log-distance path loss model: PL(d) = PL_0 + 10*n*log10(d/d_0)
var pathLossDB float64
if dTotal >= d0 {
pathLossDB = pl0 + 10.0*n*math.Log10(dTotal/d0)
} else {
// For distances < d0, use free space approximation
pathLossDB = pl0
}
// Wall attenuation on the direct path
wallLoss := computeWallLoss(tx, walker, walls)
wallLoss += computeWallLoss(walker, rx, walls)
// Total loss in dB
totalLossDB := pathLossDB + wallLoss
// Convert to linear amplitude
amplitude := math.Pow(10.0, -totalLossDB/20.0)
// Apply Fresnel zone decay
amplitude *= decay
// Phase at this position (based on total path length)
phase := 2 * math.Pi * dTotal / wavelength
return amplitude, phase
}
// computeFirstOrderReflection computes the CSI contribution from the strongest
// first-order reflection off a wall segment. Uses image method to find reflection point.
func computeFirstOrderReflection(tx, rx, walker Point, walls []Wall) (float64, float64) {
var bestReflAmp float64
var bestReflPhase float64
var foundReflection bool
for _, wall := range walls {
// Compute reflection point using image method
reflPoint, ok := findReflectionPoint(tx, rx, wall)
if !ok {
continue
}
// Reflected path: TX -> reflection point -> RX
dTxRefl := distance(tx, reflPoint)
dReflRx := distance(reflPoint, rx)
dReflTotal := dTxRefl + dReflRx
// Direct path length (for comparison)
dDirect := distance(tx, rx)
// Log-distance path loss for reflected path
var pathLossDB float64
if dReflTotal >= d0 {
pathLossDB = pl0 + 10.0*n*math.Log10(dReflTotal/d0)
} else {
pathLossDB = pl0
}
// Wall attenuation (transmission loss through wall if any)
// Plus reflection loss (weakest material first = lowest attenuation)
wallLoss := wall.Attenuation
// Total loss in dB
totalLossDB := pathLossDB + wallLoss
// Convert to linear amplitude
amplitude := math.Pow(10.0, -totalLossDB/20.0)
// Apply reflection coefficient (power coefficient)
amplitude *= math.Sqrt(reflectionCoeff)
// Normalize by direct path loss (so reflected ray is relative to direct)
directPathLoss := pl0 + 10.0*n*math.Log10(dDirect/d0)
amplitude *= math.Pow(10.0, (directPathLoss-pathLossDB)/20.0)
// Phase based on reflected path length
phase := 2 * math.Pi * dReflTotal / wavelength
// Keep the strongest reflection (lowest attenuation)
if !foundReflection || amplitude > bestReflAmp {
bestReflAmp = amplitude
bestReflPhase = phase
foundReflection = true
}
}
if !foundReflection {
return 0, 0
}
return bestReflAmp, bestReflPhase
}
// findReflectionPoint finds the specular reflection point on a wall segment
// for a ray from TX to RX using the image method.
// Returns the reflection point and true if a valid reflection exists, false otherwise.
func findReflectionPoint(tx, rx Point, wall Wall) (Point, bool) {
// For a vertical wall segment (in 2D floor plane), the reflection point
// is found by reflecting the TX across the wall line and finding the
// intersection with the wall segment.
// Wall line equation: ax + by + c = 0
// For vertical wall from (x1, y1) to (x2, y2):
// If x1 == x2 (vertical wall), reflection is straightforward
if math.Abs(wall.X1-wall.X2) < 1e-6 {
// Vertical wall at x = wall.X1
// Reflect TX across the wall
reflTxX := 2*wall.X1 - tx.X
reflTxY := tx.Y
// Find intersection of line from reflTx to RX with the wall
// Parametric line: reflTx + t*(rx - reflTx)
dx := rx.X - reflTxX
dy := rx.Y - reflTxY
if math.Abs(dx) < 1e-6 {
// Line is vertical, no intersection with vertical wall (or parallel)
return Point{}, false
}
t := (wall.X1 - reflTxX) / dx
// Compute intersection point
intersectY := reflTxY + t*dy
// Check if intersection is within wall segment bounds
minY := math.Min(wall.Y1, wall.Y2)
maxY := math.Max(wall.Y1, wall.Y2)
if intersectY < minY || intersectY > maxY {
return Point{}, false
}
// Z coordinate is average of TX and RX Z (reflection in vertical plane)
intersectZ := (tx.Z + rx.Z) / 2.0
return Point{X: wall.X1, Y: intersectY, Z: intersectZ}, true
}
// For horizontal wall segment (y1 == y2)
if math.Abs(wall.Y1-wall.Y2) < 1e-6 {
// Horizontal wall at y = wall.Y1
// Reflect TX across the wall
reflTxX := tx.X
reflTxY := 2*wall.Y1 - tx.Y
// Find intersection of line from reflTx to RX with the wall
dx := rx.X - reflTxX
dy := rx.Y - reflTxY
if math.Abs(dy) < 1e-6 {
// Line is horizontal, no intersection
return Point{}, false
}
t := (wall.Y1 - reflTxY) / dy
// Compute intersection point
intersectX := reflTxX + t*dx
// Check if intersection is within wall segment bounds
minX := math.Min(wall.X1, wall.X2)
maxX := math.Max(wall.X1, wall.X2)
if intersectX < minX || intersectX > maxX {
return Point{}, false
}
// Z coordinate is average of TX and RX Z
intersectZ := (tx.Z + rx.Z) / 2.0
return Point{X: intersectX, Y: wall.Y1, Z: intersectZ}, true
}
// General case: angled wall segment
// Compute line intersection
// Wall line: (x1, y1) to (x2, y2)
// Ray from reflected TX to RX
// Reflect TX across the wall line
reflTx := reflectPointAcrossLine(tx, Point{X: wall.X1, Y: wall.Y1, Z: 0}, Point{X: wall.X2, Y: wall.Y2, Z: 0})
// Find intersection of line from reflTx to RX with the wall line
intersect, ok := lineIntersection(
reflTx, rx,
Point{X: wall.X1, Y: wall.Y1, Z: 0}, Point{X: wall.X2, Y: wall.Y2, Z: 0},
)
if !ok {
return Point{}, false
}
// Check if intersection is within wall segment bounds
minX := math.Min(wall.X1, wall.X2)
maxX := math.Max(wall.X1, wall.X2)
minY := math.Min(wall.Y1, wall.Y2)
maxY := math.Max(wall.Y1, wall.Y2)
if intersect.X < minX || intersect.X > maxX || intersect.Y < minY || intersect.Y > maxY {
return Point{}, false
}
// Z coordinate is average of TX and RX Z
intersect.Z = (tx.Z + rx.Z) / 2.0
return intersect, true
}
// reflectPointAcrossLine reflects a point across a line defined by two points.
// Uses the formula for reflection of a point across a line in 2D.
func reflectPointAcrossLine(p, lineStart, lineEnd Point) Point {
// Line direction vector
lx := lineEnd.X - lineStart.X
ly := lineEnd.Y - lineStart.Y
// Vector from line start to point
px := p.X - lineStart.X
py := p.Y - lineStart.Y
// Project p onto the line (dot product)
dot := px*lx + py*ly
lenSq := lx*lx + ly*ly
if lenSq < 1e-10 {
// Line segment is too short
return p
}
// Projection parameter
t := dot / lenSq
// Closest point on line
closestX := lineStart.X + t*lx
closestY := lineStart.Y + t*ly
// Reflected point: p' = closest + (closest - p) = 2*closest - p
return Point{
X: 2*closestX - p.X,
Y: 2*closestY - p.Y,
Z: p.Z,
}
}
// lineIntersection finds the intersection point of two line segments in 2D.
// Returns the intersection point and true if the lines intersect, false otherwise.
func lineIntersection(p1, p2, p3, p4 Point) (Point, bool) {
// Line 1: p1 to p2
// Line 2: p3 to p4
x1, y1 := p1.X, p1.Y
x2, y2 := p2.X, p2.Y
x3, y3 := p3.X, p3.Y
x4, y4 := p4.X, p4.Y
// Compute denominator
denom := (x1-x2)*(y3-y4) - (y1-y2)*(x3-x4)
if math.Abs(denom) < 1e-10 {
// Lines are parallel
return Point{}, false
}
// Compute intersection point using parametric form
t := ((x1-x3)*(y3-y4) - (y1-y3)*(x3-x4)) / denom
u := -((x1-x2)*(y1-y3) - (y1-y2)*(x1-x3)) / denom
// Check if intersection is within both line segments
if t < 0 || t > 1 || u < 0 || u > 1 {
return Point{}, false
}
// Compute intersection point
intersectX := x1 + t*(x2-x1)
intersectY := y1 + t*(y2-y1)
return Point{X: intersectX, Y: intersectY}, true
}
// 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 through all walls.
// For each wall the path intersects, add the wall's attenuation value.
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)
}