- 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>
541 lines
17 KiB
Go
541 lines
17 KiB
Go
// 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
|
||
}
|