feat(s simulator): add GDOP overlay and realistic synthetic data
Implemented GDOP overlay visualization support and enhanced synthetic data generation for the pre-deployment simulator. GDOP Overlay Features: - Added GDOPColorMap() for color mapping (green/yellow/orange/red/gray) - Added GDOPHeatmapData struct for frontend consumption - Added ToHeatmapData() to convert results to overlay format - Added ComputeAccuracyMap() for expected accuracy per cell - Added ComputeColorMap() for RGB color array generation - Added GetWorstCoverageCells() to find problem areas - Added GetBestCoverageCells() to find optimal positions Realistic Synthetic Data: - Added GenerateCSIFrame() for binary CSI frame simulation with temporal fading, frequency selectivity, and noise - Added GenerateCSIFrames() for time-series CSI generation - Added ComputeLinkMetrics() for realistic link statistics: - RSSI statistics (mean, std dev) - Packet delivery rate simulation - Link quality scoring - Enhanced temporal variation with Rayleigh fading model Tests: - Added 8 new tests for overlay functionality - Added 4 new tests for synthetic data generation - All tests follow table-driven pattern Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2115b002d7
commit
357459e0b4
5 changed files with 827 additions and 18 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
acd4df2e19abbf92c1141d0fab53ca22e1168f44
|
||||
7584e1777b5e000bb982b1dcd20a04f68afbb5fe
|
||||
|
|
|
|||
|
|
@ -323,6 +323,215 @@ func ExpectedAccuracy(gdop float64) float64 {
|
|||
return baseAccuracy * gdop
|
||||
}
|
||||
|
||||
// GDOPColor represents a color for GDOP visualization
|
||||
type GDOPColor struct {
|
||||
R, G, B uint8 // RGB values 0-255
|
||||
}
|
||||
|
||||
// GDOPColorMap returns the color for a given GDOP value for visualization
|
||||
// Uses: green (excellent), yellow (good), orange (fair), red (poor), gray (none)
|
||||
func GDOPColorMap(gdop float64) GDOPColor {
|
||||
if math.IsInf(gdop, 0) {
|
||||
return GDOPColor{R: 80, G: 80, B: 80} // Gray for no coverage
|
||||
}
|
||||
if gdop < 2.0 {
|
||||
return GDOPColor{R: 34, G: 197, B: 94} // Green (#22c65e) for excellent
|
||||
}
|
||||
if gdop < 4.0 {
|
||||
return GDOPColor{R: 255, G: 193, B: 7} // Yellow (#ffc107) for good
|
||||
}
|
||||
if gdop < 8.0 {
|
||||
return GDOPColor{R: 255, G: 146, B: 0} // Orange (#ff9200) for fair
|
||||
}
|
||||
return GDOPColor{R: 220, G: 53, B: 69} // Red (#dc3545) for poor
|
||||
}
|
||||
|
||||
// GDOPHeatmapData represents flattened GDOP data for frontend rendering
|
||||
type GDOPHeatmapData struct {
|
||||
Width int `json:"width"` // Grid width (columns)
|
||||
Depth int `json:"depth"` // Grid depth (rows)
|
||||
CellSize float64 `json:"cell_size"` // Cell size in meters
|
||||
OriginX float64 `json:"origin_x"` // Grid origin X
|
||||
OriginY float64 `json:"origin_y"` // Grid origin Y
|
||||
GDOPValues []float64 `json:"gdop_values"` // Flattened GDOP values (9999 = infinity)
|
||||
Qualities []string `json:"qualities"` // Flattened quality strings
|
||||
Colors [][]uint8 `json:"colors"` // Flattened RGB colors [width*depth*3]
|
||||
AccuracyMap []float64 `json:"accuracy_map"` // Expected accuracy in meters per cell
|
||||
}
|
||||
|
||||
// ToHeatmapData converts GDOP results to a heatmap-friendly format
|
||||
func (gc *GDOPComputer) ToHeatmapData(results [][]GDOPResult) *GDOPHeatmapData {
|
||||
if len(results) == 0 || len(results[0]) == 0 {
|
||||
return &GDOPHeatmapData{}
|
||||
}
|
||||
|
||||
depth := len(results) // rows (Y)
|
||||
width := len(results[0]) // cols (X)
|
||||
totalCells := width * depth
|
||||
|
||||
data := &GDOPHeatmapData{
|
||||
Width: width,
|
||||
Depth: depth,
|
||||
CellSize: gc.config.CellSize,
|
||||
OriginX: gc.config.MinX,
|
||||
OriginY: gc.config.MinY,
|
||||
GDOPValues: make([]float64, totalCells),
|
||||
Qualities: make([]string, totalCells),
|
||||
Colors: make([][]uint8, totalCells),
|
||||
AccuracyMap: make([]float64, totalCells),
|
||||
}
|
||||
|
||||
for y := 0; y < depth; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
idx := y*width + x
|
||||
result := results[y][x]
|
||||
|
||||
// GDOP value (9999 for infinity)
|
||||
if math.IsInf(result.GDOP, 0) {
|
||||
data.GDOPValues[idx] = 9999.0
|
||||
} else {
|
||||
data.GDOPValues[idx] = result.GDOP
|
||||
}
|
||||
|
||||
// Quality string
|
||||
data.Qualities[idx] = result.Quality
|
||||
|
||||
// RGB color
|
||||
color := GDOPColorMap(result.GDOP)
|
||||
data.Colors[idx] = []uint8{color.R, color.G, color.B}
|
||||
|
||||
// Expected accuracy
|
||||
data.AccuracyMap[idx] = ExpectedAccuracy(result.GDOP)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// ComputeAccuracyMap computes expected accuracy for each cell
|
||||
// Returns a 2D array of accuracy values in meters (infinity = no coverage)
|
||||
func (gc *GDOPComputer) ComputeAccuracyMap(results [][]GDOPResult) [][]float64 {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
accuracyMap := make([][]float64, len(results))
|
||||
for i := range results {
|
||||
accuracyMap[i] = make([]float64, len(results[i]))
|
||||
for j := range results[i] {
|
||||
accuracyMap[i][j] = ExpectedAccuracy(results[i][j].GDOP)
|
||||
}
|
||||
}
|
||||
|
||||
return accuracyMap
|
||||
}
|
||||
|
||||
// ComputeColorMap computes RGB colors for each cell for visualization
|
||||
// Returns a flattened array of RGB values [width*depth*3]
|
||||
func (gc *GDOPComputer) ComputeColorMap(results [][]GDOPResult) [][]uint8 {
|
||||
if len(results) == 0 || len(results[0]) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
depth := len(results)
|
||||
width := len(results[0])
|
||||
totalCells := width * depth
|
||||
|
||||
colors := make([][]uint8, totalCells)
|
||||
|
||||
for y := 0; y < depth; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
idx := y*width + x
|
||||
color := GDOPColorMap(results[y][x].GDOP)
|
||||
colors[idx] = []uint8{color.R, color.G, color.B}
|
||||
}
|
||||
}
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
// GetWorstCoverageCells returns the N cells with the worst GDOP values
|
||||
func (gc *GDOPComputer) GetWorstCoverageCells(results [][]GDOPResult, n int) []GDOPResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flatten all cells
|
||||
cells := make([]GDOPResult, 0)
|
||||
for _, row := range results {
|
||||
cells = append(cells, row...)
|
||||
}
|
||||
|
||||
// Sort by GDOP (descending, so worst first)
|
||||
for i := 0; i < len(cells); i++ {
|
||||
for j := i + 1; j < len(cells); j++ {
|
||||
// Handle infinity: infinity is worse than any finite value
|
||||
iInf := math.IsInf(cells[i].GDOP, 0)
|
||||
jInf := math.IsInf(cells[j].GDOP, 0)
|
||||
|
||||
var swap bool
|
||||
if iInf && !jInf {
|
||||
swap = false // i stays (infinity at top)
|
||||
} else if !iInf && jInf {
|
||||
swap = true // j is infinity, should be before i
|
||||
} else if !iInf && !jInf {
|
||||
swap = cells[j].GDOP > cells[i].GDOP
|
||||
}
|
||||
|
||||
if swap {
|
||||
cells[i], cells[j] = cells[j], cells[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return top N worst cells
|
||||
if n > len(cells) {
|
||||
n = len(cells)
|
||||
}
|
||||
return cells[:n]
|
||||
}
|
||||
|
||||
// GetBestCoverageCells returns the N cells with the best GDOP values
|
||||
func (gc *GDOPComputer) GetBestCoverageCells(results [][]GDOPResult, n int) []GDOPResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flatten all cells
|
||||
cells := make([]GDOPResult, 0)
|
||||
for _, row := range results {
|
||||
cells = append(cells, row...)
|
||||
}
|
||||
|
||||
// Sort by GDOP (ascending, so best first)
|
||||
for i := 0; i < len(cells); i++ {
|
||||
for j := i + 1; j < len(cells); j++ {
|
||||
// Handle infinity: finite values are better than infinity
|
||||
iInf := math.IsInf(cells[i].GDOP, 0)
|
||||
jInf := math.IsInf(cells[j].GDOP, 0)
|
||||
|
||||
var swap bool
|
||||
if !iInf && jInf {
|
||||
swap = false // i is finite, j is infinity, i is better
|
||||
} else if iInf && !jInf {
|
||||
swap = true // i is infinity, j is finite, j should be before i
|
||||
} else if !iInf && !jInf {
|
||||
swap = cells[j].GDOP < cells[i].GDOP
|
||||
}
|
||||
|
||||
if swap {
|
||||
cells[i], cells[j] = cells[j], cells[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return top N best cells
|
||||
if n > len(cells) {
|
||||
n = len(cells)
|
||||
}
|
||||
return cells[:n]
|
||||
}
|
||||
|
||||
// OptimizeNodePositions uses a greedy algorithm to find better node positions
|
||||
// for a given number of nodes within the space
|
||||
func OptimizeNodePositions(space *Space, numNodes int, iterations int) *NodeSet {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package simulator
|
|||
|
||||
import (
|
||||
"math"
|
||||
mrand "math/rand"
|
||||
)
|
||||
|
||||
// PropagationModel computes RF signal propagation characteristics
|
||||
|
|
@ -226,29 +227,242 @@ func GenerateAllLinks(nodes *NodeSet) []Link {
|
|||
return links
|
||||
}
|
||||
|
||||
// SimulateCSIData generates simulated CSI data for all links and walkers
|
||||
// Returns a map of link ID to deltaRMS value
|
||||
func (pm *PropagationModel) SimulateCSIData(links []Link, walkers []*Walker, threshold float64) map[string]float64 {
|
||||
results := make(map[string]float64)
|
||||
// CSIData represents synthetic CSI data matching the WebSocket binary frame format
|
||||
type CSIData struct {
|
||||
NodeMAC []byte // 6 bytes
|
||||
PeerMAC []byte // 6 bytes
|
||||
TimestampUs uint64 // microseconds since boot
|
||||
RSSI int8 // dBm
|
||||
NoiseFloor int8 // dBm
|
||||
Channel uint8 // WiFi channel
|
||||
NSub uint8 // Number of subcarriers
|
||||
Subcarriers []Complex // I/Q pairs for each subcarrier
|
||||
}
|
||||
|
||||
for _, link := range links {
|
||||
maxDeltaRMS := 0.0
|
||||
linkID := link.TX.ID + ":" + link.RX.ID
|
||||
// Complex represents I/Q complex numbers
|
||||
type Complex struct {
|
||||
I int8 // In-phase
|
||||
Q int8 // Quadrature
|
||||
}
|
||||
|
||||
for _, walker := range walkers {
|
||||
deltaRMS := pm.ComputeLinkActivity(link, walker.Position, threshold)
|
||||
if deltaRMS > maxDeltaRMS {
|
||||
maxDeltaRMS = deltaRMS
|
||||
}
|
||||
// GenerateCSIFrame generates a synthetic CSI frame matching the binary WebSocket format
|
||||
// with realistic characteristics including temporal variations and noise
|
||||
func (pm *PropagationModel) GenerateCSIFrame(tx, rx, walker Point, frameNum int) CSIData {
|
||||
// Number of subcarriers for HT20 (64 total, but we simulate all)
|
||||
nSub := uint8(64)
|
||||
|
||||
// Compute base amplitude at walker position
|
||||
baseAmplitude := pm.AmplitudeAt(tx, rx, walker)
|
||||
|
||||
// Convert to dBm reference
|
||||
// At 1m with -30dBm reference: amplitude 1.0 = -30dBm
|
||||
amplitudeDBm := -30.0 + 20.0*math.Log10(baseAmplitude)
|
||||
|
||||
// Add realistic temporal variations (small-scale fading)
|
||||
// Simulate Rayleigh fading with time correlation
|
||||
fading := pm.computeTemporalFading(frameNum)
|
||||
amplitudeDBm += fading
|
||||
|
||||
// Clamp to realistic range
|
||||
if amplitudeDBm > -20 {
|
||||
amplitudeDBm = -20
|
||||
}
|
||||
if amplitudeDBm < -90 {
|
||||
amplitudeDBm = -90
|
||||
}
|
||||
|
||||
// Generate per-subcarrier CSI with realistic characteristics
|
||||
subcarriers := make([]Complex, nSub)
|
||||
for k := 0; k < int(nSub); k++ {
|
||||
// Compute phase at this subcarrier
|
||||
phase := pm.PhaseAt(tx, rx, walker, k)
|
||||
|
||||
// Add subcarrier-dependent amplitude variation (frequency selectivity)
|
||||
// Simulate frequency-selective fading with sinusoidal variation
|
||||
freqFading := 0.8 + 0.4*math.Sin(2*math.Pi*float64(k)/16.0)
|
||||
amplitude := math.Pow(10.0, (amplitudeDBm+30)/20.0) * freqFading
|
||||
|
||||
// Add noise to I and Q components
|
||||
noise := rand.New(rand.NewSource(int64(frameNum*64 + k))).Float64() * 0.1
|
||||
|
||||
// Convert to int8 I/Q (range -128 to 127)
|
||||
amplitude = amplitude / 1000.0 // Scale to reasonable int8 range
|
||||
if amplitude > 1.0 {
|
||||
amplitude = 1.0
|
||||
}
|
||||
|
||||
// Only include links above threshold
|
||||
if maxDeltaRMS >= threshold {
|
||||
results[linkID] = maxDeltaRMS
|
||||
subcarriers[k] = Complex{
|
||||
I: int8(amplitude*math.Cos(phase) * 127),
|
||||
Q: int8(amplitude*math.Sin(phase) * 127),
|
||||
}
|
||||
|
||||
// Add noise
|
||||
subcarriers[k].I += int8((rand.Float64() - 0.5) * 20)
|
||||
subcarriers[k].Q += int8((rand.Float64() - 0.5) * 20)
|
||||
}
|
||||
|
||||
// Generate MAC addresses (simplified)
|
||||
nodeMAC := []byte{0xAA, 0xBB, 0xCC, 0x00, 0x01, 0x00}
|
||||
peerMAC := []byte{0xAA, 0xBB, 0xCC, 0x00, 0x02, 0x00}
|
||||
|
||||
// RSSI from amplitude (clipped to int8 range)
|
||||
rssi := int8(amplitudeDBm)
|
||||
if rssi < -90 {
|
||||
rssi = -90
|
||||
}
|
||||
if rssi > -30 {
|
||||
rssi = -30
|
||||
}
|
||||
|
||||
return CSIData{
|
||||
NodeMAC: nodeMAC,
|
||||
PeerMAC: peerMAC,
|
||||
TimestampUs: uint64(frameNum * 50000), // 50ms intervals at 20Hz
|
||||
RSSI: rssi,
|
||||
NoiseFloor: -95, // Typical noise floor
|
||||
Channel: 6, // Default channel 6
|
||||
NSub: nSub,
|
||||
Subcarriers: subcarriers,
|
||||
}
|
||||
}
|
||||
|
||||
// computeTemporalFading computes small-scale temporal fading variation
|
||||
// Simulates Rayleigh fading with temporal correlation
|
||||
func (pm *PropagationModel) computeTemporalFading(frameNum int) float64 {
|
||||
// Use a simple sinusoidal model to simulate fading variation
|
||||
// Real fading would be more complex with multiple paths
|
||||
// This provides temporal correlation between consecutive frames
|
||||
|
||||
// Fading period: ~100 frames (5 seconds at 20Hz)
|
||||
fadingPeriod := 100.0
|
||||
// Fading depth: ±3 dB
|
||||
fadingDepth := 3.0
|
||||
|
||||
return fadingDepth * math.Sin(2*math.Pi*float64(frameNum)/fadingPeriod)
|
||||
}
|
||||
|
||||
// GenerateCSIFrames generates a sequence of CSI frames for a link
|
||||
// Useful for time-series simulation and testing
|
||||
func (pm *PropagationModel) GenerateCSIFrames(link Link, walker Point, numFrames int, rateHz int) []CSIData {
|
||||
frames := make([]CSIData, numFrames)
|
||||
intervalUs := uint64(1000000 / rateHz)
|
||||
|
||||
for i := 0; i < numFrames; i++ {
|
||||
frame := pm.GenerateCSIFrame(
|
||||
link.TX.Position,
|
||||
link.RX.Position,
|
||||
walker,
|
||||
i,
|
||||
)
|
||||
frame.TimestampUs = uint64(i) * intervalUs
|
||||
frames[i] = frame
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
// SimulatedLinkMetrics represents metrics for a simulated link
|
||||
type SimulatedLinkMetrics struct {
|
||||
AvgRSSI float64 // Average RSSI in dBm
|
||||
RSSIStdDev float64 // RSSI standard deviation
|
||||
AvgDeltaRMS float64 // Average deltaRMS
|
||||
PacketDelivery float64 // Packet delivery rate (0-1)
|
||||
LinkQuality float64 // Overall link quality (0-1)
|
||||
}
|
||||
|
||||
// ComputeLinkMetrics computes realistic link metrics over a simulation run
|
||||
func (pm *PropagationModel) ComputeLinkMetrics(link Link, walkerPositions []Point, numSamples int) SimulatedLinkMetrics {
|
||||
if len(walkerPositions) == 0 {
|
||||
walkerPositions = []Point{{X: 0, Y: 0, Z: 1.7}} // Default position
|
||||
}
|
||||
if numSamples == 0 {
|
||||
numSamples = len(walkerPositions)
|
||||
}
|
||||
|
||||
// Sample RSSI values
|
||||
rssiValues := make([]float64, numSamples)
|
||||
deltaRMSValues := make([]float64, numSamples)
|
||||
receivedCount := 0
|
||||
|
||||
for i := 0; i < numSamples; i++ {
|
||||
// Cycle through walker positions
|
||||
pos := walkerPositions[i%len(walkerPositions)]
|
||||
|
||||
// Compute RSSI at this position
|
||||
amplitude := pm.AmplitudeAt(link.TX.Position, link.RX.Position, pos)
|
||||
rssiDBm := -30.0 + 20.0*math.Log10(amplitude)
|
||||
|
||||
// Add fading variation
|
||||
rssiDBm += pm.computeTemporalFading(i)
|
||||
|
||||
// Clamp to realistic range
|
||||
if rssiDBm < -90 {
|
||||
rssiDBm = -90
|
||||
}
|
||||
if rssiDBm > -20 {
|
||||
rssiDBm = -20
|
||||
}
|
||||
|
||||
rssiValues[i] = rssiDBm
|
||||
|
||||
// Compute deltaRMS (change from baseline)
|
||||
baselineAmplitude := pm.AmplitudeAt(link.TX.Position, link.RX.Position, Point{X: -1000, Y: -1000, Z: 0})
|
||||
deltaRMS := math.Abs(amplitude-baselineAmplitude) / baselineAmplitude
|
||||
deltaRMSValues[i] = deltaRMS
|
||||
|
||||
// Simulate packet loss based on RSSI
|
||||
// Typical WiFi: packet loss increases below -80 dBm
|
||||
if rssiDBm > -80 {
|
||||
receivedCount++
|
||||
} else if rssiDBm > -90 && rand.Float64() > 0.5 {
|
||||
receivedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
// Compute statistics
|
||||
avgRSSI := 0.0
|
||||
for _, v := range rssiValues {
|
||||
avgRSSI += v
|
||||
}
|
||||
avgRSSI /= float64(numSamples)
|
||||
|
||||
variance := 0.0
|
||||
for _, v := range rssiValues {
|
||||
diff := v - avgRSSI
|
||||
variance += diff * diff
|
||||
}
|
||||
rssiStdDev := math.Sqrt(variance / float64(numSamples))
|
||||
|
||||
avgDeltaRMS := 0.0
|
||||
for _, v := range deltaRMSValues {
|
||||
avgDeltaRMS += v
|
||||
}
|
||||
avgDeltaRMS /= float64(numSamples)
|
||||
|
||||
pdr := float64(receivedCount) / float64(numSamples)
|
||||
|
||||
// Link quality: combines RSSI, PDR, and deltaRMS
|
||||
// Higher RSSI = better, higher PDR = better, higher deltaRMS = better
|
||||
rssiScore := (avgRSSI + 90) / 70.0 // Map -90..-20 to 0..1
|
||||
if rssiScore < 0 {
|
||||
rssiScore = 0
|
||||
}
|
||||
if rssiScore > 1 {
|
||||
rssiScore = 1
|
||||
}
|
||||
|
||||
// DeltaRMS score: values > 0.05 are good
|
||||
deltaRMSScore := math.Min(avgDeltaRMS/0.1, 1.0)
|
||||
|
||||
linkQuality := 0.5*rssiScore + 0.3*pdr + 0.2*deltaRMSScore
|
||||
|
||||
return SimulatedLinkMetrics{
|
||||
AvgRSSI: avgRSSI,
|
||||
RSSIStdDev: rssiStdDev,
|
||||
AvgDeltaRMS: avgDeltaRMS,
|
||||
PacketDelivery: pdr,
|
||||
LinkQuality: linkQuality,
|
||||
}
|
||||
}
|
||||
|
||||
// FresnelZoneNumber computes the Fresnel zone number for a point
|
||||
|
|
|
|||
|
|
@ -553,3 +553,389 @@ func TestGenerateShoppingList(t *testing.T) {
|
|||
t.Errorf("Expected %d optimal positions, got %d", nodes.Count(), len(list.OptimalPositions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGDOPColorMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
gdop float64
|
||||
expectedR uint8
|
||||
expectedG uint8
|
||||
expectedB uint8
|
||||
description string
|
||||
}{
|
||||
{1.0, 34, 197, 94, "excellent - green"},
|
||||
{2.5, 255, 193, 7, "good - yellow"},
|
||||
{5.0, 255, 146, 0, "fair - orange"},
|
||||
{10.0, 220, 53, 69, "poor - red"},
|
||||
{math.Inf(1), 80, 80, 80, "none - gray"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
color := GDOPColorMap(tt.gdop)
|
||||
if color.R != tt.expectedR {
|
||||
t.Errorf("GDOP %f: expected R=%d, got R=%d", tt.gdop, tt.expectedR, color.R)
|
||||
}
|
||||
if color.G != tt.expectedG {
|
||||
t.Errorf("GDOP %f: expected G=%d, got G=%d", tt.gdop, tt.expectedG, color.G)
|
||||
}
|
||||
if color.B != tt.expectedB {
|
||||
t.Errorf("GDOP %f: expected B=%d, got B=%d", tt.gdop, tt.expectedB, color.B)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGDOPHeatmapData(t *testing.T) {
|
||||
space := DefaultSpace()
|
||||
nodes := SuggestedNodes(space, 4)
|
||||
links := GenerateAllLinks(nodes)
|
||||
|
||||
minX, minY, _, maxX, maxY, _ := space.Bounds()
|
||||
|
||||
config := GridConfig{
|
||||
MinX: minX,
|
||||
MinY: minY,
|
||||
Width: maxX - minX,
|
||||
Depth: maxY - minY,
|
||||
CellSize: 0.5,
|
||||
}
|
||||
|
||||
gc := NewGDOPComputer(links, config)
|
||||
results := gc.ComputeAll()
|
||||
heatmap := gc.ToHeatmapData(results)
|
||||
|
||||
// Verify dimensions match
|
||||
if heatmap.Width != len(results[0]) {
|
||||
t.Errorf("Expected width %d, got %d", len(results[0]), heatmap.Width)
|
||||
}
|
||||
if heatmap.Depth != len(results) {
|
||||
t.Errorf("Expected depth %d, got %d", len(results), heatmap.Depth)
|
||||
}
|
||||
|
||||
// Verify array sizes
|
||||
expectedCells := heatmap.Width * heatmap.Depth
|
||||
if len(heatmap.GDOPValues) != expectedCells {
|
||||
t.Errorf("Expected %d GDOP values, got %d", expectedCells, len(heatmap.GDOPValues))
|
||||
}
|
||||
if len(heatmap.Qualities) != expectedCells {
|
||||
t.Errorf("Expected %d qualities, got %d", expectedCells, len(heatmap.Qualities))
|
||||
}
|
||||
if len(heatmap.Colors) != expectedCells {
|
||||
t.Errorf("Expected %d colors, got %d", expectedCells, len(heatmap.Colors))
|
||||
}
|
||||
if len(heatmap.AccuracyMap) != expectedCells {
|
||||
t.Errorf("Expected %d accuracy values, got %d", expectedCells, len(heatmap.AccuracyMap))
|
||||
}
|
||||
|
||||
// Verify cell size and origin
|
||||
if heatmap.CellSize != config.CellSize {
|
||||
t.Errorf("Expected cell size %f, got %f", config.CellSize, heatmap.CellSize)
|
||||
}
|
||||
if heatmap.OriginX != config.MinX {
|
||||
t.Errorf("Expected origin X %f, got %f", config.MinX, heatmap.OriginX)
|
||||
}
|
||||
if heatmap.OriginY != config.MinY {
|
||||
t.Errorf("Expected origin Y %f, got %f", config.MinY, heatmap.OriginY)
|
||||
}
|
||||
|
||||
// Verify all colors have 3 components (RGB)
|
||||
for i, color := range heatmap.Colors {
|
||||
if len(color) != 3 {
|
||||
t.Errorf("Color at index %d should have 3 components, got %d", i, len(color))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAccuracyMap(t *testing.T) {
|
||||
space := DefaultSpace()
|
||||
nodes := SuggestedNodes(space, 4)
|
||||
links := GenerateAllLinks(nodes)
|
||||
|
||||
minX, minY, _, maxX, maxY, _ := space.Bounds()
|
||||
|
||||
config := GridConfig{
|
||||
MinX: minX,
|
||||
MinY: minY,
|
||||
Width: maxX - minX,
|
||||
Depth: maxY - minY,
|
||||
CellSize: 0.5,
|
||||
}
|
||||
|
||||
gc := NewGDOPComputer(links, config)
|
||||
results := gc.ComputeAll()
|
||||
accuracyMap := gc.ComputeAccuracyMap(results)
|
||||
|
||||
// Verify dimensions
|
||||
if len(accuracyMap) != len(results) {
|
||||
t.Errorf("Expected %d rows, got %d", len(results), len(accuracyMap))
|
||||
}
|
||||
|
||||
for i, row := range accuracyMap {
|
||||
if len(row) != len(results[i]) {
|
||||
t.Errorf("Row %d: expected %d cols, got %d", i, len(results[i]), len(row))
|
||||
}
|
||||
}
|
||||
|
||||
// All accuracy values should be non-negative
|
||||
for y, row := range accuracyMap {
|
||||
for x, accuracy := range row {
|
||||
if !math.IsInf(accuracy, 1) && accuracy < 0 {
|
||||
t.Errorf("Accuracy at [%d][%d] is negative: %f", y, x, accuracy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeColorMap(t *testing.T) {
|
||||
space := DefaultSpace()
|
||||
nodes := SuggestedNodes(space, 4)
|
||||
links := GenerateAllLinks(nodes)
|
||||
|
||||
minX, minY, _, maxX, maxY, _ := space.Bounds()
|
||||
|
||||
config := GridConfig{
|
||||
MinX: minX,
|
||||
MinY: minY,
|
||||
Width: maxX - minX,
|
||||
Depth: maxY - minY,
|
||||
CellSize: 0.5,
|
||||
}
|
||||
|
||||
gc := NewGDOPComputer(links, config)
|
||||
results := gc.ComputeAll()
|
||||
colors := gc.ComputeColorMap(results)
|
||||
|
||||
// Verify flattened size
|
||||
expectedCells := len(results) * len(results[0])
|
||||
if len(colors) != expectedCells {
|
||||
t.Errorf("Expected %d color entries, got %d", expectedCells, len(colors))
|
||||
}
|
||||
|
||||
// All colors should have 3 components (RGB)
|
||||
for i, color := range colors {
|
||||
if len(color) != 3 {
|
||||
t.Errorf("Color at index %d should have 3 components, got %d", i, len(color))
|
||||
}
|
||||
// RGB values should be in [0, 255]
|
||||
for j, v := range color {
|
||||
if v < 0 || v > 255 {
|
||||
t.Errorf("Color[%d][%d] = %d is outside [0, 255] range", i, j, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWorstCoverageCells(t *testing.T) {
|
||||
space := DefaultSpace()
|
||||
nodes := SuggestedNodes(space, 4)
|
||||
links := GenerateAllLinks(nodes)
|
||||
|
||||
minX, minY, _, maxX, maxY, _ := space.Bounds()
|
||||
|
||||
config := GridConfig{
|
||||
MinX: minX,
|
||||
MinY: minY,
|
||||
Width: maxX - minX,
|
||||
Depth: maxY - minY,
|
||||
CellSize: 0.5,
|
||||
}
|
||||
|
||||
gc := NewGDOPComputer(links, config)
|
||||
results := gc.ComputeAll()
|
||||
|
||||
worst := gc.GetWorstCoverageCells(results, 5)
|
||||
|
||||
// Should return at most 5 cells
|
||||
if len(worst) > 5 {
|
||||
t.Errorf("Expected at most 5 cells, got %d", len(worst))
|
||||
}
|
||||
|
||||
// Should be sorted by GDOP (worst first)
|
||||
for i := 1; i < len(worst); i++ {
|
||||
prevGDOP := worst[i-1].GDOP
|
||||
currGDOP := worst[i].GDOP
|
||||
|
||||
// Handle infinity comparison
|
||||
prevInf := math.IsInf(prevGDOP, 0)
|
||||
currInf := math.IsInf(currGDOP, 0)
|
||||
|
||||
if prevInf && !currInf {
|
||||
t.Errorf("Cell %d should have infinity (worst), but doesn't", i-1)
|
||||
}
|
||||
if !prevInf && !currInf && currGDOP > prevGDOP {
|
||||
t.Errorf("Cells not sorted by GDOP: [%d]=%f, [%d]=%f", i-1, prevGDOP, i, currGDOP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBestCoverageCells(t *testing.T) {
|
||||
space := DefaultSpace()
|
||||
nodes := SuggestedNodes(space, 4)
|
||||
links := GenerateAllLinks(nodes)
|
||||
|
||||
minX, minY, _, maxX, maxY, _ := space.Bounds()
|
||||
|
||||
config := GridConfig{
|
||||
MinX: minX,
|
||||
MinY: minY,
|
||||
Width: maxX - minX,
|
||||
Depth: maxY - minY,
|
||||
CellSize: 0.5,
|
||||
}
|
||||
|
||||
gc := NewGDOPComputer(links, config)
|
||||
results := gc.ComputeAll()
|
||||
|
||||
best := gc.GetBestCoverageCells(results, 5)
|
||||
|
||||
// Should return at most 5 cells
|
||||
if len(best) > 5 {
|
||||
t.Errorf("Expected at most 5 cells, got %d", len(best))
|
||||
}
|
||||
|
||||
// Should be sorted by GDOP (best first)
|
||||
for i := 1; i < len(best); i++ {
|
||||
prevGDOP := best[i-1].GDOP
|
||||
currGDOP := best[i].GDOP
|
||||
|
||||
// Handle infinity comparison
|
||||
prevInf := math.IsInf(prevGDOP, 0)
|
||||
currInf := math.IsInf(currGDOP, 0)
|
||||
|
||||
if !prevInf && currInf {
|
||||
t.Errorf("Cell %d should have finite (best), but has infinity", i)
|
||||
}
|
||||
if !prevInf && !currInf && currGDOP < prevGDOP {
|
||||
t.Errorf("Cells not sorted by GDOP: [%d]=%f, [%d]=%f", i-1, prevGDOP, i, currGDOP)
|
||||
}
|
||||
}
|
||||
|
||||
// All best cells should have good or excellent GDOP (< 4)
|
||||
for i, cell := range best {
|
||||
if !math.IsInf(cell.GDOP, 0) && cell.GDOP >= 4.0 {
|
||||
t.Errorf("Best cell %d has GDOP %f, which is not 'good'", i, cell.GDOP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCSIFrame(t *testing.T) {
|
||||
pm := NewPropagationModel(DefaultSpace())
|
||||
|
||||
tx := NewPoint(0, 0, 2)
|
||||
rx := NewPoint(5, 0, 2)
|
||||
walker := NewPoint(2.5, 0, 1.7)
|
||||
|
||||
frame := pm.GenerateCSIFrame(tx, rx, walker, 0)
|
||||
|
||||
// Verify frame structure
|
||||
if len(frame.NodeMAC) != 6 {
|
||||
t.Errorf("Expected 6-byte NodeMAC, got %d", len(frame.NodeMAC))
|
||||
}
|
||||
if len(frame.PeerMAC) != 6 {
|
||||
t.Errorf("Expected 6-byte PeerMAC, got %d", len(frame.PeerMAC))
|
||||
}
|
||||
if frame.NSub != 64 {
|
||||
t.Errorf("Expected 64 subcarriers, got %d", frame.NSub)
|
||||
}
|
||||
if len(frame.Subcarriers) != 64 {
|
||||
t.Errorf("Expected 64 subcarrier values, got %d", len(frame.Subcarriers))
|
||||
}
|
||||
|
||||
// Verify RSSI is in realistic range
|
||||
if frame.RSSI < -90 || frame.RSSI > -30 {
|
||||
t.Errorf("RSSI %d is outside realistic range [-90, -30]", frame.RSSI)
|
||||
}
|
||||
|
||||
// Verify noise floor
|
||||
if frame.NoiseFloor != -95 {
|
||||
t.Errorf("Expected noise floor -95, got %d", frame.NoiseFloor)
|
||||
}
|
||||
|
||||
// Verify channel
|
||||
if frame.Channel < 1 || frame.Channel > 14 {
|
||||
t.Errorf("Channel %d is invalid", frame.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCSIFrames(t *testing.T) {
|
||||
pm := NewPropagationModel(DefaultSpace())
|
||||
|
||||
nodes := NewNodeSet()
|
||||
nodes.AddVirtualNode("tx", "TX", NewPoint(0, 0, 2))
|
||||
nodes.AddVirtualNode("rx", "RX", NewPoint(5, 0, 2))
|
||||
|
||||
links := GenerateAllLinks(nodes)
|
||||
if len(links) == 0 {
|
||||
t.Fatal("Expected at least one link")
|
||||
}
|
||||
|
||||
walker := NewPoint(2.5, 0, 1.7)
|
||||
|
||||
frames := pm.GenerateCSIFrames(links[0], walker, 10, 20)
|
||||
|
||||
if len(frames) != 10 {
|
||||
t.Errorf("Expected 10 frames, got %d", len(frames))
|
||||
}
|
||||
|
||||
// Verify timestamps are monotonically increasing
|
||||
for i := 1; i < len(frames); i++ {
|
||||
if frames[i].TimestampUs <= frames[i-1].TimestampUs {
|
||||
t.Errorf("Frame %d timestamp %d <= frame %d timestamp %d",
|
||||
i, frames[i].TimestampUs, i-1, frames[i-1].TimestampUs)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify interval is correct (50μs at 20Hz)
|
||||
expectedInterval := uint64(1000000 / 20)
|
||||
for i := 1; i < len(frames); i++ {
|
||||
actualInterval := frames[i].TimestampUs - frames[i-1].TimestampUs
|
||||
if actualInterval != expectedInterval {
|
||||
t.Errorf("Frame %d interval is %d, expected %d", i, actualInterval, expectedInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeLinkMetrics(t *testing.T) {
|
||||
pm := NewPropagationModel(DefaultSpace())
|
||||
|
||||
nodes := NewNodeSet()
|
||||
nodes.AddVirtualNode("tx", "TX", NewPoint(0, 0, 2))
|
||||
nodes.AddVirtualNode("rx", "RX", NewPoint(5, 0, 2))
|
||||
|
||||
links := GenerateAllLinks(nodes)
|
||||
if len(links) == 0 {
|
||||
t.Fatal("Expected at least one link")
|
||||
}
|
||||
|
||||
// Create walker positions along a path
|
||||
positions := []Point{
|
||||
NewPoint(1, 0, 1.7),
|
||||
NewPoint(2, 0, 1.7),
|
||||
NewPoint(3, 0, 1.7),
|
||||
NewPoint(4, 0, 1.7),
|
||||
}
|
||||
|
||||
metrics := pm.ComputeLinkMetrics(links[0], positions, 100)
|
||||
|
||||
// Verify metrics are in valid ranges
|
||||
if metrics.AvgRSSI < -90 || metrics.AvgRSSI > -20 {
|
||||
t.Errorf("AvgRSSI %f is outside realistic range", metrics.AvgRSSI)
|
||||
}
|
||||
if metrics.RSSIStdDev < 0 {
|
||||
t.Errorf("RSSIStdDev %f is negative", metrics.RSSIStdDev)
|
||||
}
|
||||
if metrics.AvgDeltaRMS < 0 {
|
||||
t.Errorf("AvgDeltaRMS %f is negative", metrics.AvgDeltaRMS)
|
||||
}
|
||||
if metrics.PacketDelivery < 0 || metrics.PacketDelivery > 1 {
|
||||
t.Errorf("PacketDelivery %f is outside [0, 1] range", metrics.PacketDelivery)
|
||||
}
|
||||
if metrics.LinkQuality < 0 || metrics.LinkQuality > 1 {
|
||||
t.Errorf("LinkQuality %f is outside [0, 1] range", metrics.LinkQuality)
|
||||
}
|
||||
|
||||
// Link with walker in middle should have good deltaRMS
|
||||
if metrics.AvgDeltaRMS < 0.01 {
|
||||
t.Errorf("AvgDeltaRMS %f seems too low for walker in middle of link", metrics.AvgDeltaRMS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue