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.
588 lines
14 KiB
Go
588 lines
14 KiB
Go
// Package fleet implements self-healing fleet management with GDOP optimization
|
|
package fleet
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// Role constants
|
|
const (
|
|
RoleTX = "tx"
|
|
RoleRX = "rx"
|
|
RoleTXRX = "tx_rx"
|
|
RolePassive = "passive"
|
|
)
|
|
|
|
// NodeCapabilities describes what a node can do
|
|
type NodeCapabilities struct {
|
|
CanTX bool
|
|
CanRX bool
|
|
HardwareType string
|
|
}
|
|
|
|
// NodeInfo combines position and capabilities for optimisation
|
|
type NodeInfo struct {
|
|
MAC string
|
|
PosX float64
|
|
PosY float64
|
|
PosZ float64
|
|
HealthScore float64 // 0-1, from ambient confidence
|
|
Capabilities NodeCapabilities
|
|
}
|
|
|
|
// RoleAssignment is the output of the optimiser
|
|
type RoleAssignment struct {
|
|
MAC string
|
|
Role string
|
|
}
|
|
|
|
// SensingLink represents a TX-RX pair for sensing
|
|
type SensingLink struct {
|
|
TXMAC string
|
|
RXMAC string
|
|
Angle float64 // Angle of the TX-RX axis in radians
|
|
}
|
|
|
|
// OptimisationConfig holds configuration for the role optimiser
|
|
type OptimisationConfig struct {
|
|
// Minimum health score for a node to be considered for active role (0-1)
|
|
MinHealthScore float64
|
|
// Maximum sensing range in metres
|
|
MaxSensingRange float64
|
|
// UseGDOP when true, use GDOP calculator if available
|
|
UseGDOP bool
|
|
}
|
|
|
|
// DefaultOptimisationConfig returns sensible defaults
|
|
func DefaultOptimisationConfig() OptimisationConfig {
|
|
return OptimisationConfig{
|
|
MinHealthScore: 0.3,
|
|
MaxSensingRange: 15.0,
|
|
UseGDOP: true,
|
|
}
|
|
}
|
|
|
|
// RoleOptimiser computes optimal role assignments to maximise coverage
|
|
type RoleOptimiser struct {
|
|
config OptimisationConfig
|
|
gdopCalc GDOPCalculator
|
|
roomConfig RoomConfig
|
|
}
|
|
|
|
// NewRoleOptimiser creates a new role optimiser
|
|
func NewRoleOptimiser(config OptimisationConfig) *RoleOptimiser {
|
|
return &RoleOptimiser{
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
// SetGDOPCalculator sets the GDOP calculator for coverage-based optimisation
|
|
func (ro *RoleOptimiser) SetGDOPCalculator(calc GDOPCalculator) {
|
|
ro.gdopCalc = calc
|
|
}
|
|
|
|
// SetRoomConfig sets the room configuration for GDOP calculations
|
|
func (ro *RoleOptimiser) SetRoomConfig(room RoomConfig) {
|
|
ro.roomConfig = room
|
|
}
|
|
|
|
// OptimiseResult contains the result of a role optimisation
|
|
type OptimiseResult struct {
|
|
Assignments []RoleAssignment
|
|
Links []SensingLink
|
|
MeanGDOP float64
|
|
CoverageScore float64
|
|
OptimisedAt time.Time
|
|
TriggerReason string
|
|
GDOPBefore []float32 // GDOP map before (if available)
|
|
GDOPAfter []float32 // GDOP map after
|
|
GDOPCols int
|
|
GDOPRows int
|
|
}
|
|
|
|
// Optimise computes the optimal role assignment for the given nodes
|
|
func (ro *RoleOptimiser) Optimise(nodes []NodeInfo, triggerReason string) *OptimiseResult {
|
|
result := &OptimiseResult{
|
|
OptimisedAt: time.Now(),
|
|
TriggerReason: triggerReason,
|
|
}
|
|
|
|
if len(nodes) == 0 {
|
|
return result
|
|
}
|
|
|
|
// Filter nodes by health score
|
|
healthyNodes := make([]NodeInfo, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
if n.HealthScore >= ro.config.MinHealthScore {
|
|
healthyNodes = append(healthyNodes, n)
|
|
}
|
|
}
|
|
|
|
if len(healthyNodes) == 0 {
|
|
// Fall back to using all nodes if none are healthy enough
|
|
healthyNodes = nodes
|
|
}
|
|
|
|
// Sort by MAC for deterministic results
|
|
sort.Slice(healthyNodes, func(i, j int) bool {
|
|
return healthyNodes[i].MAC < healthyNodes[j].MAC
|
|
})
|
|
|
|
n := len(healthyNodes)
|
|
|
|
// Special cases
|
|
switch {
|
|
case n == 1:
|
|
// Single node operates as TX-RX
|
|
result.Assignments = []RoleAssignment{
|
|
{MAC: healthyNodes[0].MAC, Role: RoleTXRX},
|
|
}
|
|
return result
|
|
|
|
case n == 2:
|
|
// Two nodes: one TX, one RX
|
|
// Choose the one with better health as TX
|
|
if healthyNodes[0].HealthScore >= healthyNodes[1].HealthScore {
|
|
result.Assignments = []RoleAssignment{
|
|
{MAC: healthyNodes[0].MAC, Role: RoleTX},
|
|
{MAC: healthyNodes[1].MAC, Role: RoleRX},
|
|
}
|
|
} else {
|
|
result.Assignments = []RoleAssignment{
|
|
{MAC: healthyNodes[1].MAC, Role: RoleTX},
|
|
{MAC: healthyNodes[0].MAC, Role: RoleRX},
|
|
}
|
|
}
|
|
result.Links = []SensingLink{{
|
|
TXMAC: result.Assignments[0].MAC,
|
|
RXMAC: result.Assignments[1].MAC,
|
|
Angle: ro.linkAngle(healthyNodes[0], healthyNodes[1]),
|
|
}}
|
|
return result
|
|
}
|
|
|
|
// General case: use GDOP or angular separation optimisation
|
|
if ro.config.UseGDOP && ro.gdopCalc != nil {
|
|
result = ro.optimiseByGDOP(healthyNodes, triggerReason)
|
|
} else {
|
|
result = ro.optimiseByAngularSeparation(healthyNodes, triggerReason)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// optimiseByGDOP finds the TX/RX assignment that minimises worst-case GDOP
|
|
func (ro *RoleOptimiser) optimiseByGDOP(nodes []NodeInfo, triggerReason string) *OptimiseResult {
|
|
n := len(nodes)
|
|
targetTX := n / 2
|
|
if targetTX < 1 {
|
|
targetTX = 1
|
|
}
|
|
|
|
// Get node positions for GDOP calculation
|
|
positions := make([]NodePosition, n)
|
|
for i, node := range nodes {
|
|
positions[i] = NodePosition{
|
|
MAC: node.MAC,
|
|
X: node.PosX,
|
|
Z: node.PosZ,
|
|
}
|
|
}
|
|
|
|
bestAssignments := make([]RoleAssignment, n)
|
|
bestTXNodes := make([]int, 0)
|
|
bestWorstGDOP := math.MaxFloat64
|
|
bestGDOPMap := []float32(nil)
|
|
|
|
// For small n, enumerate all combinations
|
|
if n <= 10 {
|
|
combinations := generateCombinations(n, targetTX)
|
|
for _, txIndices := range combinations {
|
|
// Evaluate this TX assignment
|
|
txPositions := make([]NodePosition, 0, targetTX)
|
|
for _, idx := range txIndices {
|
|
txPositions = append(txPositions, positions[idx])
|
|
}
|
|
|
|
gdopMap, cols, rows := ro.gdopCalc.GDOPMap(txPositions)
|
|
if len(gdopMap) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Compute worst-case GDOP
|
|
var worstGDOP float64
|
|
for _, gdop := range gdopMap {
|
|
if float64(gdop) > worstGDOP {
|
|
worstGDOP = float64(gdop)
|
|
}
|
|
}
|
|
|
|
if worstGDOP < bestWorstGDOP {
|
|
bestWorstGDOP = worstGDOP
|
|
bestTXNodes = make([]int, len(txIndices))
|
|
copy(bestTXNodes, txIndices)
|
|
bestGDOPMap = gdopMap
|
|
// Store dimensions for result
|
|
_ = cols
|
|
_ = rows
|
|
}
|
|
}
|
|
} else {
|
|
// Greedy approach for larger fleets
|
|
selectedIndices := make([]int, 0, targetTX)
|
|
remainingIndices := make([]int, n)
|
|
for i := 0; i < n; i++ {
|
|
remainingIndices[i] = i
|
|
}
|
|
|
|
for len(selectedIndices) < targetTX {
|
|
bestIdx := -1
|
|
bestGDOP := math.MaxFloat64
|
|
|
|
for _, idx := range remainingIndices {
|
|
testIndices := append(selectedIndices, idx)
|
|
txPositions := make([]NodePosition, 0, len(testIndices))
|
|
for _, i := range testIndices {
|
|
txPositions = append(txPositions, positions[i])
|
|
}
|
|
|
|
gdopMap, _, _ := ro.gdopCalc.GDOPMap(txPositions)
|
|
if len(gdopMap) == 0 {
|
|
continue
|
|
}
|
|
|
|
var worstGDOP float64
|
|
for _, gdop := range gdopMap {
|
|
if float64(gdop) > worstGDOP {
|
|
worstGDOP = float64(gdop)
|
|
}
|
|
}
|
|
|
|
if worstGDOP < bestGDOP {
|
|
bestGDOP = worstGDOP
|
|
bestIdx = idx
|
|
bestGDOPMap = gdopMap
|
|
}
|
|
}
|
|
|
|
if bestIdx >= 0 {
|
|
selectedIndices = append(selectedIndices, bestIdx)
|
|
// Remove from remaining
|
|
for i, idx := range remainingIndices {
|
|
if idx == bestIdx {
|
|
remainingIndices = append(remainingIndices[:i], remainingIndices[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
bestTXNodes = selectedIndices
|
|
}
|
|
|
|
// Build role assignments
|
|
txSet := make(map[string]struct{})
|
|
for _, idx := range bestTXNodes {
|
|
txSet[nodes[idx].MAC] = struct{}{}
|
|
}
|
|
|
|
for i, node := range nodes {
|
|
role := RoleRX
|
|
if _, isTX := txSet[node.MAC]; isTX {
|
|
role = RoleTX
|
|
}
|
|
// Check if node should be passive (co-located with another node)
|
|
if ro.shouldBePassive(node, nodes, txSet) {
|
|
role = RolePassive
|
|
}
|
|
bestAssignments[i] = RoleAssignment{MAC: node.MAC, Role: role}
|
|
}
|
|
|
|
// Compute mean GDOP for result
|
|
var sumGDOP float64
|
|
var count int
|
|
for _, gdop := range bestGDOPMap {
|
|
sumGDOP += float64(gdop)
|
|
count++
|
|
}
|
|
meanGDOP := 0.0
|
|
if count > 0 {
|
|
meanGDOP = sumGDOP / float64(count)
|
|
}
|
|
|
|
// Build sensing links
|
|
links := ro.buildSensingLinks(nodes, txSet)
|
|
|
|
return &OptimiseResult{
|
|
Assignments: bestAssignments,
|
|
Links: links,
|
|
MeanGDOP: meanGDOP,
|
|
CoverageScore: ro.computeCoverageScore(bestGDOPMap),
|
|
OptimisedAt: time.Now(),
|
|
TriggerReason: triggerReason,
|
|
GDOPAfter: bestGDOPMap,
|
|
}
|
|
}
|
|
|
|
// optimiseByAngularSeparation finds TX/RX assignment maximising angular separation
|
|
func (ro *RoleOptimiser) optimiseByAngularSeparation(nodes []NodeInfo, triggerReason string) *OptimiseResult {
|
|
n := len(nodes)
|
|
targetTX := n / 2
|
|
if targetTX < 1 {
|
|
targetTX = 1
|
|
}
|
|
|
|
// Build list of candidate TX-RX pairs with their angular scores
|
|
type linkScore struct {
|
|
txIdx, rxIdx int
|
|
score float64
|
|
angle float64
|
|
}
|
|
|
|
var candidates []linkScore
|
|
|
|
for i := 0; i < n; i++ {
|
|
for j := 0; j < n; j++ {
|
|
if i == j {
|
|
continue
|
|
}
|
|
// Check if within sensing range
|
|
dist := ro.nodeDistance(nodes[i], nodes[j])
|
|
if dist > ro.config.MaxSensingRange {
|
|
continue
|
|
}
|
|
|
|
angle := ro.linkAngle(nodes[i], nodes[j])
|
|
// Initial score based on health and distance
|
|
score := nodes[i].HealthScore * nodes[j].HealthScore / (dist + 1)
|
|
candidates = append(candidates, linkScore{
|
|
txIdx: i,
|
|
rxIdx: j,
|
|
score: score,
|
|
angle: angle,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort candidates by score
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
return candidates[i].score > candidates[j].score
|
|
})
|
|
|
|
// Greedy selection: pick links that maximise angular separation
|
|
selectedLinks := make([]linkScore, 0, targetTX)
|
|
usedAsTX := make(map[int]struct{})
|
|
usedAsRX := make(map[int]struct{})
|
|
|
|
for _, c := range candidates {
|
|
if _, used := usedAsTX[c.txIdx]; used {
|
|
continue
|
|
}
|
|
if _, used := usedAsRX[c.rxIdx]; used {
|
|
continue
|
|
}
|
|
|
|
// Compute angular separation score
|
|
angularScore := c.score
|
|
for _, sl := range selectedLinks {
|
|
sep := angularSeparation(c.angle, sl.angle)
|
|
// Penalise near-parallel links (low separation)
|
|
if sep < math.Pi/4 { // Less than 45 degrees
|
|
angularScore *= 0.5
|
|
}
|
|
}
|
|
|
|
// Accept this link if score is still good
|
|
if angularScore > 0.1 {
|
|
selectedLinks = append(selectedLinks, c)
|
|
usedAsTX[c.txIdx] = struct{}{}
|
|
usedAsRX[c.rxIdx] = struct{}{}
|
|
}
|
|
|
|
if len(selectedLinks) >= targetTX {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Build role assignments
|
|
txSet := make(map[string]struct{})
|
|
assignments := make([]RoleAssignment, n)
|
|
assigned := make(map[string]string)
|
|
|
|
for _, link := range selectedLinks {
|
|
txSet[nodes[link.txIdx].MAC] = struct{}{}
|
|
assigned[nodes[link.txIdx].MAC] = RoleTX
|
|
assigned[nodes[link.rxIdx].MAC] = RoleRX
|
|
}
|
|
|
|
for i, node := range nodes {
|
|
if role, ok := assigned[node.MAC]; ok {
|
|
assignments[i] = RoleAssignment{MAC: node.MAC, Role: role}
|
|
} else {
|
|
// Unassigned node becomes passive or RX based on capabilities
|
|
if _, isTX := txSet[node.MAC]; isTX {
|
|
assignments[i] = RoleAssignment{MAC: node.MAC, Role: RoleTX}
|
|
} else if len(txSet) < targetTX && node.Capabilities.CanTX {
|
|
txSet[node.MAC] = struct{}{}
|
|
assignments[i] = RoleAssignment{MAC: node.MAC, Role: RoleTX}
|
|
} else {
|
|
assignments[i] = RoleAssignment{MAC: node.MAC, Role: RoleRX}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build sensing links
|
|
links := make([]SensingLink, len(selectedLinks))
|
|
for i, sl := range selectedLinks {
|
|
links[i] = SensingLink{
|
|
TXMAC: nodes[sl.txIdx].MAC,
|
|
RXMAC: nodes[sl.rxIdx].MAC,
|
|
Angle: sl.angle,
|
|
}
|
|
}
|
|
|
|
return &OptimiseResult{
|
|
Assignments: assignments,
|
|
Links: links,
|
|
OptimisedAt: time.Now(),
|
|
TriggerReason: triggerReason,
|
|
}
|
|
}
|
|
|
|
// linkAngle computes the angle of the link from TX to RX
|
|
func (ro *RoleOptimiser) linkAngle(tx, rx NodeInfo) float64 {
|
|
dx := rx.PosX - tx.PosX
|
|
dz := rx.PosZ - tx.PosZ
|
|
return math.Atan2(dz, dx)
|
|
}
|
|
|
|
// nodeDistance computes the 3D distance between two nodes
|
|
func (ro *RoleOptimiser) nodeDistance(a, b NodeInfo) float64 {
|
|
dx := a.PosX - b.PosX
|
|
dy := a.PosY - b.PosY
|
|
dz := a.PosZ - b.PosZ
|
|
return math.Sqrt(dx*dx + dy*dy + dz*dz)
|
|
}
|
|
|
|
// angularSeparation computes the smaller angle between two link axes
|
|
func angularSeparation(a, b float64) float64 {
|
|
diff := math.Abs(a - b)
|
|
if diff > math.Pi {
|
|
diff = 2*math.Pi - diff
|
|
}
|
|
return diff
|
|
}
|
|
|
|
// shouldBePassive checks if a node should be assigned passive role
|
|
// (e.g., co-located with another node)
|
|
func (ro *RoleOptimiser) shouldBePassive(node NodeInfo, allNodes []NodeInfo, txSet map[string]struct{}) bool {
|
|
const colocateThreshold = 0.5 // metres
|
|
|
|
for _, other := range allNodes {
|
|
if other.MAC == node.MAC {
|
|
continue
|
|
}
|
|
dist := ro.nodeDistance(node, other)
|
|
if dist < colocateThreshold {
|
|
// Co-located with another node
|
|
// If the other node is TX, this one should be passive
|
|
if _, isTX := txSet[other.MAC]; isTX {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// computeCoverageScore computes a 0-1 coverage score from a GDOP map
|
|
func (ro *RoleOptimiser) computeCoverageScore(gdopMap []float32) float64 {
|
|
if len(gdopMap) == 0 {
|
|
return 0
|
|
}
|
|
|
|
var below2, below5 int
|
|
var sumGDOP float64
|
|
var worstGDOP float64
|
|
|
|
for _, gdop := range gdopMap {
|
|
g := float64(gdop)
|
|
sumGDOP += g
|
|
if g > worstGDOP {
|
|
worstGDOP = g
|
|
}
|
|
if g < 2 {
|
|
below2++
|
|
}
|
|
if g < 5 {
|
|
below5++
|
|
}
|
|
}
|
|
|
|
n := float64(len(gdopMap))
|
|
pctBelow2 := float64(below2) / n
|
|
pctBelow5 := float64(below5) / n
|
|
worstPenalty := math.Min(worstGDOP/10, 1.0)
|
|
|
|
// Weighted combination:
|
|
// - 50% weight to GDOP < 2 percentage
|
|
// - 30% weight to GDOP < 5 percentage
|
|
// - 20% penalty for worst GDOP (capped at 10)
|
|
return 0.5*pctBelow2 + 0.3*pctBelow5 + 0.2*(1-worstPenalty)
|
|
}
|
|
|
|
// buildSensingLinks creates a list of sensing links from TX nodes to all RX nodes
|
|
func (ro *RoleOptimiser) buildSensingLinks(nodes []NodeInfo, txSet map[string]struct{}) []SensingLink {
|
|
var links []SensingLink
|
|
|
|
for _, tx := range nodes {
|
|
if _, isTX := txSet[tx.MAC]; !isTX {
|
|
continue
|
|
}
|
|
for _, rx := range nodes {
|
|
if _, isTX := txSet[rx.MAC]; isTX {
|
|
continue // Skip TX-TX links
|
|
}
|
|
if tx.MAC == rx.MAC {
|
|
continue
|
|
}
|
|
dist := ro.nodeDistance(tx, rx)
|
|
if dist <= ro.config.MaxSensingRange {
|
|
links = append(links, SensingLink{
|
|
TXMAC: tx.MAC,
|
|
RXMAC: rx.MAC,
|
|
Angle: ro.linkAngle(tx, rx),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return links
|
|
}
|
|
|
|
// SimulateRemoval predicts coverage impact if a node is removed
|
|
func (ro *RoleOptimiser) SimulateRemoval(nodes []NodeInfo, removeMAC string) (*OptimiseResult, float64) {
|
|
// Get current coverage
|
|
currentResult := ro.Optimise(nodes, "current_state")
|
|
currentScore := currentResult.CoverageScore
|
|
|
|
// Remove the node
|
|
remaining := make([]NodeInfo, 0, len(nodes)-1)
|
|
for _, n := range nodes {
|
|
if n.MAC != removeMAC {
|
|
remaining = append(remaining, n)
|
|
}
|
|
}
|
|
|
|
// Get coverage after removal
|
|
newResult := ro.Optimise(remaining, "simulated_removal")
|
|
newScore := newResult.CoverageScore
|
|
|
|
// Coverage delta (negative means degradation)
|
|
coverageDelta := newScore - currentScore
|
|
|
|
return newResult, coverageDelta
|
|
}
|