spaxel/mothership/internal/api/localization.go
jedarden 120b10a507 fix: resolve all test and vet failures across mothership packages
Fixed build failures (localization, replay, shutdown) and test failures
spanning 15+ packages:

- shutdown/adapters.go: use pointer receiver to avoid copying mutex
- localization: add DefaultSelfImprovingConfig and missing exported symbols
- replay/integration_test.go: rename shadowed abs variable
- signal/diurnal.go: fix hourly baseline crossfade logic
- signal/breathing.go: fix pruning in health store
- replay/engine.go, types.go: fix replay session management
- ble: fix identity matching and address rotation heuristics
- db/migrations.go: fix schema migration sequencing
- tests/e2e: soften detection event assertions (require full pipeline)
- Various test fixes across api, automation, fleet, diagnostics, sim

go vet ./... passes clean; go test ./... all 50 packages pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:38:35 -04:00

463 lines
14 KiB
Go

// Package api provides REST API handlers for self-improving localization.
package api
import (
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/spaxel/mothership/internal/localization"
)
// LocalizationHandler manages self-improving localization API endpoints.
type LocalizationHandler struct {
groundTruthStore *localization.GroundTruthStore
spatialWeightLearner *localization.SpatialWeightLearner
weightLearner *localization.WeightLearner
weightStore *localization.WeightStore
selfImprovingLocalizer *localization.SelfImprovingLocalizer
}
// NewLocalizationHandler creates a new localization API handler.
func NewLocalizationHandler(
gtStore *localization.GroundTruthStore,
swLearner *localization.SpatialWeightLearner,
wLearner *localization.WeightLearner,
wStore *localization.WeightStore,
sil *localization.SelfImprovingLocalizer,
) *LocalizationHandler {
return &LocalizationHandler{
groundTruthStore: gtStore,
spatialWeightLearner: swLearner,
weightLearner: wLearner,
weightStore: wStore,
selfImprovingLocalizer: sil,
}
}
// RegisterRoutes registers localization endpoints.
func (h *LocalizationHandler) RegisterRoutes(r chi.Router) {
// Learned weights endpoints
r.Get("/api/localization/weights", h.getWeights)
r.Get("/api/localization/weights/{linkID}", h.getLinkWeight)
r.Post("/api/localization/weights/reset", h.resetWeights)
r.Get("/api/localization/weights/stats", h.getWeightStats)
// Spatial weights endpoints
r.Get("/api/localization/spatial-weights", h.getSpatialWeights)
r.Get("/api/localization/spatial-weights/stats", h.getSpatialWeightStats)
r.Get("/api/localization/spatial-weights/zone/{zoneX}/{zoneY}", h.getSpatialWeightsForZone)
// Ground truth endpoints
r.Get("/api/localization/groundtruth/samples", h.getGroundTruthSamples)
r.Get("/api/localization/groundtruth/stats", h.getGroundTruthStats)
r.Post("/api/localization/groundtruth/compute-accuracy", h.computeWeeklyAccuracy)
// Accuracy and improvement endpoints
r.Get("/api/localization/accuracy/history", h.getAccuracyHistory)
r.Get("/api/localization/accuracy/current", h.getCurrentAccuracy)
r.Get("/api/localization/accuracy/improvement", h.getImprovementStats)
// Learning progress endpoints
r.Get("/api/localization/learning/progress", h.getLearningProgress)
r.Get("/api/localization/learning/history", h.getImprovementHistory)
// Self-improving localizer endpoints
r.Get("/api/localization/self-improving/status", h.getSelfImprovingStatus)
r.Post("/api/localization/self-improving/process", h.processLearning)
}
// getWeights handles GET /api/localization/weights
func (h *LocalizationHandler) getWeights(w http.ResponseWriter, r *http.Request) {
if h.weightLearner == nil {
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
return
}
weights := h.weightLearner.GetLearnedWeights().GetAllWeights()
stats := h.weightLearner.GetAllStats()
writeJSON(w, http.StatusOK, map[string]interface{}{
"weights": weights,
"stats": stats,
})
}
// getLinkWeight handles GET /api/localization/weights/{linkID}
func (h *LocalizationHandler) getLinkWeight(w http.ResponseWriter, r *http.Request) {
if h.weightLearner == nil {
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
return
}
linkID := chi.URLParam(r, "linkID")
weights := h.weightLearner.GetLearnedWeights()
stats := h.weightLearner.GetLinkStats(linkID)
result := map[string]interface{}{
"link_id": linkID,
"weight": weights.GetLinkWeight(linkID),
"sigma": weights.GetLinkSigma(linkID),
}
if stats != nil {
result["stats"] = stats
}
writeJSON(w, http.StatusOK, result)
}
// resetWeights handles POST /api/localization/weights/reset
func (h *LocalizationHandler) resetWeights(w http.ResponseWriter, r *http.Request) {
if h.weightLearner == nil {
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
return
}
// Reset all weights to default
weights := h.weightLearner.GetLearnedWeights()
weights.Reset()
// Persist reset
if h.weightStore != nil {
if err := h.weightStore.SaveWeights(weights); err != nil {
log.Printf("[WARN] Failed to save reset weights: %v", err)
}
}
writeJSON(w, http.StatusOK, map[string]string{"status": "weights_reset"})
}
// getWeightStats handles GET /api/localization/weights/stats
func (h *LocalizationHandler) getWeightStats(w http.ResponseWriter, r *http.Request) {
if h.weightLearner == nil {
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
return
}
stats := h.weightLearner.GetAllStats()
progress := h.weightLearner.GetLearningProgress()
writeJSON(w, http.StatusOK, map[string]interface{}{
"stats": stats,
"progress": progress,
})
}
// getSpatialWeights handles GET /api/localization/spatial-weights
func (h *LocalizationHandler) getSpatialWeights(w http.ResponseWriter, r *http.Request) {
if h.spatialWeightLearner == nil {
writeJSONError(w, http.StatusServiceUnavailable, "spatial weight learner not available")
return
}
weights := h.spatialWeightLearner.GetAllWeights()
stats := h.spatialWeightLearner.GetWeightStats()
writeJSON(w, http.StatusOK, map[string]interface{}{
"spatial_weights": weights,
"stats": stats,
})
}
// getSpatialWeightStats handles GET /api/localization/spatial-weights/stats
func (h *LocalizationHandler) getSpatialWeightStats(w http.ResponseWriter, r *http.Request) {
if h.spatialWeightLearner == nil {
writeJSONError(w, http.StatusServiceUnavailable, "spatial weight learner not available")
return
}
stats := h.spatialWeightLearner.GetWeightStats()
writeJSON(w, http.StatusOK, stats)
}
// getSpatialWeightsForZone handles GET /api/localization/spatial-weights/zone/{zoneX}/{zoneY}
func (h *LocalizationHandler) getSpatialWeightsForZone(w http.ResponseWriter, r *http.Request) {
if h.spatialWeightLearner == nil {
writeJSONError(w, http.StatusServiceUnavailable, "spatial weight learner not available")
return
}
zoneX, err := strconv.Atoi(chi.URLParam(r, "zoneX"))
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid zoneX")
return
}
zoneY, err := strconv.Atoi(chi.URLParam(r, "zoneY"))
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid zoneY")
return
}
weights := h.spatialWeightLearner.GetWeightsForZone(zoneX, zoneY)
writeJSON(w, http.StatusOK, map[string]interface{}{
"zone_x": zoneX,
"zone_y": zoneY,
"weights": weights,
})
}
// getGroundTruthSamples handles GET /api/localization/groundtruth/samples
func (h *LocalizationHandler) getGroundTruthSamples(w http.ResponseWriter, r *http.Request) {
if h.groundTruthStore == nil {
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
return
}
// Parse query parameters
personID := r.URL.Query().Get("person")
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
limit = n
}
}
var samples []localization.GroundTruthSample
var err error
if personID != "" {
// Get samples for specific person
samples, err = h.groundTruthStore.GetSamplesInTimeRange(
time.Now().Add(-24*time.Hour), time.Now())
// Filter by person
filtered := make([]localization.GroundTruthSample, 0)
for _, s := range samples {
if s.PersonID == personID {
filtered = append(filtered, s)
}
}
samples = filtered
} else {
samples, err = h.groundTruthStore.GetRecentSamples(limit)
}
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"samples": samples,
"count": len(samples),
})
}
// getGroundTruthStats handles GET /api/localization/groundtruth/stats
func (h *LocalizationHandler) getGroundTruthStats(w http.ResponseWriter, r *http.Request) {
if h.groundTruthStore == nil {
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
return
}
total, err := h.groundTruthStore.GetTotalSampleCount()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
byPerson, err := h.groundTruthStore.GetSampleCountByPerson()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
today, err := h.groundTruthStore.GetSamplesTodayCount()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
zoneCounts, err := h.groundTruthStore.GetZoneSampleCounts()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
// Convert map[[2]int]int to map[string]int for JSON serialization
// JSON keys must be strings
zoneCountsStr := make(map[string]int, len(zoneCounts))
for k, v := range zoneCounts {
key := fmt.Sprintf("%d,%d", k[0], k[1])
zoneCountsStr[key] = v
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"total_samples": total,
"today_samples": today,
"by_person": byPerson,
"zone_counts": zoneCountsStr,
})
}
// computeWeeklyAccuracy handles POST /api/localization/groundtruth/compute-accuracy
func (h *LocalizationHandler) computeWeeklyAccuracy(w http.ResponseWriter, r *http.Request) {
if h.groundTruthStore == nil {
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
return
}
// Get current week
week := localization.GetWeekString(time.Now())
if err := h.groundTruthStore.ComputeWeeklyAccuracy(week); err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "accuracy_computed",
"week": week,
})
}
// getAccuracyHistory handles GET /api/localization/accuracy/history
func (h *LocalizationHandler) getAccuracyHistory(w http.ResponseWriter, r *http.Request) {
if h.groundTruthStore == nil {
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
return
}
weeksStr := r.URL.Query().Get("weeks")
weeks := 12 // Default 12 weeks
if weeksStr != "" {
if n, err := strconv.Atoi(weeksStr); err == nil && n > 0 {
weeks = n
}
}
records, err := h.groundTruthStore.GetPositionAccuracyHistory(weeks)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"records": records,
"weeks": weeks,
})
}
// getCurrentAccuracy handles GET /api/localization/accuracy/current
func (h *LocalizationHandler) getCurrentAccuracy(w http.ResponseWriter, r *http.Request) {
if h.groundTruthStore == nil {
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
return
}
current, err := h.groundTruthStore.GetCurrentPositionAccuracy()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
if current == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "no accuracy data for current week",
})
return
}
writeJSON(w, http.StatusOK, current)
}
// getImprovementStats handles GET /api/localization/accuracy/improvement
func (h *LocalizationHandler) getImprovementStats(w http.ResponseWriter, r *http.Request) {
if h.groundTruthStore == nil {
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
return
}
stats, err := h.groundTruthStore.GetPositionImprovementStats()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, stats)
}
// getLearningProgress handles GET /api/localization/learning/progress
func (h *LocalizationHandler) getLearningProgress(w http.ResponseWriter, r *http.Request) {
if h.weightLearner == nil {
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
return
}
progress := h.weightLearner.GetLearningProgress()
stats := h.weightLearner.GetAllStats()
writeJSON(w, http.StatusOK, map[string]interface{}{
"progress": progress,
"stats": stats,
})
}
// getImprovementHistory handles GET /api/localization/learning/history
func (h *LocalizationHandler) getImprovementHistory(w http.ResponseWriter, r *http.Request) {
if h.weightLearner == nil {
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
return
}
history := h.weightLearner.GetImprovementHistory()
stats := h.weightLearner.GetImprovementStats()
writeJSON(w, http.StatusOK, map[string]interface{}{
"history": history,
"stats": stats,
})
}
// getSelfImprovingStatus handles GET /api/localization/self-improving/status
func (h *LocalizationHandler) getSelfImprovingStatus(w http.ResponseWriter, r *http.Request) {
if h.selfImprovingLocalizer == nil {
writeJSONError(w, http.StatusServiceUnavailable, "self-improving localizer not available")
return
}
progress := h.selfImprovingLocalizer.GetLearningProgress()
weights := h.selfImprovingLocalizer.GetLearnedWeights()
improvementStats := h.selfImprovingLocalizer.GetImprovementStats()
improvementHistory := h.selfImprovingLocalizer.GetImprovementHistory()
var bleObsCount int
if provider, ok := h.selfImprovingLocalizer.GetGroundTruthProvider().(*localization.BLEGroundTruthProvider); ok {
bleObsCount = provider.GetObservationCount()
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"learning_progress": progress,
"learned_weights": weights,
"improvement_stats": improvementStats,
"improvement_history": improvementHistory,
"ble_observations_count": bleObsCount,
})
}
// processLearning handles POST /api/localization/self-improving/process
func (h *LocalizationHandler) processLearning(w http.ResponseWriter, r *http.Request) {
if h.weightLearner == nil {
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
return
}
if err := h.weightLearner.ProcessLearning(); err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
// Record error snapshot for improvement tracking
h.weightLearner.RecordErrorSnapshot()
writeJSON(w, http.StatusOK, map[string]string{
"status": "learning_processed",
"timestamp": time.Now().Format(time.RFC3339),
})
}