Detects when user changes same config setting 3+ times within 24 hours. Shows non-intrusive prompt offering help with guided calibration flow. Guided calibration features: - Test for false positives (walk around room) - Test for missed motion (sit still) - Suggest optimal value based on diurnal baseline SNR and link health - Apply suggested value button Files: - dashboard/js/proactive.js: Complete implementation with localStorage tracking Acceptance: - Help prompt fires after 3+ changes in 24h - Calibration flow tests both directions - Suggests value based on system data - Apply button works
468 lines
14 KiB
Go
468 lines
14 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
cutoff := maxID - 100
|
|
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.
|
|
func (h *Handler) computeExplanation(blob BlobSnapshot, links []LinkState, grid *GridSnapshot) *BlobExplanation {
|
|
if grid == nil {
|
|
return &BlobExplanation{
|
|
BlobID: blob.ID,
|
|
X: blob.X,
|
|
Y: blob.Y,
|
|
Z: blob.Z,
|
|
Confidence: blob.Confidence,
|
|
AllLinks: []LinkContribution{},
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
// Compute contribution for each link
|
|
for _, link := range links {
|
|
linkID := link.NodeMAC + ":" + link.PeerMAC
|
|
|
|
// Get node positions
|
|
nodePos := [3]float64{link.NodePos[0], link.NodePos[1], link.NodePos[2]}
|
|
peerPos := [3]float64{link.PeerPos[0], link.PeerPos[1], link.PeerPos[2]}
|
|
|
|
// Calculate path length excess at blob position
|
|
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
|
|
zoneNumber := int(math.Ceil(deltaL / halfLambda))
|
|
if zoneNumber < 1 {
|
|
zoneNumber = 1
|
|
}
|
|
|
|
// Zone decay function (default decay_rate = 2.0)
|
|
zoneDecay := 1.0 / math.Pow(float64(zoneNumber), 2.0)
|
|
|
|
// Contribution = deltaRMS * weight * zoneDecay
|
|
contribution := link.DeltaRMS * link.Weight * zoneDecay
|
|
|
|
linkContrib := LinkContribution{
|
|
LinkID: linkID,
|
|
NodeMAC: link.NodeMAC,
|
|
PeerMAC: link.PeerMAC,
|
|
DeltaRMS: link.DeltaRMS,
|
|
ZoneNumber: zoneNumber,
|
|
Weight: link.Weight,
|
|
Contributing: link.Motion && link.DeltaRMS > 0.02,
|
|
Contribution: contribution,
|
|
}
|
|
|
|
explanation.AllLinks = append(explanation.AllLinks, linkContrib)
|
|
|
|
if link.Motion && link.DeltaRMS > 0.02 {
|
|
explanation.ContributingLinks = append(explanation.ContributingLinks, linkContrib)
|
|
}
|
|
|
|
// Add Fresnel zone ellipsoid data for contributing links
|
|
if link.Motion && link.DeltaRMS > 0.02 {
|
|
// Compute ellipsoid parameters
|
|
// Center is midpoint between nodes
|
|
centerX := (nodePos[0] + peerPos[0]) / 2
|
|
centerY := (nodePos[1] + peerPos[1]) / 2
|
|
centerZ := (nodePos[2] + peerPos[2]) / 2
|
|
|
|
// Semi-axes approximation for first Fresnel zone
|
|
// The ellipsoid is roughly centered at the midpoint, with the
|
|
// long axis along the link direction
|
|
linkLength := pathDirect
|
|
longAxis := linkLength / 2
|
|
// Width of Fresnel zone at this distance
|
|
zoneWidth := 2 * math.Sqrt(math.Pow(lambda*float64(zoneNumber)/2, 2) -
|
|
math.Pow(float64(zoneNumber-1)*lambda/2, 2))
|
|
|
|
explanation.FresnelZones = append(explanation.FresnelZones, FresnelZone{
|
|
LinkID: linkID,
|
|
CenterPos: [3]float64{centerX, centerY, centerZ},
|
|
SemiAxes: [3]float64{longAxis, zoneWidth, zoneWidth},
|
|
ZoneNumber: zoneNumber,
|
|
})
|
|
}
|
|
}
|
|
|
|
return explanation
|
|
}
|
|
|
|
// 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)
|
|
}
|