spaxel/mothership/internal/simulator/node.go
jedarden bf0f5b4b54 test(simulator): fix tests to match current API
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>
2026-05-05 20:27:22 -04:00

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
}