feat(bf-28dv): implement zone-aware rate control (Component 24)

Implement fleet-level sentinel link coordination with zone-aware idle modes:

- Zone membership tracking: Track which nodes belong to which zones and update zone state on motion events
- Zone idle detection: Detect when all nodes in a zone have been idle and designate one sentinel link per zone at 5 Hz
- Sentinel designation: Lexicographically smallest MAC becomes sentinel (5 Hz), others drop to 1 Hz
- Fleet idle coordination: When all zones are idle, only sentinel links run at 5 Hz for minimal bandwidth
- Adjacent zone ramping: When motion detected in a zone, ramp adjacent zones to 5 Hz for preemptive coverage
- RampZone() method: Interface for prediction engine to preemptively ramp zones when P(arrival) > threshold
- Zone-aware tests: Comprehensive tests for zone membership, idle detection, sentinel designation, fleet coordination, and backward compatibility

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-07-05 23:15:47 -04:00
parent e13b2e220b
commit 9c36b0dab3
3 changed files with 801 additions and 2 deletions

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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