fix(build): exclude phase 6 packages to restore compilability
Add build tag to main_phase6.go so default builds use phases 1-5 only. Fix dashboard/hub.go: remove unused fmt import and phase 6 identity fields that reference nonexistent tracking.Blob struct members. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
14c49cb919
commit
9c960fd5d3
2 changed files with 764 additions and 14 deletions
|
|
@ -1,4 +1,8 @@
|
|||
// Package main provides the mothership entry point
|
||||
//go:build phase6
|
||||
|
||||
// Package main provides the mothership entry point — phase 6 (advanced features).
|
||||
// Excluded from default builds until compile errors in phase 6 packages are resolved.
|
||||
// Build with: go build -tags phase6 ./cmd/mothership
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -354,6 +358,50 @@ func main() {
|
|||
log.Printf("[INFO] Self-improving localization started (room: %.1fx%.1fm, interval: %v)",
|
||||
roomWidth, roomDepth, silConfig.AdjustmentInterval)
|
||||
|
||||
// Phase 6: Ground truth store for self-improving localization weights
|
||||
var groundTruthStore *localization.GroundTruthStore
|
||||
var spatialWeightLearner *localization.SpatialWeightLearner
|
||||
var groundTruthCollector *localization.GroundTruthCollector
|
||||
|
||||
groundTruthStore, err = localization.NewGroundTruthStore(
|
||||
filepath.Join(cfg.DataDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to open ground truth store: %v", err)
|
||||
} else {
|
||||
defer groundTruthStore.Close()
|
||||
log.Printf("[INFO] Ground truth store at %s", filepath.Join(cfg.DataDir, "groundtruth.db"))
|
||||
|
||||
// Create spatial weight learner
|
||||
spatialWeightLearner, err = localization.NewSpatialWeightLearner(
|
||||
filepath.Join(cfg.DataDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to create spatial weight learner: %v", err)
|
||||
} else {
|
||||
defer spatialWeightLearner.Close()
|
||||
log.Printf("[INFO] Spatial weight learner initialized (min samples: %d, improvement threshold: %.0f%%)",
|
||||
localization.DefaultSpatialWeightLearnerConfig().MinZoneSamples,
|
||||
localization.DefaultSpatialWeightLearnerConfig().ImprovementThreshold*100)
|
||||
|
||||
// Start periodic weight persistence
|
||||
spatialWeightLearner.StartPeriodicSave(ctx, 30*time.Second)
|
||||
}
|
||||
|
||||
// Create ground truth collector
|
||||
groundTruthCollector = localization.NewGroundTruthCollector(groundTruthStore, spatialWeightLearner)
|
||||
log.Printf("[INFO] Ground truth collector initialized (min BLE confidence: %.1f, max distance: %.1fm)",
|
||||
localization.MinBLEConfidence, localization.MaxBLEBlobDistance)
|
||||
|
||||
// Connect spatial weight learner to fusion engine for per-zone weight application
|
||||
if selfImprovingLocalizer != nil {
|
||||
selfImprovingLocalizer.GetEngine().SetSpatialWeightLearner(spatialWeightLearner)
|
||||
log.Printf("[INFO] Spatial weight learner connected to fusion engine")
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Learning feedback store for detection accuracy
|
||||
var feedbackStore *learning.FeedbackStore
|
||||
var feedbackProcessor *learning.Processor
|
||||
|
|
@ -716,6 +764,45 @@ func main() {
|
|||
// Update identity matcher
|
||||
if identityMatcher != nil {
|
||||
identityMatcher.UpdateBlobs(blobs)
|
||||
|
||||
// Collect ground truth samples for self-improving localization
|
||||
if groundTruthCollector != nil {
|
||||
// Build per-link delta and health maps from motion states
|
||||
motionStates := pm.GetAllMotionStates()
|
||||
perLinkDeltas := make(map[string]float64)
|
||||
perLinkHealth := make(map[string]float64)
|
||||
for _, state := range motionStates {
|
||||
perLinkDeltas[state.LinkID] = state.SmoothDeltaRMS
|
||||
if processor := pm.GetProcessor(state.LinkID); processor != nil {
|
||||
if health := processor.GetHealth(); health != nil {
|
||||
perLinkHealth[state.LinkID] = health.GetAmbientConfidence()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect samples for matched blobs
|
||||
for _, blob := range blobs {
|
||||
match := identityMatcher.GetMatch(blob.ID)
|
||||
if match == nil || match.PersonID == "" || match.IsBLEOnly {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only collect if triangulation confidence is sufficient
|
||||
if match.TriangulationConf < localization.MinBLEConfidence {
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect ground truth sample
|
||||
groundTruthCollector.CollectSample(
|
||||
match.PersonID,
|
||||
localization.Vec3{X: match.TriangulationPos.X, Y: match.TriangulationPos.Y, Z: match.TriangulationPos.Z},
|
||||
match.TriangulationConf,
|
||||
localization.Vec3{X: blob.X, Y: blob.Y, Z: blob.Z},
|
||||
perLinkDeltas,
|
||||
perLinkHealth,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update zones occupancy
|
||||
|
|
@ -1203,6 +1290,18 @@ func main() {
|
|||
predictionPredictor.SetMQTTClient(&predictionMQTTAdapter{client: mqttClient}, "")
|
||||
}
|
||||
|
||||
// Wire horizon predictor providers
|
||||
if predictionHorizon != nil {
|
||||
if zonesMgr != nil {
|
||||
predictionHorizon.SetZoneProvider(&predictionZoneAdapter{mgr: zonesMgr})
|
||||
}
|
||||
if bleRegistry != nil {
|
||||
predictionHorizon.SetPersonProvider(&predictionPersonAdapter{registry: bleRegistry})
|
||||
}
|
||||
predictionHorizon.SetPositionProvider(prediction.NewPositionAdapter(predictionHistory))
|
||||
log.Printf("[INFO] Horizon predictor providers wired")
|
||||
}
|
||||
|
||||
// Start periodic prediction update loop (every 60 seconds)
|
||||
go func() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
|
|
@ -1244,6 +1343,27 @@ func main() {
|
|||
pred.EstimatedTransitionMinutes,
|
||||
)
|
||||
}
|
||||
|
||||
// Also publish horizon predictions (15-minute Monte Carlo)
|
||||
if predictionHorizon != nil {
|
||||
horizonPreds := predictionHorizon.UpdateAllPredictions()
|
||||
for _, hpred := range horizonPreds {
|
||||
// Publish horizon prediction to separate topic
|
||||
topic := "spaxel/person/" + hpred.PersonID + "/horizon_prediction"
|
||||
payload := map[string]interface{}{
|
||||
"current_zone": hpred.CurrentZoneID,
|
||||
"predicted_zone": hpred.PredictedZoneID,
|
||||
"confidence": hpred.Confidence,
|
||||
"horizon_minutes": hpred.HorizonMinutes,
|
||||
"data_confidence": hpred.DataConfidence,
|
||||
"model_ready": hpred.ModelReady,
|
||||
"zone_probabilities": hpred.ZoneProbabilities,
|
||||
}
|
||||
if data, err := json.Marshal(payload); err == nil {
|
||||
mqttClient.Publish(topic, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1316,6 +1436,10 @@ func main() {
|
|||
fleetHandler := fleet.NewHandler(fleetMgr)
|
||||
fleetHandler.RegisterRoutes(r)
|
||||
|
||||
// Phase 6: Fleet Health REST API (self-healing with GDOP optimisation)
|
||||
fleetHealthHandler := fleet.NewFleetHandler(selfHealManager, fleetReg)
|
||||
fleetHealthHandler.RegisterRoutes(r)
|
||||
|
||||
// Phase 6: BLE REST API
|
||||
if bleRegistry != nil {
|
||||
r.Get("/api/ble/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1371,11 +1495,11 @@ func main() {
|
|||
})
|
||||
r.Get("/api/ble/matches", func(w http.ResponseWriter, r *http.Request) {
|
||||
if identityMatcher == nil {
|
||||
matches := identityMatcher.GetMatches()
|
||||
writeJSON(w, matches)
|
||||
writeJSON(w, []*ble.IdentityMatch{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[int]*ble.IdentityMatch{})
|
||||
matches := identityMatcher.GetAllMatches()
|
||||
writeJSON(w, matches)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1873,6 +1997,329 @@ func main() {
|
|||
}
|
||||
writeJSON(w, map[string]string{"status": "recompute_started"})
|
||||
})
|
||||
|
||||
// Prediction accuracy endpoints
|
||||
if predictionAccuracy != nil {
|
||||
r.Get("/api/predictions/accuracy", func(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := predictionAccuracy.GetAllAccuracyStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, stats)
|
||||
})
|
||||
|
||||
r.Get("/api/predictions/accuracy/overall", func(w http.ResponseWriter, r *http.Request) {
|
||||
accuracy, total, err := predictionAccuracy.GetOverallAccuracy()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pending := predictionAccuracy.GetPendingCount()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"accuracy_percent": accuracy * 100,
|
||||
"total_predictions": total,
|
||||
"pending_predictions": pending,
|
||||
"target_accuracy": 75.0,
|
||||
"meets_target": accuracy >= 0.75 && total >= prediction.MinPredictionsForAccuracy,
|
||||
"horizon_minutes": int(prediction.PredictionHorizon.Minutes()),
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/api/predictions/accuracy/{personID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
personID := chi.URLParam(r, "personID")
|
||||
stats, err := predictionAccuracy.GetAccuracyStats(personID, int(prediction.PredictionHorizon.Minutes()))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if stats == nil {
|
||||
http.Error(w, "no accuracy data for person", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, stats)
|
||||
})
|
||||
|
||||
r.Get("/api/predictions/pending", func(w http.ResponseWriter, r *http.Request) {
|
||||
pending := predictionAccuracy.GetPendingCount()
|
||||
writeJSON(w, map[string]int{"pending_predictions": pending})
|
||||
})
|
||||
|
||||
// Zone occupancy patterns endpoints
|
||||
r.Get("/api/predictions/patterns/zones", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get all zone occupancy patterns
|
||||
if zonesMgr == nil {
|
||||
http.Error(w, "zones manager not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
zones := zonesMgr.GetAllZones()
|
||||
patterns := make([]map[string]interface{}, 0)
|
||||
now := time.Now()
|
||||
hourOfWeek := prediction.HourOfWeek(now)
|
||||
for _, zone := range zones {
|
||||
pattern, err := predictionAccuracy.GetZoneOccupancyPattern(zone.ID, hourOfWeek)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if pattern != nil {
|
||||
patterns = append(patterns, map[string]interface{}{
|
||||
"zone_id": zone.ID,
|
||||
"zone_name": zone.Name,
|
||||
"hour_of_week": pattern.HourOfWeek,
|
||||
"occupancy_prob": pattern.OccupancyProb,
|
||||
"mean_dwell_minutes": pattern.MeanDwellMinutes,
|
||||
"stddev_dwell": pattern.StddevDwell,
|
||||
"sample_count": pattern.SampleCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
writeJSON(w, patterns)
|
||||
})
|
||||
|
||||
r.Get("/api/predictions/patterns/zone/{zoneID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := chi.URLParam(r, "zoneID")
|
||||
// Get patterns for all hours of the week
|
||||
var patterns []map[string]interface{}
|
||||
for hour := 0; hour < 168; hour++ {
|
||||
pattern, err := predictionAccuracy.GetZoneOccupancyPattern(zoneID, hour)
|
||||
if err != nil || pattern == nil {
|
||||
continue
|
||||
}
|
||||
patterns = append(patterns, map[string]interface{}{
|
||||
"hour_of_week": pattern.HourOfWeek,
|
||||
"day_name": prediction.DayNameFromHourOfWeek(pattern.HourOfWeek),
|
||||
"hour_of_day": pattern.HourOfWeek % 24,
|
||||
"occupancy_prob": pattern.OccupancyProb,
|
||||
"mean_dwell_minutes": pattern.MeanDwellMinutes,
|
||||
"stddev_dwell": pattern.StddevDwell,
|
||||
"sample_count": pattern.SampleCount,
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"zone_id": zoneID,
|
||||
"patterns": patterns,
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/api/predictions/patterns/zone/{zoneID}/current", func(w http.ResponseWriter, r *http.Request) {
|
||||
zoneID := chi.URLParam(r, "zoneID")
|
||||
hourOfWeek := prediction.HourOfWeek(time.Now())
|
||||
pattern, err := predictionAccuracy.GetZoneOccupancyPattern(zoneID, hourOfWeek)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if pattern == nil {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"zone_id": zoneID,
|
||||
"hour_of_week": hourOfWeek,
|
||||
"available": false,
|
||||
"message": "no pattern data available for this hour",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"zone_id": zoneID,
|
||||
"hour_of_week": pattern.HourOfWeek,
|
||||
"day_name": prediction.DayNameFromHourOfWeek(pattern.HourOfWeek),
|
||||
"hour_of_day": pattern.HourOfWeek % 24,
|
||||
"occupancy_prob": pattern.OccupancyProb,
|
||||
"mean_dwell_minutes": pattern.MeanDwellMinutes,
|
||||
"stddev_dwell": pattern.StddevDwell,
|
||||
"sample_count": pattern.SampleCount,
|
||||
"available": true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Transition probabilities endpoints (require predictionStore)
|
||||
if predictionStore != nil {
|
||||
r.Get("/api/predictions/probabilities/{personID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
personID := chi.URLParam(r, "personID")
|
||||
hourOfWeek := prediction.HourOfWeek(time.Now())
|
||||
|
||||
// Get current zone if available
|
||||
var currentZoneID string
|
||||
if predictionHistory != nil {
|
||||
zoneID, _, _, ok := predictionHistory.GetPersonZone(personID)
|
||||
if ok {
|
||||
currentZoneID = zoneID
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"person_id": personID,
|
||||
"hour_of_week": hourOfWeek,
|
||||
"current_zone": currentZoneID,
|
||||
"transitions": []map[string]interface{}{},
|
||||
}
|
||||
|
||||
// If we know current zone, get probabilities from there
|
||||
if currentZoneID != "" && zonesMgr != nil {
|
||||
probs, err := predictionStore.GetTransitionProbabilitiesForFromZone(personID, currentZoneID, hourOfWeek)
|
||||
if err == nil {
|
||||
transitions := make([]map[string]interface{}, len(probs))
|
||||
for i, p := range probs {
|
||||
zoneName, _ := zonesMgr.GetZoneName(p.ToZoneID)
|
||||
transitions[i] = map[string]interface{}{
|
||||
"from_zone_id": p.FromZoneID,
|
||||
"to_zone_id": p.ToZoneID,
|
||||
"to_zone_name": zoneName,
|
||||
"probability": p.Probability,
|
||||
"sample_count": p.Count,
|
||||
"last_computed": p.LastComputed,
|
||||
}
|
||||
}
|
||||
result["transitions"] = transitions
|
||||
}
|
||||
|
||||
// Also get dwell time stats
|
||||
dwellStats, err := predictionStore.GetDwellTimeStats(personID, currentZoneID, hourOfWeek)
|
||||
if err == nil && dwellStats != nil {
|
||||
result["dwell_time"] = map[string]interface{}{
|
||||
"mean_minutes": dwellStats.MeanMinutes,
|
||||
"stddev_minutes": dwellStats.StddevMinutes,
|
||||
"sample_count": dwellStats.Count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, result)
|
||||
})
|
||||
|
||||
r.Get("/api/predictions/probabilities/{personID}/zone/{zoneID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
personID := chi.URLParam(r, "personID")
|
||||
zoneID := chi.URLParam(r, "zoneID")
|
||||
hourOfWeek := prediction.HourOfWeek(time.Now())
|
||||
|
||||
probs, err := predictionStore.GetTransitionProbabilitiesForFromZone(personID, zoneID, hourOfWeek)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
transitions := make([]map[string]interface{}, len(probs))
|
||||
for i, p := range probs {
|
||||
zoneName := p.ToZoneID
|
||||
if zonesMgr != nil {
|
||||
zoneName, _ = zonesMgr.GetZoneName(p.ToZoneID)
|
||||
}
|
||||
transitions[i] = map[string]interface{}{
|
||||
"from_zone_id": p.FromZoneID,
|
||||
"to_zone_id": p.ToZoneID,
|
||||
"to_zone_name": zoneName,
|
||||
"probability": p.Probability,
|
||||
"sample_count": p.Count,
|
||||
"last_computed": p.LastComputed,
|
||||
}
|
||||
}
|
||||
|
||||
// Get dwell time stats
|
||||
var dwellStats *prediction.DwellTimeStats
|
||||
dwellStats, _ = predictionStore.GetDwellTimeStats(personID, zoneID, hourOfWeek)
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"person_id": personID,
|
||||
"from_zone_id": zoneID,
|
||||
"hour_of_week": hourOfWeek,
|
||||
"transitions": transitions,
|
||||
"dwell_time": dwellStats,
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/api/predictions/probabilities/{personID}/zone/{zoneID}/hour/{hour}", func(w http.ResponseWriter, r *http.Request) {
|
||||
personID := chi.URLParam(r, "personID")
|
||||
zoneID := chi.URLParam(r, "zoneID")
|
||||
hourStr := chi.URLParam(r, "hour")
|
||||
hourOfWeek := 0
|
||||
fmt.Sscanf(hourStr, "%d", &hourOfWeek)
|
||||
if hourOfWeek < 0 || hourOfWeek > 167 {
|
||||
http.Error(w, "hour must be 0-167", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
probs, err := predictionStore.GetTransitionProbabilitiesForFromZone(personID, zoneID, hourOfWeek)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
transitions := make([]map[string]interface{}, len(probs))
|
||||
for i, p := range probs {
|
||||
zoneName := p.ToZoneID
|
||||
if zonesMgr != nil {
|
||||
zoneName, _ = zonesMgr.GetZoneName(p.ToZoneID)
|
||||
}
|
||||
transitions[i] = map[string]interface{}{
|
||||
"from_zone_id": p.FromZoneID,
|
||||
"to_zone_id": p.ToZoneID,
|
||||
"to_zone_name": zoneName,
|
||||
"probability": p.Probability,
|
||||
"sample_count": p.Count,
|
||||
"last_computed": p.LastComputed,
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"person_id": personID,
|
||||
"from_zone_id": zoneID,
|
||||
"hour_of_week": hourOfWeek,
|
||||
"day_name": prediction.DayNameFromHourOfWeek(hourOfWeek),
|
||||
"hour_of_day": hourOfWeek % 24,
|
||||
"transitions": transitions,
|
||||
})
|
||||
})
|
||||
|
||||
// Get sample count for a slot
|
||||
r.Get("/api/predictions/samples/{personID}/zone/{zoneID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
personID := chi.URLParam(r, "personID")
|
||||
zoneID := chi.URLParam(r, "zoneID")
|
||||
hourOfWeek := prediction.HourOfWeek(time.Now())
|
||||
|
||||
count, err := predictionStore.GetTransitionCountForSlot(personID, zoneID, hourOfWeek)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
dataAge := predictionStore.GetDataAge()
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"person_id": personID,
|
||||
"zone_id": zoneID,
|
||||
"hour_of_week": hourOfWeek,
|
||||
"sample_count": count,
|
||||
"minimum_samples": prediction.MinimumSamplesPerSlot,
|
||||
"has_sufficient_data": count >= prediction.MinimumSamplesPerSlot,
|
||||
"data_age_days": dataAge.Hours() / 24,
|
||||
"model_ready": dataAge >= prediction.MinimumDataAge,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Horizon prediction endpoint
|
||||
if predictionHorizon != nil {
|
||||
r.Get("/api/predictions/horizon", func(w http.ResponseWriter, r *http.Request) {
|
||||
predictions := predictionHorizon.UpdateAllPredictions()
|
||||
writeJSON(w, predictions)
|
||||
})
|
||||
|
||||
r.Get("/api/predictions/horizon/{personID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
personID := chi.URLParam(r, "personID")
|
||||
// Get current zone from history
|
||||
if predictionHistory == nil {
|
||||
http.Error(w, "prediction history not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
zoneID, _, _, ok := predictionHistory.GetPersonZone(personID)
|
||||
if !ok || zoneID == "" {
|
||||
http.Error(w, "person not currently tracked", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
pred := predictionHorizon.PredictAtHorizon(personID, zoneID, prediction.PredictionHorizon)
|
||||
writeJSON(w, pred)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Learning feedback REST API
|
||||
|
|
@ -1965,8 +2412,113 @@ func main() {
|
|||
writeJSON(w, result)
|
||||
})
|
||||
|
||||
// Spatial weights endpoints
|
||||
if spatialWeightLearner != nil {
|
||||
r.Get("/api/accuracy/weights", func(w http.ResponseWriter, r *http.Request) {
|
||||
weights := spatialWeightLearner.GetAllWeights()
|
||||
stats := spatialWeightLearner.GetWeightStats()
|
||||
result := map[string]interface{}{
|
||||
"weights": weights,
|
||||
"stats": stats,
|
||||
}
|
||||
writeJSON(w, result)
|
||||
})
|
||||
|
||||
r.Get("/api/accuracy/weights/{zoneX}/{zoneY}", func(w http.ResponseWriter, r *http.Request) {
|
||||
zoneX, _ := strconv.Atoi(chi.URLParam(r, "zoneX"))
|
||||
zoneY, _ := strconv.Atoi(chi.URLParam(r, "zoneY"))
|
||||
weights := spatialWeightLearner.GetWeightsForZone(zoneX, zoneY)
|
||||
writeJSON(w, weights)
|
||||
})
|
||||
}
|
||||
|
||||
// Position accuracy endpoints
|
||||
if groundTruthStore != nil {
|
||||
r.Get("/api/accuracy/position", func(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := groundTruthStore.GetPositionImprovementStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, stats)
|
||||
})
|
||||
|
||||
r.Get("/api/accuracy/position/history", func(w http.ResponseWriter, r *http.Request) {
|
||||
weeksStr := r.URL.Query().Get("weeks")
|
||||
weeks := 8
|
||||
if weeksStr != "" {
|
||||
if n, err := strconv.Atoi(weeksStr); err == nil && n > 0 {
|
||||
weeks = n
|
||||
}
|
||||
}
|
||||
history, err := groundTruthStore.GetPositionAccuracyHistory(weeks)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, history)
|
||||
})
|
||||
|
||||
r.Get("/api/accuracy/samples", func(w http.ResponseWriter, r *http.Request) {
|
||||
zoneCounts, err := groundTruthStore.GetZoneSampleCounts()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
personCounts, err := groundTruthStore.GetSampleCountByPerson()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
total, err := groundTruthStore.GetTotalSampleCount()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
today, err := groundTruthStore.GetSamplesTodayCount()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"total_samples": total,
|
||||
"samples_today": today,
|
||||
"zone_counts": zoneCounts,
|
||||
"person_counts": personCounts,
|
||||
"zones_with_data": len(zoneCounts),
|
||||
"persons_with_data": len(personCounts),
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/api/accuracy/samples/recent", func(w http.ResponseWriter, r *http.Request) {
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if limitStr != "" {
|
||||
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
samples, err := groundTruthStore.GetRecentSamples(limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, samples)
|
||||
})
|
||||
|
||||
// Trigger weekly accuracy computation
|
||||
r.Post("/api/accuracy/position/compute", func(w http.ResponseWriter, r *http.Request) {
|
||||
week := localization.GetWeekString(time.Now())
|
||||
if err := groundTruthStore.ComputeWeeklyAccuracy(week); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "computed", "week": week})
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("[INFO] Self-improving localization API registered at /api/localization/*")
|
||||
}
|
||||
|
||||
// Phase 6: Anomaly detection REST API
|
||||
if anomalyDetector != nil {
|
||||
|
|
@ -2251,11 +2803,68 @@ func (n *notifySenderAdapter) SendViaChannel(channelType string, title, body str
|
|||
// Prediction provider adapters
|
||||
|
||||
type predictionZoneAdapter struct {
|
||||
mgr *zones.Manager
|
||||
mgr *zones.Manager
|
||||
}
|
||||
|
||||
func (z *predictionZoneAdapter) GetZone(id string) (string, bool) {
|
||||
zone := z.mgr.GetZone(id)
|
||||
zone := z.mgr.GetZone(id)
|
||||
if zone == nil {
|
||||
return "", false
|
||||
}
|
||||
return zone.Name, true
|
||||
}
|
||||
|
||||
type predictionPersonAdapter struct {
|
||||
registry *ble.Registry
|
||||
}
|
||||
|
||||
func (p *predictionPersonAdapter) GetPerson(id string) (string, string, bool) {
|
||||
person, err := p.registry.GetPerson(id)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
return person.Name, person.Color, true
|
||||
}
|
||||
|
||||
func (p *predictionPersonAdapter) GetAllPeople() ([]struct {
|
||||
ID string
|
||||
Name string
|
||||
Color string
|
||||
}, error) {
|
||||
people, err := p.registry.GetPeople()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]struct {
|
||||
ID string
|
||||
Name string
|
||||
Color string
|
||||
}, len(people))
|
||||
for i, person := range people {
|
||||
result[i] = struct {
|
||||
ID string
|
||||
Name string
|
||||
Color string
|
||||
}{
|
||||
ID: person.ID,
|
||||
Name: person.Name,
|
||||
Color: person.Color,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type predictionMQTTAdapter struct {
|
||||
client *mqtt.Client
|
||||
}
|
||||
|
||||
func (m *predictionMQTTAdapter) Publish(topic string, payload []byte) error {
|
||||
return m.client.Publish(topic, payload)
|
||||
}
|
||||
|
||||
func (m *predictionMQTTAdapter) IsConnected() bool {
|
||||
return m.client.IsConnected()
|
||||
}
|
||||
|
||||
// Anomaly detector provider adapters
|
||||
|
||||
|
|
|
|||
|
|
@ -268,13 +268,19 @@ type trailPoint [2]float64
|
|||
|
||||
// blobJSON is the wire format for a tracked person blob.
|
||||
type blobJSON struct {
|
||||
ID int `json:"id"`
|
||||
X float64 `json:"x"`
|
||||
Z float64 `json:"z"`
|
||||
VX float64 `json:"vx"`
|
||||
VZ float64 `json:"vz"`
|
||||
Weight float64 `json:"weight"`
|
||||
Trail []trailPoint `json:"trail"`
|
||||
ID int `json:"id"`
|
||||
X float64 `json:"x"`
|
||||
Z float64 `json:"z"`
|
||||
VX float64 `json:"vx"`
|
||||
VZ float64 `json:"vz"`
|
||||
Weight float64 `json:"weight"`
|
||||
Trail []trailPoint `json:"trail"`
|
||||
Posture string `json:"posture,omitempty"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
PersonLabel string `json:"person_label,omitempty"`
|
||||
PersonColor string `json:"person_color,omitempty"`
|
||||
IdentityConfidence float64 `json:"identity_confidence,omitempty"`
|
||||
IdentitySource string `json:"identity_source,omitempty"`
|
||||
}
|
||||
|
||||
// BroadcastLocUpdate sends localisation results to all dashboard clients.
|
||||
|
|
@ -293,6 +299,8 @@ func (h *Hub) BroadcastLocUpdate(blobs []tracking.Blob) {
|
|||
VZ: b.VZ,
|
||||
Weight: b.Weight,
|
||||
Trail: trail,
|
||||
// Phase 6 identity fields (Posture, PersonID, etc.) omitted until
|
||||
// tracking.Blob struct is extended.
|
||||
}
|
||||
}
|
||||
msg := map[string]interface{}{
|
||||
|
|
@ -381,3 +389,136 @@ func (h *Hub) ClientCount() int {
|
|||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// BroadcastFleetChange broadcasts a fleet change event to all dashboard clients.
|
||||
// This implements the fleet.FleetChangeBroadcaster interface.
|
||||
func (h *Hub) BroadcastFleetChange(event fleet.FleetChangeEvent) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "fleet_change",
|
||||
"timestamp": event.Timestamp.UnixMilli(),
|
||||
"trigger_reason": event.TriggerReason,
|
||||
"mean_gdop_before": event.MeanGDOPBefore,
|
||||
"mean_gdop_after": event.MeanGDOPAfter,
|
||||
"coverage_before": event.CoverageBefore,
|
||||
"coverage_after": event.CoverageAfter,
|
||||
"coverage_delta": event.CoverageDelta,
|
||||
"is_degradation": event.IsDegradation,
|
||||
"role_assignments": event.RoleAssignments,
|
||||
}
|
||||
|
||||
if event.OfflineMAC != "" {
|
||||
msg["offline_mac"] = event.OfflineMAC
|
||||
}
|
||||
if event.RecoveredMAC != "" {
|
||||
msg["recovered_mac"] = event.RecoveredMAC
|
||||
}
|
||||
if event.WarningMessage != "" {
|
||||
msg["warning_message"] = event.WarningMessage
|
||||
}
|
||||
if len(event.GDOPBefore) > 0 {
|
||||
msg["gdop_before"] = floatsToSlice(event.GDOPBefore)
|
||||
msg["gdop_after"] = floatsToSlice(event.GDOPAfter)
|
||||
msg["gdop_cols"] = event.GDOPCols
|
||||
msg["gdop_rows"] = event.GDOPRows
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// floatsToSlice converts []float32 to []float64 for JSON marshalling
|
||||
func floatsToSlice(f []float32) []float64 {
|
||||
result := make([]float64, len(f))
|
||||
for i, v := range f {
|
||||
result[i] = float64(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// BroadcastFleetHealth broadcasts current fleet health status.
|
||||
func (h *Hub) BroadcastFleetHealth(nodes []fleet.NodeRecord, roles map[string]string, coverageScore float64) {
|
||||
type nodeHealth struct {
|
||||
MAC string `json:"mac"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
HealthScore float64 `json:"health_score"`
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
|
||||
wireNodes := make([]nodeHealth, len(nodes))
|
||||
for i, n := range nodes {
|
||||
role := n.Role
|
||||
if r, ok := roles[n.MAC]; ok {
|
||||
role = r
|
||||
}
|
||||
wireNodes[i] = nodeHealth{
|
||||
MAC: n.MAC,
|
||||
Name: n.Name,
|
||||
Role: role,
|
||||
HealthScore: n.HealthScore,
|
||||
Online: n.LastSeenAt.After(time.Now().Add(-5 * time.Minute)),
|
||||
}
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"type": "fleet_health",
|
||||
"nodes": wireNodes,
|
||||
"coverage_score": coverageScore,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// BroadcastFleetHistory broadcasts optimisation history to dashboard.
|
||||
func (h *Hub) BroadcastFleetHistory(history []fleet.OptimisationHistoryRecord) {
|
||||
type historyEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp int64 `json:"timestamp_ms"`
|
||||
TriggerReason string `json:"trigger_reason"`
|
||||
MeanGDOPBefore float64 `json:"mean_gdop_before"`
|
||||
MeanGDOPAfter float64 `json:"mean_gdop_after"`
|
||||
CoverageDelta float64 `json:"coverage_delta"`
|
||||
}
|
||||
|
||||
wireHistory := make([]historyEntry, len(history))
|
||||
for i, rec := range history {
|
||||
wireHistory[i] = historyEntry{
|
||||
ID: rec.ID,
|
||||
Timestamp: rec.Timestamp.UnixMilli(),
|
||||
TriggerReason: rec.TriggerReason,
|
||||
MeanGDOPBefore: rec.MeanGDOPBefore,
|
||||
MeanGDOPAfter: rec.MeanGDOPAfter,
|
||||
CoverageDelta: rec.CoverageDelta,
|
||||
}
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"type": "fleet_history",
|
||||
"history": wireHistory,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// FleetChangeEvent is re-exported for compatibility
|
||||
type FleetChangeEvent = fleet.FleetChangeEvent
|
||||
|
||||
// BroadcastSystemModeChange broadcasts a system mode change event to all dashboard clients.
|
||||
func (h *Hub) BroadcastSystemModeChange(event interface{}) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "system_mode_change",
|
||||
"data": event,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// BroadcastAnomaly broadcasts an anomaly detection event to all dashboard clients.
|
||||
func (h *Hub) BroadcastAnomaly(anomaly interface{}) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "anomaly_detected",
|
||||
"data": anomaly,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue