feat(explainability): fix X-ray overlay with normalized confidence breakdown and tests
Three issues resolved in the detection explainability system: - computeExplanation returned early when grid==nil (always the case in the live fusion loop), causing all explain requests to return empty link contributions. Removed the unnecessary nil-grid guard since the Fresnel computation uses only blob/link positions, not the grid. - Contribution values were raw deltaRMS×weight×zoneDecay scalars, not percentages. Now normalized so contributing links sum to 1.0, giving the dashboard a proper confidence breakdown (60% / 40% / etc.). - Fixed off-by-one in blobHistory eviction (kept 101 instead of 100). Added 23 table-driven tests covering: nil-grid computation, single/multi-link normalization, Fresnel zone number geometry, ellipsoid generation, HTTP handlers (200/400 paths), WebSocket snapshot fields, BLE match inclusion, zone decay inverse-square law, and history eviction cap. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b7972ccb0b
commit
057285f901
2 changed files with 589 additions and 56 deletions
|
|
@ -63,10 +63,13 @@ type BLEMatch struct {
|
|||
|
||||
// FresnelZone describes a Fresnel zone ellipsoid for a link.
|
||||
type FresnelZone struct {
|
||||
LinkID string `json:"link_id"`
|
||||
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
|
||||
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.
|
||||
|
|
@ -156,7 +159,8 @@ func (h *Handler) UpdateBlobs(blobs []BlobSnapshot, links []LinkState, grid *Gri
|
|||
maxID = id
|
||||
}
|
||||
}
|
||||
cutoff := maxID - 100
|
||||
// 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)
|
||||
|
|
@ -348,18 +352,8 @@ func (h *Handler) refreshData(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// 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{},
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
|
@ -367,7 +361,7 @@ func (h *Handler) computeExplanation(blob BlobSnapshot, links []LinkState, grid
|
|||
Z: blob.Z,
|
||||
Confidence: blob.Confidence,
|
||||
ContributingLinks: []LinkContribution{},
|
||||
AllLinks: make([]LinkContribution, 0, len(links)),
|
||||
AllLinks: make([]LinkContribution, 0, len(links)),
|
||||
FresnelZones: []FresnelZone{},
|
||||
}
|
||||
|
||||
|
|
@ -375,87 +369,166 @@ func (h *Handler) computeExplanation(blob BlobSnapshot, links []LinkState, grid
|
|||
const lambda = 0.123
|
||||
const halfLambda = lambda / 2
|
||||
|
||||
// Compute contribution for each link
|
||||
var totalContribution float64
|
||||
|
||||
// Compute raw 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
|
||||
// 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.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[0]-blob.X, 2)+
|
||||
math.Pow(peerPos[1]-blob.Y, 2)+
|
||||
math.Pow(peerPos[2]-blob.Z, 2))
|
||||
deltaL := pathViaBlob - pathDirect
|
||||
|
||||
// Fresnel zone number
|
||||
// Fresnel zone number: zone = ceil(ΔL / (λ/2)), minimum 1
|
||||
zoneNumber := int(math.Ceil(deltaL / halfLambda))
|
||||
if zoneNumber < 1 {
|
||||
zoneNumber = 1
|
||||
}
|
||||
|
||||
// Zone decay function (default decay_rate = 2.0)
|
||||
// Zone decay: 1/n^decay_rate, decay_rate=2.0 per plan
|
||||
zoneDecay := 1.0 / math.Pow(float64(zoneNumber), 2.0)
|
||||
|
||||
// Contribution = deltaRMS * weight * zoneDecay
|
||||
contribution := link.DeltaRMS * link.Weight * zoneDecay
|
||||
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: link.Motion && link.DeltaRMS > 0.02,
|
||||
Contribution: contribution,
|
||||
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 link.Motion && link.DeltaRMS > 0.02 {
|
||||
explanation.ContributingLinks = append(explanation.ContributingLinks, linkContrib)
|
||||
}
|
||||
if contributing {
|
||||
totalContribution += rawContribution
|
||||
|
||||
// Add Fresnel zone ellipsoid data for contributing links
|
||||
if link.Motion && link.DeltaRMS > 0.02 {
|
||||
// Compute ellipsoid parameters
|
||||
// Center is midpoint between nodes
|
||||
// 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-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))
|
||||
|
||||
// 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{longAxis, zoneWidth, zoneWidth},
|
||||
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")
|
||||
|
|
|
|||
460
mothership/internal/explainability/handler_test.go
Normal file
460
mothership/internal/explainability/handler_test.go
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
package explainability
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newHandler() *Handler { return NewHandler() }
|
||||
|
||||
// makeLinkState returns a LinkState with TX at origin and RX along the X axis.
|
||||
func makeLinkState(nodeMAC, peerMAC string, rxX float64, deltaRMS float64, motion bool) LinkState {
|
||||
return LinkState{
|
||||
NodeMAC: nodeMAC,
|
||||
PeerMAC: peerMAC,
|
||||
NodePos: [3]float64{0, 0, 1},
|
||||
PeerPos: [3]float64{rxX, 0, 1},
|
||||
DeltaRMS: deltaRMS,
|
||||
Motion: motion,
|
||||
Weight: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
func makeBlobAt(id int, x, y, z, confidence float64) BlobSnapshot {
|
||||
return BlobSnapshot{ID: id, X: x, Y: y, Z: z, Confidence: confidence}
|
||||
}
|
||||
|
||||
// ── computeExplanation ────────────────────────────────────────────────────────
|
||||
|
||||
func TestComputeExplanation_NilGrid_StillComputesContributions(t *testing.T) {
|
||||
h := newHandler()
|
||||
blob := makeBlobAt(1, 2, 0, 1, 0.8)
|
||||
link := makeLinkState("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", 4.0, 0.10, true)
|
||||
|
||||
exp := h.computeExplanation(blob, []LinkState{link}, nil) // nil grid must not abort
|
||||
|
||||
if exp == nil {
|
||||
t.Fatal("expected non-nil explanation when grid is nil")
|
||||
}
|
||||
if len(exp.ContributingLinks) == 0 {
|
||||
t.Error("expected at least one contributing link")
|
||||
}
|
||||
if len(exp.AllLinks) == 0 {
|
||||
t.Error("expected at least one entry in AllLinks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeExplanation_NoLinks(t *testing.T) {
|
||||
h := newHandler()
|
||||
exp := h.computeExplanation(makeBlobAt(1, 1, 0, 1, 0.5), nil, nil)
|
||||
if exp == nil {
|
||||
t.Fatal("nil explanation")
|
||||
}
|
||||
if len(exp.ContributingLinks) != 0 || len(exp.AllLinks) != 0 {
|
||||
t.Error("expected empty link slices with no links")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeExplanation_SingleContributingLink_100Percent(t *testing.T) {
|
||||
h := newHandler()
|
||||
blob := makeBlobAt(1, 2, 0, 1, 0.9)
|
||||
link := makeLinkState("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", 4.0, 0.15, true)
|
||||
|
||||
exp := h.computeExplanation(blob, []LinkState{link}, nil)
|
||||
|
||||
if len(exp.ContributingLinks) != 1 {
|
||||
t.Fatalf("expected 1 contributing link, got %d", len(exp.ContributingLinks))
|
||||
}
|
||||
got := exp.ContributingLinks[0].Contribution
|
||||
if math.Abs(got-1.0) > 1e-9 {
|
||||
t.Errorf("single contributing link should have contribution=1.0, got %f", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeExplanation_TwoEqualLinks_FiftyFifty(t *testing.T) {
|
||||
h := newHandler()
|
||||
blob := makeBlobAt(1, 2, 0, 1, 0.8)
|
||||
// Two identical links placed symmetrically so they have the same deltaRMS, weight, and zone
|
||||
linkA := makeLinkState("AA:00:00:00:00:01", "AA:00:00:00:00:02", 4.0, 0.10, true)
|
||||
linkB := makeLinkState("BB:00:00:00:00:01", "BB:00:00:00:00:02", 4.0, 0.10, true)
|
||||
// Both links have identical geometry → identical contribution
|
||||
|
||||
exp := h.computeExplanation(blob, []LinkState{linkA, linkB}, nil)
|
||||
|
||||
if len(exp.ContributingLinks) != 2 {
|
||||
t.Fatalf("expected 2 contributing links, got %d", len(exp.ContributingLinks))
|
||||
}
|
||||
for _, lc := range exp.ContributingLinks {
|
||||
if math.Abs(lc.Contribution-0.5) > 1e-9 {
|
||||
t.Errorf("expected each link contribution=0.5, got %f", lc.Contribution)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeExplanation_NonContributingLinkExcluded(t *testing.T) {
|
||||
h := newHandler()
|
||||
blob := makeBlobAt(1, 2, 0, 1, 0.7)
|
||||
contributing := makeLinkState("AA:00:00:00:00:01", "AA:00:00:00:00:02", 4.0, 0.10, true)
|
||||
nonContrib := makeLinkState("BB:00:00:00:00:01", "BB:00:00:00:00:02", 4.0, 0.005, false)
|
||||
|
||||
exp := h.computeExplanation(blob, []LinkState{contributing, nonContrib}, nil)
|
||||
|
||||
if len(exp.ContributingLinks) != 1 {
|
||||
t.Fatalf("expected 1 contributing link, got %d", len(exp.ContributingLinks))
|
||||
}
|
||||
if len(exp.AllLinks) != 2 {
|
||||
t.Fatalf("expected 2 entries in AllLinks, got %d", len(exp.AllLinks))
|
||||
}
|
||||
// Contributing link must have contribution = 1.0 after normalization
|
||||
if math.Abs(exp.ContributingLinks[0].Contribution-1.0) > 1e-9 {
|
||||
t.Errorf("expected contributing link contribution=1.0, got %f", exp.ContributingLinks[0].Contribution)
|
||||
}
|
||||
// Non-contributing link keeps its raw value (not normalized)
|
||||
for _, lc := range exp.AllLinks {
|
||||
if !lc.Contributing {
|
||||
if lc.Contribution < 0 {
|
||||
t.Error("non-contributing link contribution should be non-negative")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeExplanation_FresnelZoneNumber(t *testing.T) {
|
||||
// Place TX at (0,0,0) and RX at (4,0,0). Blob at (2,0,0) = midpoint.
|
||||
// At midpoint, pathViaBlob = 2+2 = 4m = pathDirect = 4m, so ΔL = 0 → zone 1
|
||||
h := newHandler()
|
||||
blob := BlobSnapshot{ID: 1, X: 2, Y: 0, Z: 0, Confidence: 0.8}
|
||||
link := LinkState{
|
||||
NodeMAC: "AA:00:00:00:00:01",
|
||||
PeerMAC: "AA:00:00:00:00:02",
|
||||
NodePos: [3]float64{0, 0, 0},
|
||||
PeerPos: [3]float64{4, 0, 0},
|
||||
DeltaRMS: 0.10,
|
||||
Motion: true,
|
||||
Weight: 1.0,
|
||||
}
|
||||
|
||||
exp := h.computeExplanation(blob, []LinkState{link}, nil)
|
||||
|
||||
if len(exp.ContributingLinks) != 1 {
|
||||
t.Fatalf("expected 1 contributing link, got %d", len(exp.ContributingLinks))
|
||||
}
|
||||
if exp.ContributingLinks[0].ZoneNumber != 1 {
|
||||
t.Errorf("expected zone 1 for midpoint blob, got %d", exp.ContributingLinks[0].ZoneNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeExplanation_FresnelEllipsoidAddedForContributingLink(t *testing.T) {
|
||||
h := newHandler()
|
||||
blob := makeBlobAt(1, 2, 0, 1, 0.8)
|
||||
link := makeLinkState("AA:00:00:00:00:01", "AA:00:00:00:00:02", 4.0, 0.10, true)
|
||||
|
||||
exp := h.computeExplanation(blob, []LinkState{link}, nil)
|
||||
|
||||
if len(exp.FresnelZones) != 1 {
|
||||
t.Fatalf("expected 1 Fresnel zone, got %d", len(exp.FresnelZones))
|
||||
}
|
||||
fz := exp.FresnelZones[0]
|
||||
if fz.Lambda != 0.123 {
|
||||
t.Errorf("expected lambda=0.123, got %f", fz.Lambda)
|
||||
}
|
||||
// Semi-axes must be positive
|
||||
for i, ax := range fz.SemiAxes {
|
||||
if ax <= 0 {
|
||||
t.Errorf("SemiAxes[%d] must be > 0, got %f", i, ax)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeExplanation_NonContributingLinkNoEllipsoid(t *testing.T) {
|
||||
h := newHandler()
|
||||
blob := makeBlobAt(1, 2, 0, 1, 0.8)
|
||||
link := makeLinkState("AA:00:00:00:00:01", "AA:00:00:00:00:02", 4.0, 0.005, false)
|
||||
|
||||
exp := h.computeExplanation(blob, []LinkState{link}, nil)
|
||||
|
||||
if len(exp.FresnelZones) != 0 {
|
||||
t.Errorf("expected no Fresnel zones for non-contributing link, got %d", len(exp.FresnelZones))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeExplanation_ConfidencePropagated(t *testing.T) {
|
||||
h := newHandler()
|
||||
const want = 0.73
|
||||
blob := makeBlobAt(1, 2, 0, 1, want)
|
||||
exp := h.computeExplanation(blob, nil, nil)
|
||||
if math.Abs(exp.Confidence-want) > 1e-9 {
|
||||
t.Errorf("expected confidence=%f, got %f", want, exp.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateBlobs / BuildWebSocketSnapshot round-trip ───────────────────────────
|
||||
|
||||
func TestUpdateBlobs_PopulatesBlobHistory(t *testing.T) {
|
||||
h := newHandler()
|
||||
blobs := []BlobSnapshot{makeBlobAt(42, 1, 0, 1, 0.8)}
|
||||
links := []LinkState{makeLinkState("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", 4, 0.12, true)}
|
||||
|
||||
h.UpdateBlobs(blobs, links, nil, nil)
|
||||
|
||||
h.mu.RLock()
|
||||
_, ok := h.blobHistory[42]
|
||||
h.mu.RUnlock()
|
||||
if !ok {
|
||||
t.Error("blob 42 not found in blobHistory after UpdateBlobs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebSocketSnapshot_ContainsRequiredFields(t *testing.T) {
|
||||
h := newHandler()
|
||||
blobs := []BlobSnapshot{makeBlobAt(7, 2, 0, 1, 0.9)}
|
||||
links := []LinkState{makeLinkState("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", 4, 0.15, true)}
|
||||
|
||||
h.UpdateBlobs(blobs, links, nil, nil)
|
||||
snap := h.BuildWebSocketSnapshot(7)
|
||||
|
||||
if snap == nil {
|
||||
t.Fatal("expected non-nil snapshot for known blob")
|
||||
}
|
||||
if _, ok := snap["blob_id"]; !ok {
|
||||
t.Error("snapshot missing blob_id")
|
||||
}
|
||||
if _, ok := snap["fusion_score"]; !ok {
|
||||
t.Error("snapshot missing fusion_score")
|
||||
}
|
||||
if _, ok := snap["per_link_contributions"]; !ok {
|
||||
t.Error("snapshot missing per_link_contributions")
|
||||
}
|
||||
if _, ok := snap["fresnel_zones"]; !ok {
|
||||
t.Error("snapshot missing fresnel_zones")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebSocketSnapshot_ContributionPctNormalized(t *testing.T) {
|
||||
// Two equal contributing links → each should have contribution_pct ≈ 50
|
||||
h := newHandler()
|
||||
blobs := []BlobSnapshot{makeBlobAt(1, 2, 0, 1, 0.8)}
|
||||
links := []LinkState{
|
||||
makeLinkState("AA:00:00:00:00:01", "AA:00:00:00:00:02", 4, 0.10, true),
|
||||
makeLinkState("BB:00:00:00:00:01", "BB:00:00:00:00:02", 4, 0.10, true),
|
||||
}
|
||||
|
||||
h.UpdateBlobs(blobs, links, nil, nil)
|
||||
snap := h.BuildWebSocketSnapshot(1)
|
||||
|
||||
contribs, ok := snap["per_link_contributions"].([]map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("per_link_contributions has wrong type: %T", snap["per_link_contributions"])
|
||||
}
|
||||
var sumPct float64
|
||||
for _, c := range contribs {
|
||||
pct, _ := c["contribution_pct"].(float64)
|
||||
if c["contributing"] == true {
|
||||
sumPct += pct
|
||||
}
|
||||
}
|
||||
if math.Abs(sumPct-100.0) > 0.1 {
|
||||
t.Errorf("contributing link contribution_pct should sum to 100, got %f", sumPct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebSocketSnapshot_UnknownBlob_ReturnsNil(t *testing.T) {
|
||||
h := newHandler()
|
||||
snap := h.BuildWebSocketSnapshot(9999)
|
||||
if snap != nil {
|
||||
t.Error("expected nil for unknown blob ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebSocketSnapshot_BLEMatch_Included(t *testing.T) {
|
||||
h := newHandler()
|
||||
blobs := []BlobSnapshot{makeBlobAt(3, 1, 0, 1, 0.85)}
|
||||
triPos := [3]float64{1.1, 0.1, 1.0}
|
||||
identity := map[int]*BLEMatch{
|
||||
3: {
|
||||
PersonID: "alice",
|
||||
PersonLabel: "Alice",
|
||||
PersonColor: "#4488ff",
|
||||
DeviceAddr: "AA:BB:CC:DD:EE:FF",
|
||||
Confidence: 0.92,
|
||||
MatchMethod: "ble_rssi",
|
||||
TriangulationPos: &triPos,
|
||||
},
|
||||
}
|
||||
|
||||
h.UpdateBlobs(blobs, nil, nil, identity)
|
||||
snap := h.BuildWebSocketSnapshot(3)
|
||||
|
||||
if snap == nil {
|
||||
t.Fatal("nil snapshot")
|
||||
}
|
||||
bleMatch, ok := snap["ble_match"]
|
||||
if !ok || bleMatch == nil {
|
||||
t.Error("expected ble_match field in snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetExplanationForBlob ─────────────────────────────────────────────────────
|
||||
|
||||
func TestGetExplanationForBlob_FoundByID(t *testing.T) {
|
||||
h := newHandler()
|
||||
blobs := []BlobSnapshot{makeBlobAt(55, 1, 0, 1, 0.7)}
|
||||
h.UpdateBlobs(blobs, nil, nil, nil)
|
||||
|
||||
// Timestamp within 1 minute of now
|
||||
exp := h.GetExplanationForBlob(55, 0)
|
||||
// We don't assert non-nil here because the timestamp mismatch causes a fallback search.
|
||||
// Instead test via the primary path:
|
||||
h.mu.RLock()
|
||||
stored := h.blobHistory[55]
|
||||
h.mu.RUnlock()
|
||||
if stored == nil {
|
||||
t.Fatal("blob 55 not in history")
|
||||
}
|
||||
ts := stored.Timestamp
|
||||
exp = h.GetExplanationForBlob(55, ts)
|
||||
if exp == nil {
|
||||
t.Fatal("expected non-nil explanation for correct timestamp")
|
||||
}
|
||||
if exp.BlobID != 55 {
|
||||
t.Errorf("expected blob_id=55, got %d", exp.BlobID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExplanationForBlob_UnknownID_ReturnsNil(t *testing.T) {
|
||||
h := newHandler()
|
||||
exp := h.GetExplanationForBlob(9999, 0)
|
||||
if exp != nil {
|
||||
t.Error("expected nil for unknown blob ID with distant timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
func registerAndServe(h *Handler, method, path string, r *http.Request) *httptest.ResponseRecorder {
|
||||
router := chi.NewRouter()
|
||||
h.RegisterRoutes(router)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestHTTP_ExplainBlob_KnownBlob(t *testing.T) {
|
||||
h := newHandler()
|
||||
blobs := []BlobSnapshot{makeBlobAt(10, 3, 0, 1, 0.75)}
|
||||
links := []LinkState{makeLinkState("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", 4, 0.12, true)}
|
||||
h.UpdateBlobs(blobs, links, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/explain/10", nil)
|
||||
w := registerAndServe(h, http.MethodGet, "/api/explain/10", req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp BlobExplanation
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.BlobID != 10 {
|
||||
t.Errorf("expected blob_id=10, got %d", resp.BlobID)
|
||||
}
|
||||
if resp.Confidence != 0.75 {
|
||||
t.Errorf("expected confidence=0.75, got %f", resp.Confidence)
|
||||
}
|
||||
if len(resp.ContributingLinks) == 0 {
|
||||
t.Error("expected contributing links in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_ExplainBlob_UnknownBlob_ReturnsEmptyExplanation(t *testing.T) {
|
||||
h := newHandler()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/explain/999", nil)
|
||||
w := registerAndServe(h, http.MethodGet, "/api/explain/999", req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp BlobExplanation
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp.BlobID != 999 {
|
||||
t.Errorf("expected blob_id=999 in empty response, got %d", resp.BlobID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_ExplainBlob_InvalidID_Returns400(t *testing.T) {
|
||||
h := newHandler()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/explain/not-a-number", nil)
|
||||
w := registerAndServe(h, http.MethodGet, "/api/explain/not-a-number", req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_ExplainBlobAtTime_InvalidBlobID_Returns400(t *testing.T) {
|
||||
h := newHandler()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/explain/blob/bad/at/1000", nil)
|
||||
w := registerAndServe(h, http.MethodGet, "/api/explain/blob/bad/at/1000", req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_ExplainBlobAtTime_InvalidTimestamp_Returns400(t *testing.T) {
|
||||
h := newHandler()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/explain/blob/1/at/bad-ts", nil)
|
||||
w := registerAndServe(h, http.MethodGet, "/api/explain/blob/1/at/bad-ts", req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ZoneDecay invariants ──────────────────────────────────────────────────────
|
||||
|
||||
func TestZoneDecay_InverseSquareLaw(t *testing.T) {
|
||||
// For decay_rate=2, zone_decay(n) = 1/n². Verify for zones 1-5.
|
||||
cases := []struct {
|
||||
zone int
|
||||
want float64
|
||||
}{
|
||||
{1, 1.0},
|
||||
{2, 0.25},
|
||||
{3, 1.0 / 9.0},
|
||||
{4, 1.0 / 16.0},
|
||||
{5, 1.0 / 25.0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := 1.0 / math.Pow(float64(tc.zone), 2.0)
|
||||
if math.Abs(got-tc.want) > 1e-9 {
|
||||
t.Errorf("zone %d: expected %f, got %f", tc.zone, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Old history eviction ──────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateBlobs_EvictsOldEntries(t *testing.T) {
|
||||
h := newHandler()
|
||||
|
||||
// Insert 110 blobs (triggers eviction at >100)
|
||||
for i := 0; i < 110; i++ {
|
||||
blobs := []BlobSnapshot{makeBlobAt(i, 0, 0, 0, 0.5)}
|
||||
h.UpdateBlobs(blobs, nil, nil, nil)
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
count := len(h.blobHistory)
|
||||
h.mu.RUnlock()
|
||||
|
||||
if count > 100 {
|
||||
t.Errorf("blobHistory should be capped at 100 after eviction, got %d", count)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue