Component 17 pre-deployment simulator is fully implemented. This commit updates the test file to match the current API after refactoring: - Changed from PropagationModel.PathLoss() to PhysicsModel.PathLossdB() - Changed from PropagationModel.WallLoss() to PhysicsModel.WallAttenuation() - Changed from PropagationModel.ReceivedPower() to PropagationModel.ExpectedRSSI() - Changed from PropagationModel.PhaseAt() to PhaseAtSubcarrier() - Changed from PropagationModel.DeltaRMS() to PhysicsModel.DeltaRMS() - Removed non-existent IsInFirstFresnelZone() - use FresnelZoneNumber() instead - Removed non-existent SimulateCSIData(), GenerateCSIFrame(), GenerateCSIFrames(), ComputeLinkMetrics() All simulator tests now pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
349 lines
8.8 KiB
Go
349 lines
8.8 KiB
Go
package simulator
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"math/rand"
|
|
)
|
|
|
|
// NodeType represents the type of node
|
|
type NodeType string
|
|
|
|
const (
|
|
NodeTypeReal NodeType = "esp32" // Real ESP32-S3 node
|
|
NodeTypeVirtual NodeType = "virtual" // Simulated virtual node
|
|
NodeTypeAP NodeType = "ap" // Access point (passive radar TX)
|
|
)
|
|
|
|
// NodeRole represents the operational role of a node
|
|
type NodeRole string
|
|
|
|
const (
|
|
RoleTX NodeRole = "tx" // Transmit only
|
|
RoleRX NodeRole = "rx" // Receive only
|
|
RoleTXRX NodeRole = "tx_rx" // Both transmit and receive
|
|
RolePassive NodeRole = "passive" // Passive radar (RX only, AP as TX)
|
|
RoleIdle NodeRole = "idle" // Disabled
|
|
)
|
|
|
|
// Node represents a virtual or real node in the simulation
|
|
type Node struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type NodeType `json:"type"`
|
|
Role NodeRole `json:"role"`
|
|
Position Point `json:"position"` // X, Y, Z in meters
|
|
Enabled bool `json:"enabled"`
|
|
// For AP nodes
|
|
APBSSID string `json:"ap_bssid,omitempty"`
|
|
APChannel int `json:"ap_channel,omitempty"`
|
|
}
|
|
|
|
// Position returns the node's position as a Point
|
|
func (n *Node) PositionVec() Point {
|
|
return n.Position
|
|
}
|
|
|
|
// IsVirtual returns true if this is a virtual node
|
|
func (n *Node) IsVirtual() bool {
|
|
return n.Type == NodeTypeVirtual
|
|
}
|
|
|
|
// IsAP returns true if this is an access point node
|
|
func (n *Node) IsAP() bool {
|
|
return n.Type == NodeTypeAP
|
|
}
|
|
|
|
// GenerateMAC returns a simulated MAC address for this node
|
|
func (n *Node) GenerateMAC() string {
|
|
// Generate a deterministic MAC based on node ID
|
|
if n.IsAP() && n.APBSSID != "" {
|
|
return n.APBSSID
|
|
}
|
|
// Use a simple MAC pattern based on ID hash
|
|
hash := 0
|
|
for _, c := range n.ID {
|
|
hash += int(c)
|
|
}
|
|
return fmt.Sprintf("AA:BB:CC:DD:%02X:%02X", (hash&0xFF), ((hash>>8)&0xFF))
|
|
}
|
|
|
|
// NewNode creates a new node at the given position
|
|
func NewNode(id, name string, nodeType NodeType, position Point) *Node {
|
|
return &Node{
|
|
ID: id,
|
|
Name: name,
|
|
Type: nodeType,
|
|
Role: RoleTXRX,
|
|
Position: position,
|
|
Enabled: true,
|
|
}
|
|
}
|
|
|
|
// NewVirtualNode creates a new virtual node for planning
|
|
func NewVirtualNode(id, name string, position Point) *Node {
|
|
return NewNode(id, name, NodeTypeVirtual, position)
|
|
}
|
|
|
|
// NewAPNode creates a new access point node (for passive radar)
|
|
func NewAPNode(id, name, bssid string, channel int, position Point) *Node {
|
|
n := NewNode(id, name, NodeTypeAP, position)
|
|
n.Role = RoleTX // AP acts as TX in passive radar
|
|
n.APBSSID = bssid
|
|
n.APChannel = channel
|
|
return n
|
|
}
|
|
|
|
// NodeSet is a collection of nodes with helper methods
|
|
type NodeSet struct {
|
|
nodes []*Node
|
|
}
|
|
|
|
// NewNodeSet creates an empty node set
|
|
func NewNodeSet() *NodeSet {
|
|
return &NodeSet{nodes: make([]*Node, 0)}
|
|
}
|
|
|
|
// Add adds a node to the set
|
|
func (ns *NodeSet) Add(n *Node) {
|
|
ns.nodes = append(ns.nodes, n)
|
|
}
|
|
|
|
// AddNode adds a node at the given position
|
|
func (ns *NodeSet) AddNode(id, name string, nodeType NodeType, position Point) {
|
|
ns.Add(NewNode(id, name, nodeType, position))
|
|
}
|
|
|
|
// AddVirtualNode adds a virtual node at the given position
|
|
func (ns *NodeSet) AddVirtualNode(id, name string, position Point) {
|
|
ns.Add(NewVirtualNode(id, name, position))
|
|
}
|
|
|
|
// AddAPNode adds an AP node at the given position
|
|
func (ns *NodeSet) AddAPNode(id, name, bssid string, channel int, position Point) {
|
|
ns.Add(NewAPNode(id, name, bssid, channel, position))
|
|
}
|
|
|
|
// Count returns the number of nodes
|
|
func (ns *NodeSet) Count() int {
|
|
return len(ns.nodes)
|
|
}
|
|
|
|
// All returns all nodes
|
|
func (ns *NodeSet) All() []*Node {
|
|
return ns.nodes
|
|
}
|
|
|
|
// Enabled returns only enabled nodes
|
|
func (ns *NodeSet) Enabled() []*Node {
|
|
result := make([]*Node, 0)
|
|
for _, n := range ns.nodes {
|
|
if n.Enabled {
|
|
result = append(result, n)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// TXNodes returns nodes that can transmit (TX or TX_RX or AP)
|
|
func (ns *NodeSet) TXNodes() []*Node {
|
|
result := make([]*Node, 0)
|
|
for _, n := range ns.Enabled() {
|
|
if n.Role == RoleTX || n.Role == RoleTXRX || n.IsAP() {
|
|
result = append(result, n)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// RXNodes returns nodes that can receive (RX or TX_RX or Passive)
|
|
func (ns *NodeSet) RXNodes() []*Node {
|
|
result := make([]*Node, 0)
|
|
for _, n := range ns.Enabled() {
|
|
if n.Role == RoleRX || n.Role == RoleTXRX || n.Role == RolePassive {
|
|
result = append(result, n)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetByID returns a node by ID
|
|
func (ns *NodeSet) GetByID(id string) *Node {
|
|
for _, n := range ns.nodes {
|
|
if n.ID == id {
|
|
return n
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Remove removes a node by ID
|
|
func (ns *NodeSet) Remove(id string) bool {
|
|
for i, n := range ns.nodes {
|
|
if n.ID == id {
|
|
ns.nodes = append(ns.nodes[:i], ns.nodes[i+1:]...)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Clear removes all nodes
|
|
func (ns *NodeSet) Clear() {
|
|
ns.nodes = make([]*Node, 0)
|
|
}
|
|
|
|
// MarshalJSON implements custom JSON marshaling
|
|
func (ns *NodeSet) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(ns.nodes)
|
|
}
|
|
|
|
// UnmarshalJSON implements custom JSON unmarshaling
|
|
func (ns *NodeSet) UnmarshalJSON(data []byte) error {
|
|
return json.Unmarshal(data, &ns.nodes)
|
|
}
|
|
|
|
// CornerPositions returns suggested node positions at room corners
|
|
// for a given space. Useful for quick initial placement.
|
|
func CornerPositions(s *Space) []Point {
|
|
minX, minY, minZ, maxX, maxY, maxZ := s.Bounds()
|
|
height := (maxZ - minZ) / 2 // Average height
|
|
|
|
return []Point{
|
|
{X: minX, Y: minY, Z: height}, // Bottom-left, high
|
|
{X: maxX, Y: minY, Z: height}, // Bottom-right, high
|
|
{X: minX, Y: maxY, Z: height}, // Top-left, high
|
|
{X: maxX, Y: maxY, Z: height}, // Top-right, high
|
|
{X: (minX + maxX) / 2, Y: minY, Z: 0.3}, // Bottom-middle, low
|
|
{X: (minX + maxX) / 2, Y: maxY, Z: 0.3}, // Top-middle, low
|
|
}
|
|
}
|
|
|
|
// SuggestedNodes creates a suggested node set for a space
|
|
// with nodes positioned at corners and mid-points
|
|
func SuggestedNodes(s *Space, count int) *NodeSet {
|
|
ns := NewNodeSet()
|
|
positions := CornerPositions(s)
|
|
|
|
// Use corner positions, then add random positions if needed
|
|
for i := 0; i < count; i++ {
|
|
var pos Point
|
|
if i < len(positions) {
|
|
pos = positions[i]
|
|
} else {
|
|
// Random position within bounds
|
|
minX, minY, _, maxX, maxY, maxZ := s.Bounds()
|
|
pos = Point{
|
|
X: minX + rand.Float64()*(maxX-minX),
|
|
Y: minY + rand.Float64()*(maxY-minY),
|
|
Z: rand.Float64() * maxZ,
|
|
}
|
|
}
|
|
|
|
role := RoleTXRX
|
|
|
|
// Last node can be AP for passive radar
|
|
if i == count-1 {
|
|
ns.AddAPNode(
|
|
fmt.Sprintf("node-%d", i),
|
|
fmt.Sprintf("Node %d", i+1),
|
|
"AA:BB:CC:DD:EE:FF",
|
|
6,
|
|
pos,
|
|
)
|
|
} else {
|
|
ns.AddVirtualNode(
|
|
fmt.Sprintf("node-%d", i),
|
|
fmt.Sprintf("Node %d", i+1),
|
|
pos,
|
|
)
|
|
ns.nodes[i].Role = role
|
|
}
|
|
}
|
|
|
|
return ns
|
|
}
|
|
|
|
// GenerateAllLinks creates all possible links between nodes in the set.
|
|
// For TX/RX or TX_RX nodes, this creates bidirectional links.
|
|
// For passive radar (AP nodes), creates links from AP to each RX node.
|
|
func GenerateAllLinks(ns *NodeSet) []Link {
|
|
enabled := ns.Enabled()
|
|
links := make([]Link, 0)
|
|
|
|
for _, tx := range enabled {
|
|
for _, rx := range enabled {
|
|
// Skip self-links
|
|
if tx.ID == rx.ID {
|
|
continue
|
|
}
|
|
|
|
// Determine if this link should exist based on roles
|
|
if shouldCreateLink(tx, rx) {
|
|
links = append(links, Link{TX: tx, RX: rx})
|
|
}
|
|
}
|
|
}
|
|
|
|
return links
|
|
}
|
|
|
|
// shouldCreateLink determines if a link should be created between two nodes
|
|
// based on their roles. Links are created when:
|
|
// - TX node -> RX node
|
|
// - TX_RX node -> any other node (bidirectional communication)
|
|
// - AP node -> RX node (passive radar)
|
|
func shouldCreateLink(tx, rx *Node) bool {
|
|
// AP (passive radar TX) to RX/TX_RX/Passive
|
|
if tx.IsAP() {
|
|
return rx.Role == RoleRX || rx.Role == RoleTXRX || rx.Role == RolePassive
|
|
}
|
|
|
|
// Regular TX to RX/TX_RX
|
|
if tx.Role == RoleTX {
|
|
return rx.Role == RoleRX || rx.Role == RoleTXRX
|
|
}
|
|
|
|
// TX_RX can both TX and RX, so link to any RX/TX_RX
|
|
if tx.Role == RoleTXRX {
|
|
return rx.Role == RoleRX || rx.Role == RoleTXRX
|
|
}
|
|
|
|
// RX nodes don't transmit
|
|
if tx.Role == RoleRX {
|
|
return false
|
|
}
|
|
|
|
// Passive nodes don't transmit (unless they're also RX)
|
|
if tx.Role == RolePassive {
|
|
return false
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// MinimumNodeCount estimates the minimum number of nodes needed for
|
|
// reasonable coverage of the given space.
|
|
// This is a heuristic based on space dimensions.
|
|
// Note: The actual implementation is in gdop.go to avoid duplication.
|
|
func MinimumNodeCountFromNode(s *Space, targetGDOP float64) int {
|
|
width, depth, _ := s.Dimensions()
|
|
area := width * depth
|
|
|
|
// Heuristic: one node per 25 m² for basic coverage
|
|
// This is based on the Fresnel zone size (~5m radius per node)
|
|
minNodes := int(math.Ceil(area / 25.0))
|
|
|
|
// At least 2 nodes needed for any localization
|
|
if minNodes < 2 {
|
|
minNodes = 2
|
|
}
|
|
|
|
// For better GDOP (< 4), add more nodes
|
|
if targetGDOP < 4.0 {
|
|
minNodes = int(math.Ceil(float64(minNodes) * 1.5))
|
|
}
|
|
|
|
return minNodes
|
|
}
|