Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Ran gofmt across the entire mothership codebase to ensure consistent code formatting per Go standards. All tests pass after formatting.
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
|
|
}
|