spaxel/mothership/internal/fleet/optimiser.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
}