diff --git a/mothership/internal/ingestion/ratecontrol.go b/mothership/internal/ingestion/ratecontrol.go index 231e4d3..b056ef3 100644 --- a/mothership/internal/ingestion/ratecontrol.go +++ b/mothership/internal/ingestion/ratecontrol.go @@ -11,6 +11,12 @@ const ( RateIdle = 2 // RateActive is the CSI sampling rate (Hz) when motion is detected. RateActive = 50 + // RateSentinel is the CSI sampling rate (Hz) for the designated sentinel link in an idle zone. + // Runs at 5 Hz to maintain minimal coverage while reducing bandwidth. + RateSentinel = 5 + // RateFleetIdle is the CSI sampling rate (Hz) for non-sentinel links when the entire fleet is idle. + // Only one sentinel per zone runs at RateSentinel; all others run at 1 Hz. + RateFleetIdle = 1 // idleTimeout is how long after the last motion event before dropping back to idle. idleTimeout = 30 * time.Second @@ -27,15 +33,34 @@ type nodeRateState struct { lastMotionAt time.Time } +// zoneRateState tracks the adaptive rate state for a single zone. +type zoneRateState struct { + zoneID string + nodes map[string]bool // nodes in this zone (keyed by MAC) + sentinelNodeMAC string // designated sentinel node (may be empty) + allNodesIdle bool // true when all nodes in zone are idle + lastMotionAt time.Time // last motion in this zone +} + // RateController manages per-node adaptive sensing rates. When motion is detected // on a node's link, it ramps that node to RateActive (50 Hz). When no motion has // been seen for idleTimeout, it drops back to RateIdle (2 Hz). The caller provides // a configSender callback that sends the rate and variance threshold to the node. +// +// Zone-aware mode: When SetZoneMembershipFn is called, the controller also tracks +// zone-level idle states and designates sentinel links per zone at RateSentinel (5 Hz) +// when the fleet is idle, with all other links at RateFleetIdle (1 Hz). type RateController struct { mu sync.Mutex nodes map[string]*nodeRateState // keyed by node MAC configSender func(nodeMAC string, rateHz int, varianceThreshold float64) adjacentNodes func(nodeMAC string) []string // returns MACs of adjacent nodes; may be nil + + // Zone-aware fields + zones map[string]*zoneRateState // keyed by zone ID + zoneMembership func(nodeMAC string) []string // returns zone IDs for a node + adjacentZones func(zoneID string) []string // returns zone IDs adjacent to a zone + fleetIdle bool // true when all zones are idle } // NewRateController creates a RateController. configSender is called whenever a @@ -44,6 +69,7 @@ type RateController struct { func NewRateController(configSender func(nodeMAC string, rateHz int, varianceThreshold float64)) *RateController { return &RateController{ nodes: make(map[string]*nodeRateState), + zones: make(map[string]*zoneRateState), configSender: configSender, } } @@ -58,6 +84,24 @@ func (rc *RateController) SetAdjacentNodesFn(fn func(nodeMAC string) []string) { rc.mu.Unlock() } +// SetZoneMembershipFn configures a callback that returns the zone IDs for a given node. +// When set, the controller tracks zone-level idle states and designates sentinel links. +// The function should return zone IDs based on the node's position (X, Y, Z) within zone bounds. +func (rc *RateController) SetZoneMembershipFn(fn func(nodeMAC string) []string) { + rc.mu.Lock() + rc.zoneMembership = fn + rc.mu.Unlock() +} + +// SetAdjacentZonesFn configures a callback that returns zone IDs adjacent to a given zone. +// When set, OnMotionState preemptively ramps adjacent zones to RateSentinel when motion +// is detected in a zone. The function should return zone IDs based on portal connections. +func (rc *RateController) SetAdjacentZonesFn(fn func(zoneID string) []string) { + rc.mu.Lock() + rc.adjacentZones = fn + rc.mu.Unlock() +} + // OnMotionState is called after each CSI frame is processed. If the node was idle // and motion is now detected, it ramps up immediately. func (rc *RateController) OnMotionState(nodeMAC string, motionDetected bool) { @@ -75,6 +119,25 @@ func (rc *RateController) OnMotionState(nodeMAC string, motionDetected bool) { ns.active = true rc.configSender(nodeMAC, RateActive, 0) // active: disable on-device hint, server handles it } + + // Zone-aware mode: update zones this node belongs to + if rc.zoneMembership != nil { + now := time.Now() + zoneIDs := rc.zoneMembership(nodeMAC) + for _, zoneID := range zoneIDs { + zone := rc.getOrCreateZone(zoneID) + zoneWasIdle := zone.allNodesIdle + zone.allNodesIdle = false + zone.lastMotionAt = now + zone.nodes[nodeMAC] = true + + // If zone was idle and is now active, ramp all nodes in zone to active + // and ramp adjacent zones to sentinel (preemptive coverage) + if zoneWasIdle { + rc.activateZone(zoneID) + } + } + } } // OnMotionHint is called when the ESP32 sends a motion_hint message (on-device @@ -97,8 +160,57 @@ func (rc *RateController) OnMotionHint(nodeMAC string) { // OnNodeDisconnected removes rate state for a disconnected node. func (rc *RateController) OnNodeDisconnected(nodeMAC string) { rc.mu.Lock() + defer rc.mu.Unlock() + + // Remove from nodes delete(rc.nodes, nodeMAC) - rc.mu.Unlock() + + // If zone-aware mode is active, remove from all zones and redesignate sentinels if needed + if rc.zoneMembership != nil { + zoneIDs := rc.zoneMembership(nodeMAC) + for _, zoneID := range zoneIDs { + if zone, ok := rc.zones[zoneID]; ok { + delete(zone.nodes, nodeMAC) + // If this was the sentinel, redesignate + if zone.sentinelNodeMAC == nodeMAC { + zone.sentinelNodeMAC = "" + if zone.allNodesIdle && len(zone.nodes) > 0 { + rc.designateSentinel(zoneID) + } + } + } + } + } +} + +// RampZone preemptively ramps all nodes in a zone to active rate. +// Called by the prediction engine when a zone arrival is predicted (P(arrival) > threshold). +// Optionally ramps adjacent zones to sentinel rate for preemptive coverage. +func (rc *RateController) RampZone(zoneID string, rampAdjacent bool) { + rc.mu.Lock() + defer rc.mu.Unlock() + + zone := rc.getOrCreateZone(zoneID) + now := time.Now() + + // Ramp all nodes in zone to active + for nodeMAC := range zone.nodes { + ns := rc.getOrCreate(nodeMAC) + ns.active = true + ns.lastMotionAt = now + rc.configSender(nodeMAC, RateActive, 0) + } + + // Update zone state + zone.allNodesIdle = false + zone.lastMotionAt = now + + // Optionally ramp adjacent zones to sentinel + if rampAdjacent && rc.adjacentZones != nil { + for _, adjZoneID := range rc.adjacentZones(zoneID) { + rc.rampZoneToSentinel(adjZoneID) + } + } } // Run starts the background goroutine that enforces idle timeouts. @@ -123,12 +235,87 @@ func (rc *RateController) checkIdleTimeouts() { rc.mu.Lock() defer rc.mu.Unlock() + // First, handle per-node idle detection for mac, ns := range rc.nodes { if ns.active && now.Sub(ns.lastMotionAt) >= idleTimeout { ns.active = false - rc.configSender(mac, RateIdle, DefaultVarianceThreshold) // idle: enable on-device hint + // If zone-aware mode is active, don't send config here; let zone-level logic handle it + if rc.zoneMembership == nil { + rc.configSender(mac, RateIdle, DefaultVarianceThreshold) + } } } + + // Then, handle zone-level idle detection and sentinel designation + if rc.zoneMembership != nil { + allZonesIdle := true + now := time.Now() + + for zoneID, zone := range rc.zones { + // Determine if all nodes in this zone are idle + allNodesIdle := true + oldestMotion := time.Unix(0, 0) + + for nodeMAC := range zone.nodes { + ns := rc.nodes[nodeMAC] + if ns.active { + allNodesIdle = false + break + } + if ns.lastMotionAt.After(oldestMotion) { + oldestMotion = ns.lastMotionAt + } + } + + // Check if zone has been idle for timeout duration + zoneIdle := allNodesIdle && (!oldestMotion.IsZero() && now.Sub(oldestMotion) >= idleTimeout) + + // Zone transition: active -> idle + if zoneIdle && !zone.allNodesIdle { + zone.allNodesIdle = true + zone.lastMotionAt = oldestMotion + rc.designateSentinel(zoneID) + } else if !zoneIdle { + // Zone is active (or has no nodes with valid timestamps) + allZonesIdle = false + } + } + + // Update fleet idle state + rc.fleetIdle = allZonesIdle && len(rc.zones) > 0 + } +} + +// designateSentinel chooses a sentinel node for an idle zone and adjusts rates. +// The sentinel gets RateSentinel (5 Hz); all other nodes get RateFleetIdle (1 Hz). +func (rc *RateController) designateSentinel(zoneID string) { + zone := rc.zones[zoneID] + if len(zone.nodes) == 0 { + return + } + + // Find lexicographically smallest MAC as sentinel + var sentinelMAC string + for nodeMAC := range zone.nodes { + if sentinelMAC == "" || nodeMAC < sentinelMAC { + sentinelMAC = nodeMAC + } + } + zone.sentinelNodeMAC = sentinelMAC + + // Set rates: sentinel at 5 Hz, others at 1 Hz + for nodeMAC := range zone.nodes { + rate := RateFleetIdle + if nodeMAC == sentinelMAC { + rate = RateSentinel + } + ns := rc.nodes[nodeMAC] + if ns.active { + // Shouldn't happen if zone is idle, but handle it + ns.active = false + } + rc.configSender(nodeMAC, rate, DefaultVarianceThreshold) + } } func (rc *RateController) getOrCreate(nodeMAC string) *nodeRateState { @@ -139,3 +326,53 @@ func (rc *RateController) getOrCreate(nodeMAC string) *nodeRateState { rc.nodes[nodeMAC] = ns return ns } + +// getOrCreateZone retrieves a zone's state, creating it if necessary. +func (rc *RateController) getOrCreateZone(zoneID string) *zoneRateState { + if zone, ok := rc.zones[zoneID]; ok { + return zone + } + zone := &zoneRateState{ + zoneID: zoneID, + nodes: make(map[string]bool), + allNodesIdle: true, // start idle; will be marked active on first motion + } + rc.zones[zoneID] = zone + return zone +} + +// activateZone ramps all nodes in a zone to active and ramps adjacent zones to sentinel. +// Called when a zone transitions from idle to active (motion detected in an idle zone). +func (rc *RateController) activateZone(zoneID string) { + // Ramp all nodes in this zone to active + zone := rc.zones[zoneID] + for nodeMAC := range zone.nodes { + ns := rc.getOrCreate(nodeMAC) + if !ns.active { + ns.active = true + ns.lastMotionAt = time.Now() + rc.configSender(nodeMAC, RateActive, 0) + } + } + + // Ramp adjacent zones to sentinel (preemptive coverage) + if rc.adjacentZones != nil { + for _, adjZoneID := range rc.adjacentZones(zoneID) { + rc.rampZoneToSentinel(adjZoneID) + } + } +} + +// rampZoneToSentinel ramps all nodes in a zone to sentinel rate (5 Hz). +// Used for preemptive coverage of zones adjacent to active zones. +func (rc *RateController) rampZoneToSentinel(zoneID string) { + zone := rc.getOrCreateZone(zoneID) + for nodeMAC := range zone.nodes { + ns := rc.getOrCreate(nodeMAC) + // Only ramp if node is idle (no need to affect already-active nodes) + if !ns.active { + ns.lastMotionAt = time.Now() + rc.configSender(nodeMAC, RateSentinel, DefaultVarianceThreshold) + } + } +} diff --git a/mothership/internal/ingestion/ratecontrol_test.go b/mothership/internal/ingestion/ratecontrol_test.go index d7264dc..721c94e 100644 --- a/mothership/internal/ingestion/ratecontrol_test.go +++ b/mothership/internal/ingestion/ratecontrol_test.go @@ -197,3 +197,357 @@ func TestNodeDisconnectClearsState(t *testing.T) { t.Error("node state should be removed after disconnect") } } + +// Zone-aware tests + +func TestZoneMembershipTracking(t *testing.T) { + rc, sent := newTestRC() + + // Set zone membership function + rc.SetZoneMembershipFn(func(mac string) []string { + if mac == "AA:BB:CC:DD:EE:01" { + return []string{"zone-a", "zone-b"} // Node in overlapping zones + } + if mac == "AA:BB:CC:DD:EE:02" { + return []string{"zone-a"} + } + return nil + }) + + // Motion in zone-a node + rc.OnMotionState("AA:BB:CC:DD:EE:01", true) + + // Should ramp the node to active + if len(*sent) != 1 || (*sent)[0].rate != RateActive { + t.Errorf("expected node to ramp to active, got %d sends: %v", len(*sent), *sent) + } + + // Check zone state was updated + rc.mu.Lock() + zoneA := rc.zones["zone-a"] + zoneB := rc.zones["zone-b"] + rc.mu.Unlock() + + if zoneA == nil || zoneB == nil { + t.Fatal("zones should be created") + } + if !zoneA.nodes["AA:BB:CC:DD:EE:01"] || !zoneB.nodes["AA:BB:CC:DD:EE:01"] { + t.Error("node should be registered in both zones") + } + if zoneA.allNodesIdle || zoneB.allNodesIdle { + t.Error("zones should be marked active after motion") + } +} + +func TestZoneIdleDetectionAndSentinelDesignation(t *testing.T) { + rc, sent := newTestRC() + + // Set zone membership + rc.SetZoneMembershipFn(func(mac string) []string { + return []string{"zone-a"} + }) + + // Add two nodes to zone-a (both active) + rc.OnMotionState("AA:BB:CC:DD:EE:01", true) + rc.OnMotionState("AA:BB:CC:DD:EE:02", true) + + // Both should be at active rate + if len(*sent) != 2 { + t.Fatalf("expected 2 active sends, got %d", len(*sent)) + } + + // Force both nodes to timeout + rc.mu.Lock() + rc.nodes["AA:BB:CC:DD:EE:01"].lastMotionAt = time.Now().Add(-idleTimeout - time.Second) + rc.nodes["AA:BB:CC:DD:EE:02"].lastMotionAt = time.Now().Add(-idleTimeout - time.Second) + rc.mu.Unlock() + + // Run idle timeout check + rc.checkIdleTimeouts() + + // Should have sent 2 more configs: sentinel (5 Hz) and non-sentinel (1 Hz) + // Sentinel is lexicographically smaller MAC + if len(*sent) != 4 { + t.Fatalf("expected 4 sends total (2 active + 2 idle), got %d: %v", len(*sent), *sent) + } + + // Verify rates + sentinelMAC := "AA:BB:CC:DD:EE:01" // Lexicographically smaller + otherMAC := "AA:BB:CC:DD:EE:02" + + sentinelRate, otherRate := -1, -1 + for _, s := range *sent { + if s.mac == sentinelMAC && s.rate != RateActive { // Skip initial active send + sentinelRate = s.rate + } + if s.mac == otherMAC && s.rate != RateActive { + otherRate = s.rate + } + } + + if sentinelRate != RateSentinel { + t.Errorf("expected sentinel rate %d, got %d", RateSentinel, sentinelRate) + } + if otherRate != RateFleetIdle { + t.Errorf("expected non-sentinel rate %d, got %d", RateFleetIdle, otherRate) + } + + // Verify zone state + rc.mu.Lock() + zone := rc.zones["zone-a"] + rc.mu.Unlock() + + if !zone.allNodesIdle { + t.Error("zone should be marked idle after timeout") + } + if zone.sentinelNodeMAC != sentinelMAC { + t.Errorf("expected sentinel %s, got %s", sentinelMAC, zone.sentinelNodeMAC) + } +} + +func TestFleetIdleDetection(t *testing.T) { + rc, sent := newTestRC() + + // Set zone membership and adjacent zones + rc.SetZoneMembershipFn(func(mac string) []string { + if mac == "AA:BB:CC:DD:EE:01" || mac == "AA:BB:CC:DD:EE:02" { + return []string{"zone-a"} + } + if mac == "AA:BB:CC:DD:EE:03" { + return []string{"zone-b"} + } + return nil + }) + + rc.SetAdjacentZonesFn(func(zoneID string) []string { + if zoneID == "zone-a" { + return []string{"zone-b"} + } + if zoneID == "zone-b" { + return []string{"zone-a"} + } + return nil + }) + + // Add nodes to both zones (all active) + rc.OnMotionState("AA:BB:CC:DD:EE:01", true) + rc.OnMotionState("AA:BB:CC:DD:EE:02", true) + rc.OnMotionState("AA:BB:CC:DD:EE:03", true) + + // All should be at active rate + if len(*sent) != 3 { + t.Fatalf("expected 3 active sends, got %d", len(*sent)) + } + + // Force all nodes to timeout + rc.mu.Lock() + for mac := range rc.nodes { + rc.nodes[mac].lastMotionAt = time.Now().Add(-idleTimeout - time.Second) + } + rc.mu.Unlock() + + // Run idle timeout check + rc.checkIdleTimeouts() + + // Should have sent configs for sentinel + non-sentinel in each zone + // zone-a: 2 nodes (sentinel + non-sentinel), zone-b: 1 node (sentinel only) + expectedSends := 3 + 2 + 1 // initial active + idle configs + if len(*sent) != expectedSends { + t.Fatalf("expected %d sends, got %d: %v", expectedSends, len(*sent), *sent) + } + + // Verify fleet is idle + rc.mu.Lock() + fleetIdle := rc.fleetIdle + rc.mu.Unlock() + + if !fleetIdle { + t.Error("fleet should be idle when all zones are idle") + } +} + +func TestAdjacentZoneRamping(t *testing.T) { + rc, sent := newTestRC() + + // Set zone membership and adjacent zones + rc.SetZoneMembershipFn(func(mac string) []string { + if mac == "AA:BB:CC:DD:EE:01" { + return []string{"zone-a"} + } + if mac == "AA:BB:CC:DD:EE:02" { + return []string{"zone-b"} + } + return nil + }) + + rc.SetAdjacentZonesFn(func(zoneID string) []string { + if zoneID == "zone-a" { + return []string{"zone-b"} + } + return nil + }) + + // Zone-b is idle (timeout) - start with idle node + rc.OnMotionState("AA:BB:CC:DD:EE:02", true) + rc.mu.Lock() + rc.nodes["AA:BB:CC:DD:EE:02"].lastMotionAt = time.Now().Add(-idleTimeout - time.Second) + rc.mu.Unlock() + + // Run idle timeout to mark zone-b idle and designate sentinel + rc.checkIdleTimeouts() + + // Reset sent tracker to count only zone-a activation sends + initialSends := len(*sent) + *sent = (*sent)[:initialSends] + + // Zone-a detects motion (should ramp adjacent zone-b to sentinel) + rc.OnMotionState("AA:BB:CC:DD:EE:01", true) + + // Should have: zone-a node active + zone-b node ramped to sentinel + // Note: zone-b node is already at sentinel (5 Hz), but we ramp it again + // This is expected behavior for preemptive coverage + if len(*sent) != initialSends+2 { + t.Fatalf("expected 2 sends (zone-a active + zone-b sentinel), got %d: %v", len(*sent)-initialSends, (*sent)[initialSends:]) + } + + // Verify zone-a went to active and zone-b went to sentinel + zoneAActive := false + zoneBSentinel := false + for i := initialSends; i < len(*sent); i++ { + if (*sent)[i].mac == "AA:BB:CC:DD:EE:01" && (*sent)[i].rate == RateActive { + zoneAActive = true + } + if (*sent)[i].mac == "AA:BB:CC:DD:EE:02" && (*sent)[i].rate == RateSentinel { + zoneBSentinel = true + } + } + if !zoneAActive { + t.Error("zone-a node should be ramped to active rate") + } + if !zoneBSentinel { + t.Error("adjacent zone-b node should be ramped to sentinel rate") + } +} + +func TestRampZonePredictionEngine(t *testing.T) { + rc, sent := newTestRC() + + // Set zone membership + rc.SetZoneMembershipFn(func(mac string) []string { + if mac == "AA:BB:CC:DD:EE:01" || mac == "AA:BB:CC:DD:EE:02" { + return []string{"zone-a"} + } + return nil + }) + + // Add nodes to zone-a (they'll be idle by default) + rc.OnMotionState("AA:BB:CC:DD:EE:01", true) + rc.OnMotionState("AA:BB:CC:DD:EE:02", true) + + // Force idle timeout + rc.mu.Lock() + for mac := range rc.nodes { + rc.nodes[mac].lastMotionAt = time.Now().Add(-idleTimeout - time.Second) + } + rc.mu.Unlock() + rc.checkIdleTimeouts() + + // Reset sent tracker to count only RampZone sends + initialSends := len(*sent) + *sent = (*sent)[:initialSends] + + // Prediction engine ramps zone-a (with adjacent zone ramping disabled) + rc.RampZone("zone-a", false) + + // Should have ramped both nodes to active + if len(*sent) != initialSends+2 { + t.Fatalf("expected 2 ramp sends, got %d: %v", len(*sent)-initialSends, (*sent)[initialSends:]) + } + + for i := initialSends; i < len(*sent); i++ { + if (*sent)[i].rate != RateActive { + t.Errorf("RampZone should set active rate, got %d", (*sent)[i].rate) + } + } + + // Verify zone state updated + rc.mu.Lock() + zone := rc.zones["zone-a"] + rc.mu.Unlock() + + if zone.allNodesIdle { + t.Error("zone should be marked active after RampZone") + } +} + +func TestBackwardCompatibilityNoZones(t *testing.T) { + rc, sent := newTestRC() + + // No zone membership function set - should fall back to per-node behavior + + rc.OnMotionState("AA:BB:CC:DD:EE:FF", true) + + if len(*sent) != 1 || (*sent)[0].rate != RateActive { + t.Errorf("should work without zones (per-node fallback), got %d sends: %v", len(*sent), *sent) + } + + // Force timeout + rc.mu.Lock() + rc.nodes["AA:BB:CC:DD:EE:FF"].lastMotionAt = time.Now().Add(-idleTimeout - time.Second) + rc.mu.Unlock() + + rc.checkIdleTimeouts() + + // Should drop to idle rate (2 Hz) + if len(*sent) != 2 || (*sent)[1].rate != RateIdle { + t.Errorf("should drop to RateIdle (2 Hz) without zones, got %d sends: %v", len(*sent), *sent) + } +} + +func TestNodeDisconnectWithSentinelRedesignation(t *testing.T) { + rc, sent := newTestRC() + + // Set zone membership + rc.SetZoneMembershipFn(func(mac string) []string { + return []string{"zone-a"} + }) + + // Add two nodes, let them go idle + rc.OnMotionState("AA:BB:CC:DD:EE:01", true) + rc.OnMotionState("AA:BB:CC:DD:EE:02", true) + + // Force timeout + rc.mu.Lock() + for mac := range rc.nodes { + rc.nodes[mac].lastMotionAt = time.Now().Add(-idleTimeout - time.Second) + } + rc.mu.Unlock() + rc.checkIdleTimeouts() + + // Sentinel should be 01 (lexicographically smaller) + rc.mu.Lock() + sentinelMAC := rc.zones["zone-a"].sentinelNodeMAC + rc.mu.Unlock() + + if sentinelMAC != "AA:BB:CC:DD:EE:01" { + t.Errorf("expected sentinel 01, got %s", sentinelMAC) + } + + // Disconnect sentinel + rc.OnNodeDisconnected("AA:BB:CC:DD:EE:01") + + // New sentinel should be redesignated (02) + rc.mu.Lock() + newSentinelMAC := rc.zones["zone-a"].sentinelNodeMAC + rc.mu.Unlock() + + if newSentinelMAC != "AA:BB:CC:DD:EE:02" { + t.Errorf("expected new sentinel 02, got %s", newSentinelMAC) + } + + // Should have sent config for new sentinel + lastSend := (*sent)[len(*sent)-1] + if lastSend.mac != "AA:BB:CC:DD:EE:02" || lastSend.rate != RateSentinel { + t.Errorf("expected new sentinel config, got %d Hz for %s", lastSend.rate, lastSend.mac) + } +} diff --git a/mothership/internal/ingestion/ratecontrol_zones_design.md b/mothership/internal/ingestion/ratecontrol_zones_design.md new file mode 100644 index 0000000..68021fd --- /dev/null +++ b/mothership/internal/ingestion/ratecontrol_zones_design.md @@ -0,0 +1,208 @@ +# Zone-Aware Rate Control Design + +## Overview + +Extend the current per-node `RateController` to support fleet-level coordination with zone awareness and sentinel link designation. + +## Current State + +**RateController** (mothership/internal/ingestion/ratecontrol.go): +- Manages per-node adaptive rates: 50 Hz active, 2 Hz idle +- Per-node state machine: active/idle with 30-second timeout +- OnMotionHint ramps adjacent nodes (already has adjacency function) +- No zone awareness, no fleet-level coordination + +## New Requirements (Component 24) + +1. **Zone-level idle detection**: When all nodes in a zone have been idle, designate one sentinel link per zone at 5 Hz; all others drop to 1 Hz +2. **Fleet-wide idle state**: When all zones are idle, only sentinel links at 5 Hz run +3. **Adjacent zone ramping**: When activity detected in one zone, ramp that zone to full rate AND adjacent zones to 5 Hz +4. **Prediction engine interface**: RampZone(zoneID) for preemptive zone ramping + +## Design + +### Rate Tiers + +```go +const ( + // RateActive is the CSI sampling rate (Hz) when motion is detected. + RateActive = 50 + + // RateIdle is the CSI sampling rate (Hz) when a node is idle but fleet is not fully idle. + RateIdle = 2 + + // RateSentinel is the CSI sampling rate (Hz) for the designated sentinel link in an idle zone. + // Runs at 5 Hz to maintain minimal coverage while reducing bandwidth. + RateSentinel = 5 + + // RateFleetIdle is the CSI sampling rate (Hz) for non-sentinel links when the entire fleet is idle. + // Only one sentinel per zone runs at RateSentinel; all others run at 1 Hz. + RateFleetIdle = 1 +) +``` + +### Data Structures + +```go +// zoneRateState tracks the adaptive rate state for a single zone. +type zoneRateState struct { + zoneID string + nodes map[string]bool // nodes in this zone + sentinelNodeMAC string // designated sentinel node (may be empty) + allNodesIdle bool // true when all nodes in zone are idle + lastMotionAt time.Time // last motion in this zone +} + +// RateController manages per-node and zone-aware adaptive sensing rates. +type RateController struct { + // Existing fields + mu sync.Mutex + nodes map[string]*nodeRateState + configSender func(nodeMAC string, rateHz int, varianceThreshold float64) + adjacentNodes func(nodeMAC string) []string + + // New zone-aware fields + zones map[string]*zoneRateState // keyed by zone ID + zoneMembership func(nodeMAC string) []string // returns zone IDs for a node + adjacentZones func(zoneID string) []string // returns zone IDs adjacent to a zone + fleetIdle bool // true when all zones are idle +} +``` + +### Zone Membership Algorithm + +**Determining which zones a node belongs to:** +- A node is in a zone if its position (X, Y, Z) is within the zone's bounds +- Zone bounds: (min_x <= node.X <= max_x) && (min_y <= node.Y <= max_y) && (min_z <= node.Z <= max_z) +- A node can be in multiple zones (overlapping zones) +- Use zone membership provider function from zone manager + +**Zone membership provider signature:** +```go +// ZoneMembershipFn returns the zone IDs that a node belongs to based on its position. +// The caller must provide this function; it typically queries the zone manager. +type ZoneMembershipFn func(nodeMAC string) []string +``` + +### Sentinel Designation Algorithm + +**Choosing the sentinel node for a zone:** +- When a zone becomes idle, designate one node as the sentinel +- Selection criteria: the node with the lexicographically smallest MAC address (deterministic) +- Sentinel gets RateSentinel (5 Hz), all other nodes in zone get RateFleetIdle (1 Hz) +- Redesignate only when previous sentinel disconnects + +**Zone idle detection:** +- Zone is idle when all its nodes have been idle for the timeout period +- Track last motion time per zone (max of last motion times of all nodes in zone) +- When zone transitions to idle, designate sentinel and adjust rates + +### Fleet Idle Detection + +**Fleet is idle when:** +- All zones are in the idle state (allNodesIdle = true) +- OR no zones exist (fallback to per-node behavior) + +**When fleet becomes idle:** +- All zones: sentinel runs at 5 Hz, others at 1 Hz +- When fleet was idle, one zone becomes active: that zone at 50 Hz, adjacent zones at 5 Hz + +### Adjacent Zone Ramping + +**When motion is detected in a zone:** +- Ramp that zone's nodes to RateActive (50 Hz) +- Ramp adjacent zones' nodes to RateSentinel (5 Hz) - preemptive coverage +- Adjacent zones determined by portals in zone manager + +**Adjacent zones provider signature:** +```go +// AdjacentZonesFn returns zone IDs that are adjacent to a given zone. +// The caller must provide this function; it typically queries the zone manager for portals. +type AdjacentZonesFn func(zoneID string) []string +``` + +### Prediction Engine Interface + +**RampZone(zoneID) method:** +- Called by prediction engine when P(arrival in zone) > threshold +- Ramps all nodes in the zone to RateActive (50 Hz) +- Resets zone's lastMotionAt to now +- Optionally ramps adjacent zones to RateSentinel (5 Hz) + +```go +// RampZone preemptively ramps all nodes in a zone to active rate. +// Called by the prediction engine when a zone arrival is predicted. +func (rc *RateController) RampZone(zoneID string) { + // Set all nodes in zone to active + // Update zone state + // Optionally ramp adjacent zones to sentinel +} +``` + +### State Machine + +**Node state:** +- Active (50 Hz) → Idle (2 Hz) after 30s timeout +- Idle → Active immediately on motion detection +- Idle (2 Hz) → Fleet Idle (1 Hz) when fleet becomes idle AND not sentinel +- Idle (2 Hz) → Sentinel (5 Hz) when fleet becomes idle AND is sentinel + +**Zone state:** +- All nodes idle → Zone idle +- Zone idle → Zone active when any node detects motion +- Zone active → Zone idle after timeout (same 30s) + +**Fleet state:** +- All zones idle → Fleet idle +- Fleet idle → Fleet active when any zone becomes active + +## Integration Points + +### Required from callers: + +1. **Node position provider**: Function to get a node's (X, Y, Z) position + - Already available from fusion.Engine.NodePositions() + - Used to determine zone membership + +2. **Zone membership provider**: Function to get zone IDs for a node + - Queries zone manager for zones containing the node's position + - Returns []string (may be empty for nodes in no zones) + +3. **Adjacent zones provider**: Function to get adjacent zone IDs + - Queries zone manager for portals connected to a zone + - Returns []string (may be empty for isolated zones) + +4. **Zone bounds provider**: Function to get zone bounds + - Queries zone manager for zone (min_x, min_y, min_z, max_x, max_y, max_z) + - Used for spatial containment test + +## Implementation Plan + +1. Add new rate constants (RateSentinel, RateFleetIdle) +2. Add zoneRateState struct +3. Add zone-related fields to RateController +4. Implement SetZoneMembershipFn() and SetAdjacentZonesFn() +5. Implement zone membership tracking in OnMotionState() +6. Implement zone idle detection in checkIdleTimeouts() +7. Implement sentinel designation logic +8. Implement fleet idle detection +9. Implement RampZone() method +10. Implement adjacent zone ramping in OnMotionState() +11. Update tests to cover zone-aware behavior + +## Backward Compatibility + +- If no zone membership function is set, fall back to per-node behavior only +- Existing tests should continue to pass +- Zone-aware features are opt-in via SetZoneMembershipFn() + +## Testing Strategy + +1. Test zone membership determination +2. Test zone idle detection +3. Test sentinel designation +4. Test fleet idle detection +5. Test adjacent zone ramping +6. Test RampZone() prediction engine interface +7. Test fallback to per-node behavior when zones not configured +8. Test node disconnection handles sentinel redesignation