Implement ambient display mode for wall-mounted tablets with: - Canvas 2D renderer (ambient_renderer.js) with 2 Hz render rate - Time-of-day palette transitions (morning/day/evening/night) - Zone outlines, portal lines, node positions, person blobs - Lerp-interpolated smooth movement (20% factor per frame) - Auto-dim after 60s of no presence in ambient zone - Alert mode with pulsing red background and acknowledge button - Morning briefing overlay (15s display after 6am) - System status indicator and time display Files: - dashboard/js/ambient_renderer.js: Canvas 2D rendering engine - dashboard/js/ambient_briefing.js: Morning briefing overlay - dashboard/js/ambient.test.js: Test suite - dashboard/css/notifications.css: Notification styles - dashboard/css/simulator.css: Simulator styles - dashboard/js/notifications.js: Notification handling - dashboard/js/simplemode.js: Simple mode logic - dashboard/simple.html: Simple mode page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
942 lines
24 KiB
Go
942 lines
24 KiB
Go
package simulator
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
func TestPathLoss(t *testing.T) {
|
|
pm := NewPropagationModel(DefaultSpace())
|
|
|
|
tests := []struct {
|
|
distance float64
|
|
expected float64 // Approximate expected path loss
|
|
}{
|
|
{1.0, 40.0}, // At reference distance
|
|
{2.0, 46.0}, // 2x distance = +6 dB
|
|
{10.0, 60.0}, // 10x distance = +20 dB
|
|
{100.0, 80.0}, // 100x distance = +40 dB
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(fmt.Sprintf("distance=%.1f", tt.distance), func(t *testing.T) {
|
|
loss := pm.PathLoss(tt.distance)
|
|
// Allow small floating point error
|
|
if math.Abs(loss-tt.expected) > 1.0 {
|
|
t.Errorf("Distance %f: expected loss ~%f dB, got %f dB", tt.distance, tt.expected, loss)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWallLoss(t *testing.T) {
|
|
space := &Space{
|
|
Walls: []WallSegment{
|
|
{
|
|
ID: "wall-1",
|
|
Material: MaterialDrywall,
|
|
P1: NewPoint(2, 0, 0),
|
|
P2: NewPoint(2, 10, 0),
|
|
Height: 2.5,
|
|
},
|
|
},
|
|
}
|
|
pm := NewPropagationModel(space)
|
|
|
|
tests := []struct {
|
|
name string
|
|
from, to Point
|
|
expected float64
|
|
}{
|
|
{
|
|
name: "no wall intersection",
|
|
from: NewPoint(0, 5, 1),
|
|
to: NewPoint(1, 5, 1),
|
|
expected: 0,
|
|
},
|
|
{
|
|
name: "crosses wall",
|
|
from: NewPoint(0, 5, 1),
|
|
to: NewPoint(5, 5, 1),
|
|
expected: 3.0, // Drywall loss
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
loss := pm.WallLoss(tt.from, tt.to)
|
|
if loss != tt.expected {
|
|
t.Errorf("Expected loss %f, got %f", tt.expected, loss)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReceivedPower(t *testing.T) {
|
|
pm := NewPropagationModel(DefaultSpace())
|
|
|
|
tx := NewPoint(0, 0, 2)
|
|
rx := NewPoint(5, 0, 2)
|
|
txPower := 20.0 // dBm
|
|
|
|
power := pm.ReceivedPower(tx, rx, txPower)
|
|
|
|
// Power should be less than TX power
|
|
if power > txPower {
|
|
t.Errorf("Received power %f dBm should be less than TX power %f dBm", power, txPower)
|
|
}
|
|
|
|
// Power should be reasonable (not too weak, not negative infinity)
|
|
if power < -100 || power > txPower {
|
|
t.Errorf("Received power %f dBm is out of reasonable range", power)
|
|
}
|
|
}
|
|
|
|
func TestAmplitudeAt(t *testing.T) {
|
|
pm := NewPropagationModel(DefaultSpace())
|
|
|
|
tx := NewPoint(0, 0, 2)
|
|
rx := NewPoint(5, 0, 2)
|
|
walker := NewPoint(2.5, 0, 1.7) // Midpoint
|
|
|
|
amp := pm.AmplitudeAt(tx, rx, walker)
|
|
|
|
// Amplitude should be positive and reasonable
|
|
if amp < 0 || amp > 10 {
|
|
t.Errorf("Amplitude %f is out of reasonable range", amp)
|
|
}
|
|
}
|
|
|
|
func TestPhaseAt(t *testing.T) {
|
|
pm := NewPropagationModel(DefaultSpace())
|
|
|
|
tx := NewPoint(0, 0, 2)
|
|
rx := NewPoint(5, 0, 2)
|
|
walker := NewPoint(2.5, 0, 1.7)
|
|
|
|
// Test multiple subcarriers
|
|
for k := 0; k < 10; k++ {
|
|
phase := pm.PhaseAt(tx, rx, walker, k)
|
|
|
|
// Phase should be in [-π, π]
|
|
if phase < -math.Pi || phase > math.Pi {
|
|
t.Errorf("Subcarrier %d: phase %f is outside [-π, π]", k, phase)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeltaRMS(t *testing.T) {
|
|
pm := NewPropagationModel(DefaultSpace())
|
|
|
|
tx := NewPoint(0, 0, 2)
|
|
rx := NewPoint(5, 0, 2)
|
|
walker := NewPoint(2.5, 0, 1.7)
|
|
|
|
baseline := pm.AmplitudeAt(tx, rx, NewPoint(-1000, -1000, 0))
|
|
deltaRMS := pm.DeltaRMS(tx, rx, walker, baseline)
|
|
|
|
// DeltaRMS should be positive
|
|
if deltaRMS < 0 {
|
|
t.Errorf("DeltaRMS %f should be non-negative", deltaRMS)
|
|
}
|
|
|
|
// Walker at midpoint should produce significant delta
|
|
if deltaRMS < 0.01 {
|
|
t.Errorf("DeltaRMS %f seems too low for walker at midpoint", deltaRMS)
|
|
}
|
|
}
|
|
|
|
func TestFresnelZoneNumber(t *testing.T) {
|
|
tx := NewPoint(0, 0, 2)
|
|
rx := NewPoint(6, 0, 2)
|
|
|
|
tests := []struct {
|
|
name string
|
|
point Point
|
|
expected int
|
|
}{
|
|
{
|
|
name: "on direct path (midpoint)",
|
|
point: NewPoint(3, 0, 2),
|
|
expected: 1, // Zone 1
|
|
},
|
|
{
|
|
name: "at TX",
|
|
point: NewPoint(0, 0, 2),
|
|
expected: 1, // Zone 1
|
|
},
|
|
{
|
|
name: "at RX",
|
|
point: NewPoint(6, 0, 2),
|
|
expected: 1, // Zone 1
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
zone := FresnelZoneNumber(tx, rx, tt.point)
|
|
if zone != tt.expected {
|
|
t.Errorf("Expected zone %d, got %d", tt.expected, zone)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsInFirstFresnelZone(t *testing.T) {
|
|
tx := NewPoint(0, 0, 2)
|
|
rx := NewPoint(6, 0, 2)
|
|
|
|
// Points on direct path should be in first Fresnel zone
|
|
midpoint := NewPoint(3, 0, 2)
|
|
if !IsInFirstFresnelZone(tx, rx, midpoint) {
|
|
t.Error("Midpoint should be in first Fresnel zone")
|
|
}
|
|
|
|
// Points far from direct path should not be
|
|
farPoint := NewPoint(3, 10, 2)
|
|
if IsInFirstFresnelZone(tx, rx, farPoint) {
|
|
t.Error("Far point from direct path should not be in first Fresnel zone")
|
|
}
|
|
}
|
|
|
|
func TestIsInFresnelZones(t *testing.T) {
|
|
tx := NewPoint(0, 0, 2)
|
|
rx := NewPoint(6, 0, 2)
|
|
midpoint := NewPoint(3, 0, 2)
|
|
|
|
// Midpoint should be in first 3 zones
|
|
if !IsInFresnelZones(tx, rx, midpoint, 3) {
|
|
t.Error("Midpoint should be in first 3 Fresnel zones")
|
|
}
|
|
|
|
// Far point should not be in first 1 zone
|
|
farPoint := NewPoint(3, 10, 2)
|
|
if IsInFresnelZones(tx, rx, farPoint, 1) {
|
|
t.Error("Far point should not be in first Fresnel zone")
|
|
}
|
|
}
|
|
|
|
func TestGenerateAllLinks(t *testing.T) {
|
|
nodes := NewNodeSet()
|
|
nodes.AddVirtualNode("node-1", "Node 1", NewPoint(0, 0, 2))
|
|
nodes.AddVirtualNode("node-2", "Node 2", NewPoint(5, 0, 2))
|
|
nodes.AddVirtualNode("node-3", "Node 3", NewPoint(2.5, 5, 2))
|
|
|
|
links := GenerateAllLinks(nodes)
|
|
|
|
// With 3 TXRX nodes, should have 6 links (each direction)
|
|
// Actually, with all nodes as TXRX, each ordered pair is a link
|
|
// Node 1 -> Node 2, Node 1 -> Node 3, Node 2 -> Node 1, Node 2 -> Node 3, Node 3 -> Node 1, Node 3 -> Node 2
|
|
expectedMinLinks := 6 // At minimum
|
|
|
|
if len(links) < expectedMinLinks {
|
|
t.Errorf("Expected at least %d links, got %d", expectedMinLinks, len(links))
|
|
}
|
|
|
|
// No self-links
|
|
for _, link := range links {
|
|
if link.TX.ID == link.RX.ID {
|
|
t.Errorf("Found self-link: %s -> %s", link.TX.ID, link.RX.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSimulateCSIData(t *testing.T) {
|
|
pm := NewPropagationModel(DefaultSpace())
|
|
|
|
nodes := NewNodeSet()
|
|
nodes.AddVirtualNode("node-1", "Node 1", NewPoint(0, 0, 2))
|
|
nodes.AddVirtualNode("node-2", "Node 2", NewPoint(5, 0, 2))
|
|
|
|
walkers := NewWalkerSet()
|
|
walkers.AddRandomWalker("walker-1", NewPoint(2.5, 0, 1.7), 1.0)
|
|
|
|
links := GenerateAllLinks(nodes)
|
|
threshold := 0.02
|
|
|
|
results := pm.SimulateCSIData(links, walkers.All(), threshold)
|
|
|
|
// Should have some active links
|
|
if len(results) == 0 {
|
|
t.Error("Expected some active links with walker present")
|
|
}
|
|
|
|
// All results should have deltaRMS >= threshold
|
|
for linkID, deltaRMS := range results {
|
|
if deltaRMS < threshold {
|
|
t.Errorf("Link %s: deltaRMS %f below threshold %f", linkID, deltaRMS, threshold)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGDOPComputer(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, // Larger cells for faster test
|
|
}
|
|
|
|
gc := NewGDOPComputer(links, config)
|
|
|
|
// Should compute without error
|
|
results := gc.ComputeAll()
|
|
|
|
if len(results) == 0 {
|
|
t.Error("Expected non-empty GDOP results")
|
|
}
|
|
|
|
// Check that we have reasonable grid dimensions
|
|
expectedRows := int(math.Ceil(config.Depth / config.CellSize))
|
|
expectedCols := int(math.Ceil(config.Width / config.CellSize))
|
|
|
|
if len(results) != expectedRows {
|
|
t.Errorf("Expected %d rows, got %d", expectedRows, len(results))
|
|
}
|
|
|
|
if len(results[0]) != expectedCols {
|
|
t.Errorf("Expected %d cols, got %d", expectedCols, len(results[0]))
|
|
}
|
|
}
|
|
|
|
func TestGDOPQuality(t *testing.T) {
|
|
tests := []struct {
|
|
gdop float64
|
|
quality string
|
|
}{
|
|
{1.0, "excellent"},
|
|
{2.5, "good"},
|
|
{5.0, "fair"},
|
|
{10.0, "poor"},
|
|
{math.Inf(1), "none"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.quality, func(t *testing.T) {
|
|
quality := gdopToQuality(tt.gdop)
|
|
if quality != tt.quality {
|
|
t.Errorf("GDOP %f: expected quality '%s', got '%s'", tt.gdop, tt.quality, quality)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCoverageScore(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()
|
|
|
|
score := gc.CoverageScore(results)
|
|
|
|
// Score should be between 0 and 100
|
|
if score < 0 || score > 100 {
|
|
t.Errorf("Coverage score %f is outside [0, 100] range", score)
|
|
}
|
|
|
|
// With 4 nodes at corners, should have reasonable coverage
|
|
if score < 10 {
|
|
t.Errorf("Coverage score %f seems too low for 4 corner nodes", score)
|
|
}
|
|
}
|
|
|
|
func TestAverageGDOP(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()
|
|
|
|
avgGDOP := gc.AverageGDOP(results)
|
|
|
|
// Average GDOP should be finite and reasonable
|
|
if math.IsInf(avgGDOP, 0) {
|
|
t.Error("Average GDOP is infinite - no coverage?")
|
|
}
|
|
|
|
if avgGDOP < 0 {
|
|
t.Errorf("Average GDOP %f is negative", avgGDOP)
|
|
}
|
|
}
|
|
|
|
func TestQualityCounts(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()
|
|
|
|
counts := gc.QualityCounts(results)
|
|
|
|
// Should have all quality categories
|
|
qualities := []string{"excellent", "good", "fair", "poor", "none"}
|
|
totalCells := 0
|
|
|
|
for _, quality := range qualities {
|
|
count, exists := counts[quality]
|
|
if !exists {
|
|
t.Errorf("Missing quality category: %s", quality)
|
|
}
|
|
totalCells += count
|
|
}
|
|
|
|
// Total cells should match grid size
|
|
expectedRows := int(math.Ceil(config.Depth / config.CellSize))
|
|
expectedCols := int(math.Ceil(config.Width / config.CellSize))
|
|
expectedTotal := expectedRows * expectedCols
|
|
|
|
if totalCells != expectedTotal {
|
|
t.Errorf("Total cells %d doesn't match grid size %d", totalCells, expectedTotal)
|
|
}
|
|
}
|
|
|
|
func TestMinimumNodeCount(t *testing.T) {
|
|
space := DefaultSpace()
|
|
|
|
// Test different GDOP targets
|
|
tests := []struct {
|
|
targetGDOP float64
|
|
minNodes int
|
|
}{
|
|
{2.0, 1}, // Excellent coverage
|
|
{4.0, 1}, // Good coverage
|
|
{8.0, 1}, // Fair coverage
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(fmt.Sprintf("targetGDOP=%.1f", tt.targetGDOP), func(t *testing.T) {
|
|
count := MinimumNodeCount(space, tt.targetGDOP)
|
|
if count < tt.minNodes {
|
|
t.Errorf("Expected at least %d nodes, got %d", tt.minNodes, count)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpectedAccuracy(t *testing.T) {
|
|
tests := []struct {
|
|
gdop float64
|
|
minAccuracy float64
|
|
maxAccuracy float64
|
|
}{
|
|
{1.0, 0.4, 0.6}, // GDOP 1: ~0.5m
|
|
{2.0, 0.8, 1.2}, // GDOP 2: ~1.0m
|
|
{4.0, 1.6, 2.4}, // GDOP 4: ~2.0m
|
|
{math.Inf(1), -1, -1}, // Infinity: no accuracy
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(fmt.Sprintf("gdop=%.1f", tt.gdop), func(t *testing.T) {
|
|
accuracy := ExpectedAccuracy(tt.gdop)
|
|
|
|
if math.IsInf(tt.gdop, 0) {
|
|
if !math.IsInf(accuracy, 0) {
|
|
t.Errorf("Infinite GDOP should give infinite accuracy, got %f", accuracy)
|
|
}
|
|
return
|
|
}
|
|
|
|
if accuracy < tt.minAccuracy || accuracy > tt.maxAccuracy {
|
|
t.Errorf("GDOP %f: accuracy %f outside expected range [%f, %f]",
|
|
tt.gdop, accuracy, tt.minAccuracy, tt.maxAccuracy)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCornerPositions(t *testing.T) {
|
|
space := DefaultSpace()
|
|
positions := CornerPositions(space)
|
|
|
|
if len(positions) != 6 {
|
|
t.Errorf("Expected 6 corner positions, got %d", len(positions))
|
|
}
|
|
|
|
// All positions should be within space bounds
|
|
minX, minY, minZ, maxX, maxY, maxZ := space.Bounds()
|
|
|
|
for i, pos := range positions {
|
|
if pos.X < minX || pos.X > maxX {
|
|
t.Errorf("Position %d: X %f outside bounds [%f, %f]", i, pos.X, minX, maxX)
|
|
}
|
|
if pos.Y < minY || pos.Y > maxY {
|
|
t.Errorf("Position %d: Y %f outside bounds [%f, %f]", i, pos.Y, minY, maxY)
|
|
}
|
|
if pos.Z < minZ || pos.Z > maxZ {
|
|
t.Errorf("Position %d: Z %f outside bounds [%f, %f]", i, pos.Z, minZ, maxZ)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSuggestedNodes(t *testing.T) {
|
|
space := DefaultSpace()
|
|
nodes := SuggestedNodes(space, 4)
|
|
|
|
if nodes.Count() != 4 {
|
|
t.Errorf("Expected 4 nodes, got %d", nodes.Count())
|
|
}
|
|
|
|
// All nodes should be enabled
|
|
allNodes := nodes.All()
|
|
for _, node := range allNodes {
|
|
if !node.Enabled {
|
|
t.Errorf("Node %s should be enabled", node.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateShoppingList(t *testing.T) {
|
|
space := DefaultSpace()
|
|
nodes := SuggestedNodes(space, 4)
|
|
|
|
list := GenerateShoppingList(space, nodes)
|
|
|
|
// Should have positive values
|
|
if list.MinimumNodes < 1 {
|
|
t.Errorf("Minimum nodes %d should be at least 1", list.MinimumNodes)
|
|
}
|
|
|
|
if list.RecommendedNodes < 1 {
|
|
t.Errorf("Recommended nodes %d should be at least 1", list.RecommendedNodes)
|
|
}
|
|
|
|
if list.CoveragePercent < 0 || list.CoveragePercent > 100 {
|
|
t.Errorf("Coverage percent %f outside [0, 100] range", list.CoveragePercent)
|
|
}
|
|
|
|
if list.ExpectedAccuracy < 0 {
|
|
t.Errorf("Expected accuracy %f should be non-negative", list.ExpectedAccuracy)
|
|
}
|
|
|
|
if len(list.OptimalPositions) != nodes.Count() {
|
|
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)
|
|
}
|
|
}
|