spaxel/mothership/internal/explainability/handler.go
jedarden 77a2fbc9c0 test: implement acceptance scenario integration tests (AS-1 through AS-6)
- Added comprehensive integration tests in test/acceptance/ covering all 6 acceptance scenarios from plan.md
- AS-1: First-time setup in under 5 minutes - verifies PIN setup and node auto-discovery
- AS-2: Person detected while walking - verifies blob detection during walker simulation
- AS-3: Fall alert fires correctly - verifies fall detection with webhook integration
- AS-4: BLE identity resolves to person name - verifies BLE device registration and identity matching
- AS-5: OTA update succeeds / rollback on bad firmware - verifies OTA workflow and rollback
- AS-6: Replay shows recorded history - verifies replay session creation, seeking, and playback

Tests use spaxel-sim CLI as the test harness and verify:
- API endpoint responses (/api/auth/setup, /api/nodes, /api/blobs, /api/events, /api/ble/devices, /api/replay/*)
- Detection accuracy thresholds (>60% blob presence during walking)
- Alert generation and webhook delivery
- Firmware version updates and rollback behavior
- Replay session lifecycle management

All tests skip by default unless ACCEPTANCE_TEST=1 or SPAXEL_INTEGRATION_TEST=1 is set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 05:45:15 -04:00

541 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package explainability provides the detection explainability API.
// This allows users to understand why a blob was detected at a specific location
// by showing per-link contributions, Fresnel zone intersections, and confidence breakdown.
package explainability
import (
"encoding/json"
"math"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-chi/chi/v5"
)
// Handler provides the explainability HTTP API.
type Handler struct {
mu sync.RWMutex
blobHistory map[int]*BlobExplanation // blobID -> explanation data
blobHistoryByTime map[int64]*BlobExplanation // timestamp -> explanation for feedback lookups
linkStates map[string]*LinkState // linkID -> link state
fusionResult *FusionResultSnapshot // latest fusion result
}
// BlobExplanation contains all data needed to explain a blob detection.
type BlobExplanation struct {
BlobID int `json:"blob_id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
Confidence float64 `json:"confidence"`
Timestamp int64 `json:"timestamp_ms"`
ContributingLinks []LinkContribution `json:"contributing_links"`
AllLinks []LinkContribution `json:"all_links"`
BLEMatch *BLEMatch `json:"ble_match,omitempty"`
FresnelZones []FresnelZone `json:"fresnel_zones"`
}
// LinkContribution describes how much a link contributed to a blob detection.
type LinkContribution struct {
LinkID string `json:"link_id"` // e.g., "AA:BB:CC:DD:EE:FF"
NodeMAC string `json:"node_mac"`
PeerMAC string `json:"peer_mac"`
DeltaRMS float64 `json:"delta_rms"`
ZoneNumber int `json:"zone_number"` // Fresnel zone number at blob position
Weight float64 `json:"weight"` // Learned weight multiplier
Contributing bool `json:"contributing"` // true if deltaRMS exceeded threshold
Contribution float64 `json:"contribution"` // amount added to fusion grid at blob position
}
// BLEMatch describes a BLE device match for the blob.
type BLEMatch struct {
PersonID string `json:"person_id"`
PersonLabel string `json:"person_label"`
PersonColor string `json:"person_color"`
DeviceAddr string `json:"device_addr"`
Confidence float64 `json:"confidence"`
MatchMethod string `json:"match_method"` // "ble_triangulation" or "ble_only"
ReportedByNodes []string `json:"reported_by_nodes"`
TriangulationPos *[3]float64 `json:"triangulation_pos,omitempty"` // [x, y, z]
}
// FresnelZone describes a Fresnel zone ellipsoid for a link.
type FresnelZone struct {
LinkID string `json:"link_id"`
CenterPos [3]float64 `json:"center_pos"` // [x, y, z] zone center
SemiAxes [3]float64 `json:"semi_axes"` // [a, b, c] for ellipsoid
ZoneNumber int `json:"zone_number"` // zone number for this blob position
TXPos [3]float64 `json:"tx_pos"` // transmitter position for proper ellipsoid orientation
RXPos [3]float64 `json:"rx_pos"` // receiver position for proper ellipsoid orientation
Lambda float64 `json:"lambda"` // WiFi wavelength in metres
}
// LinkState captures the current state of a link.
type LinkState struct {
NodeMAC string
PeerMAC string
NodePos [3]float64 // [x, y, z]
PeerPos [3]float64 // [x, y, z]
DeltaRMS float64
Motion bool
Weight float64 // Learned weight
HealthScore float64
}
// FusionResultSnapshot captures the latest fusion result for explainability.
type FusionResultSnapshot struct {
Timestamp int64
Blobs []BlobSnapshot
GridData *GridSnapshot
}
// BlobSnapshot is a lightweight blob representation.
type BlobSnapshot struct {
ID int
X, Y, Z float64
Confidence float64
Weight float64 // Peak height in the grid
}
// GridSnapshot captures the fusion grid for computing contributions.
type GridSnapshot struct {
Width, Depth, CellSize float64
OriginX, OriginZ float64
Data []float64 // Normalised [0-1] row-major grid data
Rows, Cols int
}
// NewHandler creates a new explainability handler.
func NewHandler() *Handler {
return &Handler{
blobHistory: make(map[int]*BlobExplanation),
blobHistoryByTime: make(map[int64]*BlobExplanation),
linkStates: make(map[string]*LinkState),
fusionResult: &FusionResultSnapshot{},
}
}
// UpdateBlobs updates the handler with the latest blob and link data.
// This should be called from the signal processing pipeline whenever blobs are detected.
func (h *Handler) UpdateBlobs(blobs []BlobSnapshot, links []LinkState, grid *GridSnapshot, identity map[int]*BLEMatch) {
h.mu.Lock()
defer h.mu.Unlock()
// Update fusion result snapshot
h.fusionResult = &FusionResultSnapshot{
Timestamp: time.Now().Unix(),
Blobs: blobs,
GridData: grid,
}
// Update link states
for _, link := range links {
linkID := link.NodeMAC + ":" + link.PeerMAC
h.linkStates[linkID] = &link
}
// Generate explanations for each blob
for _, blob := range blobs {
explanation := h.computeExplanation(blob, links, grid)
if bleMatch := identity[blob.ID]; bleMatch != nil {
explanation.BLEMatch = bleMatch
}
h.blobHistory[blob.ID] = explanation
// Store by timestamp for feedback lookups
timestamp := time.Now().UnixMilli()
explanation.Timestamp = timestamp
h.blobHistoryByTime[timestamp] = explanation
}
// Clean up old blob history (keep last 100)
if len(h.blobHistory) > 100 {
// Remove oldest entries (simple FIFO by recreating map with last 100)
// In practice, blob IDs are incrementing, so we can remove IDs < current - 100
var maxID int
for id := range h.blobHistory {
if id > maxID {
maxID = id
}
}
// keep IDs maxID-99 .. maxID (100 entries): delete id < maxID-99
cutoff := maxID - 99
for id := range h.blobHistory {
if id < cutoff {
delete(h.blobHistory, id)
}
}
}
}
// RegisterRoutes registers the explainability API routes.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/api/explain/{blobID}", h.explainBlob)
r.Post("/api/explain/refresh", h.refreshData)
r.Get("/api/explain/blob/{blobID}/at/{timestamp}", h.explainBlobAtTime)
}
// explainBlob handles GET /api/explain/{blobID}
func (h *Handler) explainBlob(w http.ResponseWriter, r *http.Request) {
blobIDStr := chi.URLParam(r, "blobID")
blobID, err := strconv.Atoi(blobIDStr)
if err != nil {
http.Error(w, "Invalid blob ID", http.StatusBadRequest)
return
}
h.mu.RLock()
explanation, ok := h.blobHistory[blobID]
h.mu.RUnlock()
if !ok {
// Return empty explanation for unknown blob
explanation = &BlobExplanation{
BlobID: blobID,
X: 0,
Y: 0,
Z: 0,
Confidence: 0,
}
}
writeJSON(w, explanation)
}
// explainBlobAtTime handles GET /api/explain/blob/{blobID}/at/{timestamp}
// Returns the explainability snapshot for a blob at or near a specific timestamp.
// This is used by the feedback system to explain why a detection occurred.
func (h *Handler) explainBlobAtTime(w http.ResponseWriter, r *http.Request) {
blobIDStr := chi.URLParam(r, "blobID")
blobID, err := strconv.Atoi(blobIDStr)
if err != nil {
http.Error(w, "Invalid blob ID", http.StatusBadRequest)
return
}
timestampStr := chi.URLParam(r, "timestamp")
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil {
http.Error(w, "Invalid timestamp", http.StatusBadRequest)
return
}
h.mu.RLock()
defer h.mu.RUnlock()
// First try to get by blob ID directly
if explanation, ok := h.blobHistory[blobID]; ok {
// Check if the timestamp is close (within 1 minute)
if abs64(explanation.Timestamp-timestamp) < 60000 {
writeJSON(w, explanation)
return
}
}
// If not found by blob ID or timestamp mismatch, search by timestamp
// Find the closest explanation within 1 minute
var closest *BlobExplanation
minDiff := int64(60000) // 1 minute
for _, exp := range h.blobHistoryByTime {
diff := abs64(exp.Timestamp - timestamp)
if diff < minDiff {
minDiff = diff
closest = exp
}
}
if closest != nil {
writeJSON(w, closest)
return
}
// Return empty explanation if nothing found
explanation := &BlobExplanation{
BlobID: blobID,
X: 0,
Y: 0,
Z: 0,
Confidence: 0,
Timestamp: timestamp,
}
writeJSON(w, explanation)
}
// abs64 returns the absolute value of an int64.
func abs64(x int64) int64 {
if x < 0 {
return -x
}
return x
}
// GetExplanationForBlob retrieves the explainability snapshot for a blob at or near a specific timestamp.
// This is a public method used by other handlers (like feedback) to access explainability data.
func (h *Handler) GetExplanationForBlob(blobID int, timestamp int64) *BlobExplanation {
h.mu.RLock()
defer h.mu.RUnlock()
// First try to get by blob ID directly
if explanation, ok := h.blobHistory[blobID]; ok {
// Check if the timestamp is close (within 1 minute)
if abs64(explanation.Timestamp-timestamp) < 60000 {
return explanation
}
}
// If not found by blob ID or timestamp mismatch, search by timestamp
// Find the closest explanation within 1 minute
var closest *BlobExplanation
minDiff := int64(60000) // 1 minute
for _, exp := range h.blobHistoryByTime {
diff := abs64(exp.Timestamp - timestamp)
if diff < minDiff {
minDiff = diff
closest = exp
}
}
if closest != nil {
return closest
}
// Return nil if nothing found
return nil
}
// refreshData handles POST /api/explain/refresh
// This is called by the dashboard to refresh the explainability data.
func (h *Handler) refreshData(w http.ResponseWriter, r *http.Request) {
var req struct {
Blobs []BlobSnapshot `json:"blobs"`
Links []LinkState `json:"links"`
GridData *GridSnapshot `json:"grid_data,omitempty"`
Identity map[int]*BLEMatch `json:"identity,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
h.mu.Lock()
defer h.mu.Unlock()
// Update fusion result snapshot
h.fusionResult = &FusionResultSnapshot{
Timestamp: int64(req.GridData.Rows), // placeholder
Blobs: req.Blobs,
GridData: req.GridData,
}
// Update link states
for _, link := range req.Links {
linkID := link.NodeMAC + ":" + link.PeerMAC
h.linkStates[linkID] = &link
}
// Generate explanations for each blob
for _, blob := range req.Blobs {
explanation := h.computeExplanation(blob, req.Links, req.GridData)
if bleMatch := req.Identity[blob.ID]; bleMatch != nil {
explanation.BLEMatch = bleMatch
}
h.blobHistory[blob.ID] = explanation
}
writeJSON(w, map[string]interface{}{
"updated": len(h.blobHistory),
})
}
// computeExplanation calculates the explanation for a single blob.
// grid is accepted for future use but Fresnel computation only needs blob/link positions.
func (h *Handler) computeExplanation(blob BlobSnapshot, links []LinkState, _ *GridSnapshot) *BlobExplanation {
explanation := &BlobExplanation{
BlobID: blob.ID,
X: blob.X,
Y: blob.Y,
Z: blob.Z,
Confidence: blob.Confidence,
ContributingLinks: []LinkContribution{},
AllLinks: make([]LinkContribution, 0, len(links)),
FresnelZones: []FresnelZone{},
}
// WiFi wavelength constant (2.4 GHz -> ~0.123 m)
const lambda = 0.123
const halfLambda = lambda / 2
var totalContribution float64
// Compute raw contribution for each link
for _, link := range links {
linkID := link.NodeMAC + ":" + link.PeerMAC
nodePos := [3]float64{link.NodePos[0], link.NodePos[1], link.NodePos[2]}
peerPos := [3]float64{link.PeerPos[0], link.PeerPos[1], link.PeerPos[2]}
// Path length excess at blob position: ΔL = |blob-TX| + |blob-RX| - |TX-RX|
pathDirect := math.Sqrt(
math.Pow(peerPos[0]-nodePos[0], 2) +
math.Pow(peerPos[1]-nodePos[1], 2) +
math.Pow(peerPos[2]-nodePos[2], 2))
pathViaBlob := math.Sqrt(
math.Pow(blob.X-nodePos[0], 2)+
math.Pow(blob.Y-nodePos[1], 2)+
math.Pow(blob.Z-nodePos[2], 2)) +
math.Sqrt(
math.Pow(peerPos[0]-blob.X, 2)+
math.Pow(peerPos[1]-blob.Y, 2)+
math.Pow(peerPos[2]-blob.Z, 2))
deltaL := pathViaBlob - pathDirect
// Fresnel zone number: zone = ceil(ΔL / (λ/2)), minimum 1
zoneNumber := int(math.Ceil(deltaL / halfLambda))
if zoneNumber < 1 {
zoneNumber = 1
}
// Zone decay: 1/n^decay_rate, decay_rate=2.0 per plan
zoneDecay := 1.0 / math.Pow(float64(zoneNumber), 2.0)
contributing := link.Motion && link.DeltaRMS > 0.02
// Raw contribution = deltaRMS × learned_weight × zone_decay
rawContribution := link.DeltaRMS * link.Weight * zoneDecay
linkContrib := LinkContribution{
LinkID: linkID,
NodeMAC: link.NodeMAC,
PeerMAC: link.PeerMAC,
DeltaRMS: link.DeltaRMS,
ZoneNumber: zoneNumber,
Weight: link.Weight,
Contributing: contributing,
Contribution: rawContribution, // normalized below
}
explanation.AllLinks = append(explanation.AllLinks, linkContrib)
if contributing {
totalContribution += rawContribution
// Fresnel zone ellipsoid for contributing link
centerX := (nodePos[0] + peerPos[0]) / 2
centerY := (nodePos[1] + peerPos[1]) / 2
centerZ := (nodePos[2] + peerPos[2]) / 2
// Semi-major axis along link axis; semi-minor perpendicular
a := (pathDirect + lambda) / 2
b := math.Sqrt(math.Max(0, a*a-(pathDirect/2)*(pathDirect/2)))
explanation.FresnelZones = append(explanation.FresnelZones, FresnelZone{
LinkID: linkID,
CenterPos: [3]float64{centerX, centerY, centerZ},
SemiAxes: [3]float64{b, b, a},
ZoneNumber: zoneNumber,
TXPos: nodePos,
RXPos: peerPos,
Lambda: lambda,
})
}
}
// Normalize contributions so contributing links sum to 1.0 (proper percentage breakdown).
// Non-contributing links retain their raw value for display context.
if totalContribution > 0 {
for i := range explanation.AllLinks {
if explanation.AllLinks[i].Contributing {
explanation.AllLinks[i].Contribution /= totalContribution
}
}
}
// Populate ContributingLinks from the normalized AllLinks slice
for _, lc := range explanation.AllLinks {
if lc.Contributing {
explanation.ContributingLinks = append(explanation.ContributingLinks, lc)
}
}
return explanation
}
// BuildWebSocketSnapshot returns an explanation snapshot for a blob in the
// format expected by the dashboard WebSocket handler (_transformSnapshot in
// explainability.js). Returns nil if the blob has no recorded explanation.
func (h *Handler) BuildWebSocketSnapshot(blobID int) map[string]interface{} {
h.mu.RLock()
exp, ok := h.blobHistory[blobID]
h.mu.RUnlock()
if !ok || exp == nil {
return nil
}
// Convert AllLinks to the per_link_contributions format the dashboard expects.
perLinkContribs := make([]map[string]interface{}, 0, len(exp.AllLinks))
for _, link := range exp.AllLinks {
perLinkContribs = append(perLinkContribs, map[string]interface{}{
"link_id": link.LinkID,
"tx_mac": link.NodeMAC,
"rx_mac": link.PeerMAC,
"delta_rms": link.DeltaRMS,
"zone_number": link.ZoneNumber,
"weight": link.Weight,
"learned_weight": 1.0,
"combined_weight": link.Weight,
"contribution_pct": link.Contribution * 100,
"fresnel_intersection_volume": 0,
"contributing": link.Contributing,
})
}
// Convert FresnelZones to the format the dashboard expects.
fresnelZones := make([]map[string]interface{}, 0, len(exp.FresnelZones))
for _, z := range exp.FresnelZones {
fresnelZones = append(fresnelZones, map[string]interface{}{
"link_id": z.LinkID,
"tx_pos": z.TXPos[:],
"rx_pos": z.RXPos[:],
"center_pos": z.CenterPos[:],
"semi_axes": z.SemiAxes[:],
"zone_number": z.ZoneNumber,
"lambda": z.Lambda,
})
}
snap := map[string]interface{}{
"blob_id": exp.BlobID,
"blob_position": []float64{exp.X, exp.Y, exp.Z},
"fusion_score": exp.Confidence,
"per_link_contributions": perLinkContribs,
"fresnel_zones": fresnelZones,
"timestamp": time.UnixMilli(exp.Timestamp),
}
if exp.BLEMatch != nil {
snap["ble_match"] = map[string]interface{}{
"device_mac": exp.BLEMatch.DeviceAddr,
"person_id": exp.BLEMatch.PersonID,
"person_label": exp.BLEMatch.PersonLabel,
"person_color": exp.BLEMatch.PersonColor,
"triangulation_confidence": exp.BLEMatch.Confidence,
"match_method": exp.BLEMatch.MatchMethod,
}
if exp.BLEMatch.TriangulationPos != nil {
snap["ble_match"].(map[string]interface{})["triangulation_pos"] = exp.BLEMatch.TriangulationPos[:]
}
}
return snap
}
// writeJSON writes a JSON response.
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
data, err := json.Marshal(v)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(data); //nolint:errcheck
}