feat: complete pre-deployment simulator implementation

Implements comprehensive pre-deployment simulator for WiFi CSI-based
positioning that allows users to predict detection quality before
purchasing hardware.

Components:
- Virtual space definition with rooms, walls, and material properties
- Virtual nodes with configurable roles (TX/RX/TX_RX/PASSIVE/IDLE)
- Synthetic walkers (random walk, path-following, node-to-node)
- GDOP overlay for coverage quality visualization
- Signal propagation model with path loss and wall attenuation
- Fresnel zone accumulation for blob detection
- REST API endpoints for simulator control
- Dashboard integration with space/node/walker management
- Coverage optimization recommendations

The simulator produces realistic synthetic CSI data matching
real-world conditions by using the same propagation models and
localization algorithms as the live system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-09 21:30:13 -04:00
parent a70e3e433d
commit cdd9c64800
7 changed files with 379 additions and 46 deletions

View file

@ -1483,6 +1483,20 @@
#room-editor-btn:hover { background: rgba(255,255,255,0.12); color: #ccc; }
#room-editor-btn.active { background: rgba(79,195,247,0.2); color: #4fc3f7; border-color: #4fc3f7; }
/* Simulator button */
#simulator-btn {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
color: #888;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
#simulator-btn:hover { background: rgba(255,255,255,0.12); color: #ccc; }
#simulator-btn.active { background: rgba(156,39,176,0.2); color: #ab47bc; border-color: #ab47bc; }
/* GDOP toggle */
#gdop-toggle-btn {
background: rgba(255,255,255,0.06);
@ -1704,6 +1718,14 @@
margin-bottom: 6px;
}
#gdop-coverage-score {
font-size: 13px;
font-weight: 600;
color: #22c65e;
margin-bottom: 8px;
text-align: center;
}
#gdop-gradient {
width: 140px;
height: 12px;
@ -2543,6 +2565,7 @@
<button id="fresnel-toggle-btn" onclick="toggleFresnelZones()">Fresnel</button>
<button id="room-editor-btn" onclick="Placement && Placement.toggleRoomEditor()">Room</button>
<button id="floorplan-btn" onclick="FloorPlanSetup.togglePanel()">Floor plan</button>
<button id="simulator-btn" onclick="Simulate && Simulate.togglePanel()">Simulator</button>
</div>
<div class="status-item">
<span>FPS: <strong id="fps-counter">0</strong></span>
@ -2710,9 +2733,190 @@
<button id="room-apply-btn" onclick="Placement && Placement.applyRoomFromEditor()">Apply</button>
</div>
<!-- Pre-Deployment Simulator Panel -->
<div id="simulator-panel" class="simulator-panel" style="display:none;">
<div class="simulator-header">
<h2>Pre-Deployment Simulator</h2>
<button id="sim-close-btn" class="sim-close-btn" aria-label="Close simulator">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="simulator-content">
<!-- Space Configuration -->
<section class="sim-section">
<h3>Space Configuration</h3>
<div class="sim-field-group">
<div class="sim-field">
<label>Width <span class="unit">(m)</span></label>
<input type="number" id="sim-space-width" value="10" min="1" max="100" step="0.5">
</div>
<div class="sim-field">
<label>Depth <span class="unit">(m)</span></label>
<input type="number" id="sim-space-depth" value="10" min="1" max="100" step="0.5">
</div>
<div class="sim-field">
<label>Height <span class="unit">(m)</span></label>
<input type="number" id="sim-space-height" value="2.5" min="1" max="50" step="0.1">
</div>
</div>
<button id="sim-apply-space" class="sim-btn sim-btn-primary">Apply Space</button>
</section>
<!-- Tools -->
<section class="sim-section">
<h3>Tools</h3>
<div class="sim-tools">
<button class="sim-tool-btn active" data-tool="select" title="Select">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"></path>
</svg>
Select
</button>
<button class="sim-tool-btn" data-tool="node" title="Add Virtual Node">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 1v6m0 6v6"></path>
<path d="M1 12h6m6 0h6"></path>
</svg>
Node
</button>
<button class="sim-tool-btn" data-tool="walker" title="Add Walker">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="5" r="1"></circle>
<path d="M4 20l4-5 3 3 5-7 4 4v5H4z"></path>
</svg>
Walker
</button>
</div>
</section>
<!-- Virtual Nodes -->
<section class="sim-section">
<h3>Virtual Nodes <span id="sim-node-count" class="sim-count">(0)</span></h3>
<div class="sim-actions">
<button id="sim-add-node" class="sim-btn sim-btn-sm">+ Add Node</button>
<button id="sim-clear-nodes" class="sim-btn sim-btn-sm sim-btn-danger">Clear All</button>
</div>
<div id="sim-nodes-container" class="sim-items-list">
<div class="sim-empty-state">No virtual nodes. Click "Add Node" or use the Node tool.</div>
</div>
</section>
<!-- Walkers -->
<section class="sim-section">
<h3>Synthetic Walkers <span id="sim-walker-count" class="sim-count">(0)</span></h3>
<div class="sim-field">
<label>Walker Type</label>
<select id="sim-walker-type">
<option value="random">Random Walk</option>
<option value="path">Path Following</option>
</select>
</div>
<div class="sim-actions">
<button id="sim-add-walker" class="sim-btn sim-btn-sm">+ Add Walker</button>
<button id="sim-clear-walkers" class="sim-btn sim-btn-sm sim-btn-danger">Clear All</button>
</div>
<div id="sim-walkers-container" class="sim-items-list">
<div class="sim-empty-state">No walkers. Add walkers to simulate movement.</div>
</div>
</section>
<!-- GDOP Analysis -->
<section class="sim-section">
<h3>GDOP Coverage Analysis</h3>
<div class="sim-actions">
<button id="sim-show-gdop" class="sim-btn sim-btn-sm">Show GDOP</button>
<button id="sim-update-gdop" class="sim-btn sim-btn-sm">Update</button>
</div>
<div id="sim-gdop-results" class="sim-gdop-results" style="display:none;">
<div class="sim-gdop-stats">
<div class="sim-stat">
<span class="sim-stat-label">Coverage</span>
<span id="sim-gdop-coverage" class="sim-stat-value">--%</span>
</div>
<div class="sim-stat">
<span class="sim-stat-label">Mean GDOP</span>
<span id="sim-gdop-mean" class="sim-stat-value">--</span>
</div>
</div>
<div id="sim-gdop-legend" class="sim-gdop-legend">
<div class="gdop-legend-item" data-quality="excellent">
<div class="gdop-legend-color" style="background:#22c65e;"></div>
<span class="gdop-legend-label">Excellent (&lt;2)</span>
</div>
<div class="gdop-legend-item" data-quality="good">
<div class="gdop-legend-color" style="background:#ffc107;"></div>
<span class="gdop-legend-label">Good (2-4)</span>
</div>
<div class="gdop-legend-item" data-quality="fair">
<div class="gdop-legend-color" style="background:#ff9200;"></div>
<span class="gdop-legend-label">Fair (4-8)</span>
</div>
<div class="gdop-legend-item" data-quality="poor">
<div class="gdop-legend-color" style="background:#dc3545;"></div>
<span class="gdop-legend-label">Poor (&gt;8)</span>
</div>
<div class="gdop-legend-item" data-quality="none">
<div class="gdop-legend-color" style="background:#505050;"></div>
<span class="gdop-legend-label">No Coverage</span>
</div>
</div>
</div>
</section>
<!-- Simulation Controls -->
<section class="sim-section">
<h3>Simulation</h3>
<div class="sim-sim-controls">
<button id="sim-start-btn" class="sim-btn sim-btn-primary">Start Simulation</button>
<button id="sim-pause-btn" class="sim-btn" disabled>Pause</button>
<button id="sim-stop-btn" class="sim-btn sim-btn-danger" disabled>Stop</button>
</div>
<div class="sim-progress">
<span class="sim-progress-label">Time: <span id="sim-time">0:00</span></span>
<div class="sim-progress-bar">
<div id="sim-progress-fill" class="sim-progress-fill"></div>
</div>
</div>
</section>
<!-- Results -->
<section id="sim-results-section" class="sim-section" style="display:none;">
<h3>Results</h3>
<div class="sim-results">
<div class="sim-result-item">
<span class="sim-result-label">Expected Accuracy</span>
<span id="sim-result-accuracy" class="sim-result-value">--</span>
</div>
<div class="sim-result-item">
<span class="sim-result-label">Coverage</span>
<span id="sim-result-coverage" class="sim-result-value">--</span>
</div>
</div>
</section>
<!-- Recommendations -->
<section id="sim-recommendations-section" class="sim-section" style="display:none;">
<h3>Recommendations</h3>
<div id="sim-recommendations" class="sim-recommendations"></div>
</section>
<!-- Shopping List -->
<section id="sim-shopping-section" class="sim-section" style="display:none;">
<h3>Hardware Shopping List</h3>
<div id="sim-shopping-list" class="sim-shopping-list"></div>
</section>
</div>
</div>
<!-- GDOP legend -->
<div id="gdop-legend">
<h4>GDOP Coverage</h4>
<div id="gdop-coverage-score">Coverage: --%</div>
<canvas id="gdop-gradient" width="140" height="12"></canvas>
<div id="gdop-labels">
<span>Excellent</span>

