Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Ran gofmt across the entire mothership codebase to ensure consistent code formatting per Go standards. All tests pass after formatting.
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
|
||
}
|