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>
531 lines
15 KiB
Go
531 lines
15 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 — 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)
|
||
}
|