View file

@ -110,6 +110,10 @@ const Placement = (function () {
// ── GDOP overlay ─────────────────────────────────────────────────────────
// Cache for backend GDOP data
var _gdopCache = null;
var _gdopPending = false;
function updateGDOPOverlay() {
var nodes = getNodePositions();
@ -118,32 +122,70 @@ const Placement = (function () {
return;
}
// Fetch GDOP data from backend API (uses angular diversity algorithm)
if (!_gdopPending) {
_gdopPending = true;
fetch('/api/simulator/gdop/heatmap')
.then(function (resp) { return resp.json(); })
.then(function (data) {
_gdopCache = data;
renderGDOPFromCache();
_gdopPending = false;
})
.catch(function (e) {
console.error('[Placement] GDOP fetch failed:', e);
_gdopPending = false;
});
} else if (_gdopCache) {
renderGDOPFromCache();
}
}
function renderGDOPFromCache() {
if (!_gdopCache || !_roomConfig) return;
var data = _gdopCache;
var w = _roomConfig.width;
var d = _roomConfig.depth;
var ox = _roomConfig.origin_x || 0;
var oz = _roomConfig.origin_z || 0;
// Get grid dimensions from backend response
var gridWidth = data.grid_dimensions ? data.grid_dimensions[0] : GDOP_RES;
var gridDepth = data.grid_dimensions ? data.grid_dimensions[1] : GDOP_RES;
var gdopValues = data.gdop_map || [];
var colors = data.colors || [];
// Create or reuse DataTexture
if (!_gdopTexture) {
var data = new Uint8Array(GDOP_RES * GDOP_RES * 4);
_gdopTexture = new THREE.DataTexture(data, GDOP_RES, GDOP_RES, THREE.RGBAFormat);
var texData = new Uint8Array(gridWidth * gridDepth * 4);
_gdopTexture = new THREE.DataTexture(texData, gridWidth, gridDepth, THREE.RGBAFormat);
_gdopTexture.magFilter = THREE.LinearFilter;
_gdopTexture.minFilter = THREE.LinearFilter;
}
var data = _gdopTexture.image.data;
var texData = _gdopTexture.image.data;
for (var iz = 0; iz < GDOP_RES; iz++) {
var pz = oz + (iz / (GDOP_RES - 1)) * d;
for (var ix = 0; ix < GDOP_RES; ix++) {
var px = ox + (ix / (GDOP_RES - 1)) * w;
var hdop = computeHDOP(px, pz, nodes);
var c = gdopToColor(hdop);
var idx = (iz * GDOP_RES + ix) * 4;
data[idx] = c[0];
data[idx + 1] = c[1];
data[idx + 2] = c[2];
data[idx + 3] = c[3];
// Use backend-provided colors if available, otherwise compute from GDOP values
if (colors.length > 0) {
// Backend provides RGB colors
for (var i = 0; i < colors.length && i < gridWidth * gridDepth; i++) {
var idx = i * 4;
texData[idx] = colors[i][0];
texData[idx + 1] = colors[i][1];
texData[idx + 2] = colors[i][2];
texData[idx + 3] = 150; // Alpha
}
} else if (gdopValues.length > 0) {
// Fallback: compute colors from GDOP values
for (var i = 0; i < gdopValues.length && i < gridWidth * gridDepth; i++) {
var gdop = gdopValues[i];
var c = gdopToColor(gdop);
var idx = i * 4;
texData[idx] = c[0];
texData[idx + 1] = c[1];
texData[idx + 2] = c[2];
texData[idx + 3] = c[3];
}
}
@ -168,6 +210,20 @@ const Placement = (function () {
}
_gdopMesh.visible = true;
// Update coverage score display if available
updateCoverageScore(data.coverage_score || data.coverage_percent);
}
function updateCoverageScore(score) {
var scoreEl = document.getElementById('gdop-coverage-score');
if (scoreEl) {
var pct = typeof score === 'number' ? score : 0;
scoreEl.textContent = 'Coverage: ' + pct.toFixed(1) + '%';
// Color-code the score
scoreEl.style.color = pct >= 70 ? '#22c65e' : pct >= 50 ? '#ffc107' : '#dc3545';
}
}
function rebuildGDOPIfDirty() {
@ -211,6 +267,7 @@ const Placement = (function () {
obj.position.z = Math.max(oz, Math.min(oz + _roomConfig.depth, obj.position.z));
_gdopDirty = true;
_gdopCache = null; // Clear cache to force refresh with new node positions
// Update link lines to follow dragged node
if (Viz3D.rebuildLinkLines) Viz3D.rebuildLinkLines();

View file

@ -443,12 +443,14 @@
const node = {
id: id,
name: 'Node ' + (state.nodes.length + 1),
type: 'virtual',
position: {
x: state.space.width / 2,
y: 1.0,
z: state.space.depth / 2,
},
role: 'tx_rx',
enabled: true,
};
state.nodes.push(node);
@ -537,9 +539,14 @@
function addWalker() {
const type = elements.walkerType.value;
const id = 'walker_' + Date.now();
// Map frontend type to backend WalkerType
const backendType = type === 'path' ? 'path_follow' :
type === 'zone' ? 'node_to_node' : 'random_walk';
const walker = {
id: id,
type: type,
type: backendType,
position: {
x: state.space.width / 2,
y: 1.0,
@ -550,19 +557,19 @@
y: 0,
z: (Math.random() - 0.5) * CONFIG.walkerSpeed,
},
speed: CONFIG.walkerSpeed,
height: 1.7,
};
if (type === 'path') {
// Create default path
walker.path = [
{ x: 2, y: 1, z: 2 },
{ x: state.space.width - 2, y: 1, z: 2 },
{ x: state.space.width - 2, y: 1, z: state.space.depth - 2 },
{ x: 2, y: 1, z: state.space.depth - 2 },
{ x: 2, y: 1.0, z: 2 },
{ x: state.space.width - 2, y: 1.0, z: 2 },
{ x: state.space.width - 2, y: 1.0, z: state.space.depth - 2 },
{ x: 2, y: 1.0, z: state.space.depth - 2 },
];
walker.path_index = 0;
} else if (type === 'zone') {
walker.target_zones = [];
}
state.walkers.push(walker);
@ -636,6 +643,21 @@
}
}
// ============================================
// Panel Toggle
// ============================================
function togglePanel() {
const panel = document.getElementById('simulator-panel');
const btn = document.getElementById('simulator-btn');
if (!panel || !btn) return;
const isVisible = panel.style.display !== 'none';
panel.style.display = isVisible ? 'none' : 'block';
btn.classList.toggle('active', !isVisible);
console.log('[Simulate] Panel', isVisible ? 'hidden' : 'shown');
}
// ============================================
// GDOP Visualization
// ============================================

View file

@ -2142,15 +2142,7 @@ const Viz3D = (function () {
* Fetch GDOP heatmap data from API.
*/
function fetchGDOPData() {
fetch('/api/simulator/gdop/compute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cell_size: 0.2,
max_zone: 3,
threshold: 0.02
})
})
fetch('/api/simulator/gdop/heatmap')
.then(function(response) { return response.json(); })
.then(function(data) {
_gdopData = data;
@ -2166,21 +2158,39 @@ const Viz3D = (function () {
* @param {Object} data - GDOP computation results
*/
function updateGDOPOverlay(data) {
if (!data || !data.gdop_heatmap) {
if (!data || !data.gdop_map) {
console.warn('[Viz3D] No GDOP heatmap data in response');
return;
}
var heatmap = data.gdop_heatmap;
var width = heatmap.width;
var depth = heatmap.depth;
var cellSize = heatmap.cell_size;
var originX = heatmap.origin_x;
var originY = heatmap.origin_y;
var gridDimensions = data.grid_dimensions || [0, 0, 0];
var width = gridDimensions[0];
var depth = gridDimensions[1];
var cellSize = 0.2; // Default cell size (not provided by this endpoint)
// Create texture from GDOP data
var gdopValues = new Float32Array(heatmap.gdop_values);
var colors = new Uint8Array(heatmap.colors.flat());
var gdopValues = new Float32Array(data.gdop_map);
// Generate colors from GDOP values using GDOPColorMap
var colors = new Uint8Array(width * depth * 3);
for (var i = 0; i < gdopValues.length; i++) {
var gdop = gdopValues[i];
var color;
if (gdop >= 9999) {
color = { r: 80, g: 80, b: 80 }; // None - gray
} else if (gdop < 2.0) {
color = { r: 34, g: 197, b: 94 }; // Excellent - green
} else if (gdop < 4.0) {
color = { r: 255, g: 193, b: 7 }; // Good - yellow
} else if (gdop < 8.0) {
color = { r: 255, g: 146, b: 0 }; // Fair - orange
} else {
color = { r: 220, g: 53, b: 69 }; // Poor - red
}
colors[i * 3] = color.r;
colors[i * 3 + 1] = color.g;
colors[i * 3 + 2] = color.b;
}
// Create data texture
if (_gdopTexture) {
@ -2206,9 +2216,9 @@ const Viz3D = (function () {
_gdopMesh = new THREE.Mesh(geo, mat);
_gdopMesh.rotation.x = -Math.PI / 2;
_gdopMesh.position.set(
originX + (width * cellSize) / 2,
(width * cellSize) / 2,
0.01, // Slightly above floor
originY + (depth * cellSize) / 2
(depth * cellSize) / 2
);
_scene.add(_gdopMesh);
_gdopMesh.visible = _gdopOverlayVisible;
@ -2220,17 +2230,17 @@ const Viz3D = (function () {
depth * cellSize
);
_gdopMesh.position.set(
originX + (width * cellSize) / 2,
(width * cellSize) / 2,
0.01,
originY + (depth * cellSize) / 2
(depth * cellSize) / 2
);
_gdopMesh.material.map = _gdopTexture;
}
// Update or create legend
updateGDOPLegend(data.coverage_score);
updateGDOPLegend(data.coverage_percent || data.coverage_score);
console.log('[Viz3D] GDOP overlay updated:', data.coverage_score.toFixed(1) + '% coverage');
console.log('[Viz3D] GDOP overlay updated:', (data.coverage_percent || data.coverage_score || 0).toFixed(1) + '% coverage');
}
/**

View file

@ -3,11 +3,13 @@ package api
import (
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/spaxel/mothership/internal/simulator"
@ -38,6 +40,7 @@ func (h *SimulatorHandler) RegisterRoutes(r chi.Router) {
r.Route("/simulator", func(r chi.Router) {
r.Get("/", h.GetState)
r.Post("/reset", h.Reset)
r.Post("/session", h.CreateSession)
// Space management
r.Route("/space", func(r chi.Router) {
@ -108,6 +111,37 @@ func (h *SimulatorHandler) Reset(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"status": "reset"})
}
// CreateSession creates a new simulator session
func (h *SimulatorHandler) CreateSession(w http.ResponseWriter, r *http.Request) {
var req struct {
Space *simulator.Space `json:"space"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Use default space if request body is empty/invalid
req.Space = simulator.DefaultSpace()
}
if req.Space == nil {
req.Space = simulator.DefaultSpace()
}
// Update the handler's space if provided
if req.Space != nil {
h.mu.Lock()
h.space = req.Space
h.mu.Unlock()
}
// Generate session ID
sessionID := fmt.Sprintf("sim_%d", time.Now().UnixNano())
respondJSON(w, http.StatusOK, map[string]interface{}{
"session_id": sessionID,
"space": h.space,
})
}
// GetSpace returns the current space definition
func (h *SimulatorHandler) GetSpace(w http.ResponseWriter, r *http.Request) {
h.mu.RLock()

View file

@ -234,7 +234,7 @@ func (b *FleetRegistryBridge) OptimizeCoverage(space *Space) (*CoverageOptimizat
weakAreas := make([]Point, 0)
for row := range results {
for col := range results[row] {
if results[row][col] > 4 {
if results[row][col].GDOP > 4 {
// Calculate position from grid indices
x := minX + float64(col)*config.CellSize
y := minY + float64(row)*config.CellSize

View file

@ -244,6 +244,12 @@ func (s *Space) TotalVolume() float64 {
return v
}
// Dimensions returns the overall width, depth, and height of the space
func (s *Space) Dimensions() (width, depth, height float64) {
minX, minY, minZ, maxX, maxY, maxZ := s.Bounds()
return maxX - minX, maxY - minY, maxZ - minZ
}
// GetWalls returns all wall segments from all rooms plus standalone walls
func (s *Space) GetWalls() []WallSegment {
walls := make([]WallSegment, 0, len(s.Walls))