feat: add self-improving localization REST API
Implement REST API endpoints for managing learned weights and tracking
improvement in the self-improving localization system.
- Add LocalizationHandler with endpoints for:
- GET /api/localization/weights - get all learned link weights
- GET /api/localization/weights/{linkID} - get specific link weight
- POST /api/localization/weights/reset - reset all weights to default
- GET /api/localization/spatial-weights - get spatial weights per zone
- GET /api/localization/groundtruth/* - ground truth sample management
- GET /api/localization/accuracy/* - position accuracy tracking
- GET /api/localization/learning/* - learning progress and history
- Integrate spatial weight learner into fusion engine:
- Add AddLinkInfluenceWithSpatialWeights to grid.go for per-cell weight application
- Update Fuse() in fusion.go to use spatial weight functions when available
- Apply both sigma adjustments and spatial weights for Fresnel zone computation
- Add comprehensive table-driven tests for all API endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5c474bb6ce
commit
af8800caef
7 changed files with 1386 additions and 10 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
6705826f922b604e805c080c39d3d567ff68caf2
|
||||
ef75a823fcf137f4e836d4bf11011b77ca27cb60
|
||||
|
|
|
|||
|
|
@ -459,8 +459,10 @@
|
|||
* @param {Object} msg - { type: 'morning_summary', report: { ... } }
|
||||
*/
|
||||
function handleMorningSummary(msg) {
|
||||
if (msg.report) {
|
||||
showMorningSummary(msg.report);
|
||||
// Backend sends "sleep" field (from BroadcastMorningSummary in hub.go)
|
||||
var report = msg.report || msg.sleep;
|
||||
if (report) {
|
||||
showMorningSummary(report);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -495,4 +497,7 @@
|
|||
handleSleepStatus: handleSleepStatus,
|
||||
fetchSleepData: fetchSleepData
|
||||
};
|
||||
|
||||
// Auto-initialize on load
|
||||
init();
|
||||
})();
|
||||
|
|
|
|||
457
mothership/internal/api/localization.go
Normal file
457
mothership/internal/api/localization.go
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
// Package api provides REST API handlers for self-improving localization.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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.mu.Lock()
|
||||
weights.linkWeights = make(map[string]float64)
|
||||
weights.linkSigmas = make(map[string]float64)
|
||||
weights.linkStats = make(map[string]*localization.LinkLearningStats)
|
||||
weights.lastUpdate = time.Now()
|
||||
weights.mu.Unlock()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"total_samples": total,
|
||||
"today_samples": today,
|
||||
"by_person": byPerson,
|
||||
"zone_counts": zoneCounts,
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
gtStats, _ := h.selfImprovingLocalizer.GetGroundTruthProvider().GetObservationCount()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"learning_progress": progress,
|
||||
"learned_weights": weights,
|
||||
"improvement_stats": improvementStats,
|
||||
"improvement_history": improvementHistory,
|
||||
"ble_observations_count": gtStats,
|
||||
})
|
||||
}
|
||||
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
844
mothership/internal/api/localization_test.go
Normal file
844
mothership/internal/api/localization_test.go
Normal file
|
|
@ -0,0 +1,844 @@
|
|||
// Package api provides REST API tests for self-improving localization.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/spaxel/mothership/internal/localization"
|
||||
)
|
||||
|
||||
func TestLocalizationHandler_getWeights(t *testing.T) {
|
||||
// Create temporary directory
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create components
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test GET /api/localization/weights
|
||||
req := httptest.NewRequest("GET", "/api/localization/weights", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check fields
|
||||
if _, ok := result["weights"]; !ok {
|
||||
t.Error("Missing weights field")
|
||||
}
|
||||
if _, ok := result["stats"]; !ok {
|
||||
t.Error("Missing stats field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_getLinkWeight(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test GET /api/localization/weights/test-link-1
|
||||
req := httptest.NewRequest("GET", "/api/localization/weights/test-link-1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check fields
|
||||
if result["link_id"] != "test-link-1" {
|
||||
t.Errorf("Expected link_id test-link-1, got %v", result["link_id"])
|
||||
}
|
||||
if _, ok := result["weight"]; !ok {
|
||||
t.Error("Missing weight field")
|
||||
}
|
||||
if _, ok := result["sigma"]; !ok {
|
||||
t.Error("Missing sigma field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_resetWeights(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
// Set some weights first
|
||||
weights := sil.GetWeightLearner().GetLearnedWeights()
|
||||
weights.SetWeights("test-link", 1.5, 0.5)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test POST /api/localization/weights/reset
|
||||
req := httptest.NewRequest("POST", "/api/localization/weights/reset", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result["status"] != "weights_reset" {
|
||||
t.Errorf("Expected status weights_reset, got %v", result["status"])
|
||||
}
|
||||
|
||||
// Verify weights were reset
|
||||
weight := sil.GetWeightLearner().GetLearnedWeights().GetLinkWeight("test-link")
|
||||
if weight != 1.0 {
|
||||
t.Errorf("Expected weight to be reset to 1.0, got %v", weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_getSpatialWeights(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test GET /api/localization/spatial-weights
|
||||
req := httptest.NewRequest("GET", "/api/localization/spatial-weights", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check fields
|
||||
if _, ok := result["spatial_weights"]; !ok {
|
||||
t.Error("Missing spatial_weights field")
|
||||
}
|
||||
if _, ok := result["stats"]; !ok {
|
||||
t.Error("Missing stats field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_getSpatialWeightsForZone(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
// Set some weights for testing
|
||||
swLearner.mu.Lock()
|
||||
swLearner.setWeightLocked("link1", 0, 0, 1.5)
|
||||
swLearner.setWeightLocked("link2", 0, 0, 0.8)
|
||||
swLearner.mu.Unlock()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test GET /api/localization/spatial-weights/zone/0/0
|
||||
req := httptest.NewRequest("GET", "/api/localization/spatial-weights/zone/0/0", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check fields
|
||||
if result["zone_x"] != 0 {
|
||||
t.Errorf("Expected zone_x 0, got %v", result["zone_x"])
|
||||
}
|
||||
if result["zone_y"] != 0 {
|
||||
t.Errorf("Expected zone_y 0, got %v", result["zone_y"])
|
||||
}
|
||||
if _, ok := result["weights"]; !ok {
|
||||
t.Error("Missing weights field")
|
||||
}
|
||||
|
||||
// Verify weights
|
||||
weights, ok := result["weights"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("weights is not a map")
|
||||
}
|
||||
|
||||
// Check that our test weights are present
|
||||
if link1Weight, ok := weights["link1"].(float64); !ok || link1Weight != 1.5 {
|
||||
t.Errorf("Expected link1 weight 1.5, got %v", weights["link1"])
|
||||
}
|
||||
if link2Weight, ok := weights["link2"].(float64); !ok || link2Weight != 0.8 {
|
||||
t.Errorf("Expected link2 weight 0.8, got %v", weights["link2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_getGroundTruthSamples(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
// Add some test samples
|
||||
for i := 0; i < 5; i++ {
|
||||
sample := localization.GroundTruthSample{
|
||||
Timestamp: time.Now().Add(-time.Duration(i) * time.Minute),
|
||||
PersonID: "test-person",
|
||||
BLEPosition: localization.Vec3{X: 1.0, Y: 0.0, Z: 1.0},
|
||||
BlobPosition: localization.Vec3{X: 1.0 + float64(i)*0.1, Y: 0.0, Z: 1.0},
|
||||
PositionError: float64(i) * 0.1,
|
||||
PerLinkDeltas: map[string]float64{"link1": 0.5},
|
||||
PerLinkHealth: map[string]float64{"link1": 0.9},
|
||||
BLEConfidence: 0.8,
|
||||
ZoneGridX: 0,
|
||||
ZoneGridY: 0,
|
||||
}
|
||||
if err := gtStore.AddSample(sample); err != nil {
|
||||
t.Fatalf("Failed to add sample: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test GET /api/localization/groundtruth/samples
|
||||
req := httptest.NewRequest("GET", "/api/localization/groundtruth/samples?limit=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check fields
|
||||
if _, ok := result["samples"]; !ok {
|
||||
t.Error("Missing samples field")
|
||||
}
|
||||
if _, ok := result["count"]; !ok {
|
||||
t.Error("Missing count field")
|
||||
}
|
||||
|
||||
// Verify we got samples
|
||||
count, ok := result["count"].(int)
|
||||
if !ok || count != 5 {
|
||||
t.Errorf("Expected 5 samples, got %v", result["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_getGroundTruthStats(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
// Add test samples
|
||||
sample := localization.GroundTruthSample{
|
||||
Timestamp: time.Now(),
|
||||
PersonID: "test-person",
|
||||
BLEPosition: localization.Vec3{X: 1.0, Y: 0.0, Z: 1.0},
|
||||
BlobPosition: localization.Vec3{X: 1.0, Y: 0.0, Z: 1.0},
|
||||
PositionError: 0.1,
|
||||
PerLinkDeltas: map[string]float64{"link1": 0.5},
|
||||
PerLinkHealth: map[string]float64{"link1": 0.9},
|
||||
BLEConfidence: 0.8,
|
||||
ZoneGridX: 0,
|
||||
ZoneGridY: 0,
|
||||
}
|
||||
if err := gtStore.AddSample(sample); err != nil {
|
||||
t.Fatalf("Failed to add sample: %v", err)
|
||||
}
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test GET /api/localization/groundtruth/stats
|
||||
req := httptest.NewRequest("GET", "/api/localization/groundtruth/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
requiredFields := []string{"total_samples", "today_samples", "by_person", "zone_counts"}
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := result[field]; !ok {
|
||||
t.Errorf("Missing required field: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify total samples
|
||||
total, ok := result["total_samples"].(int)
|
||||
if !ok || total != 1 {
|
||||
t.Errorf("Expected 1 total sample, got %v", result["total_samples"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_getAccuracyHistory(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test GET /api/localization/accuracy/history
|
||||
req := httptest.NewRequest("GET", "/api/localization/accuracy/history?weeks=4", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check fields
|
||||
if _, ok := result["records"]; !ok {
|
||||
t.Error("Missing records field")
|
||||
}
|
||||
if _, ok := result["weeks"]; !ok {
|
||||
t.Error("Missing weeks field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_getLearningProgress(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test GET /api/localization/learning/progress
|
||||
req := httptest.NewRequest("GET", "/api/localization/learning/progress", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
requiredFields := []string{"progress", "stats"}
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := result[field]; !ok {
|
||||
t.Errorf("Missing required field: %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_getSelfImprovingStatus(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test GET /api/localization/self-improving/status
|
||||
req := httptest.NewRequest("GET", "/api/localization/self-improving/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
requiredFields := []string{
|
||||
"learning_progress", "learned_weights", "improvement_stats",
|
||||
"improvement_history", "ble_observations_count",
|
||||
}
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := result[field]; !ok {
|
||||
t.Errorf("Missing required field: %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_processLearning(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test POST /api/localization/self-improving/process
|
||||
req := httptest.NewRequest("POST", "/api/localization/self-improving/process", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check fields
|
||||
if result["status"] != "learning_processed" {
|
||||
t.Errorf("Expected status learning_processed, got %v", result["status"])
|
||||
}
|
||||
if _, ok := result["timestamp"]; !ok {
|
||||
t.Error("Missing timestamp field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizationHandler_getImprovementHistory(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "localization_api_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
gtStore, err := localization.NewGroundTruthStore(
|
||||
filepath.Join(tmpDir, "groundtruth.db"),
|
||||
localization.DefaultGroundTruthStoreConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create ground truth store: %v", err)
|
||||
}
|
||||
defer gtStore.Close()
|
||||
|
||||
swLearner, err := localization.NewSpatialWeightLearner(
|
||||
filepath.Join(tmpDir, "spatial_weights.db"),
|
||||
localization.DefaultSpatialWeightLearnerConfig(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create spatial weight learner: %v", err)
|
||||
}
|
||||
defer swLearner.Close()
|
||||
|
||||
wStore, err := localization.NewWeightStore(filepath.Join(tmpDir, "weights.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create weight store: %v", err)
|
||||
}
|
||||
defer wStore.Close()
|
||||
|
||||
config := localization.DefaultSelfImprovingConfig()
|
||||
sil := localization.NewSelfImprovingLocalizer(config)
|
||||
|
||||
handler := NewLocalizationHandler(gtStore, swLearner, sil.GetWeightLearner(), wStore, sil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Test GET /api/localization/learning/history
|
||||
req := httptest.NewRequest("GET", "/api/localization/learning/history", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if _, ok := result["history"]; !ok {
|
||||
t.Error("Missing history field")
|
||||
}
|
||||
if _, ok := result["stats"]; !ok {
|
||||
t.Error("Missing stats field")
|
||||
}
|
||||
}
|
||||
|
|
@ -145,8 +145,17 @@ func (e *Engine) Fuse(links []LinkMotion) *FusionResult {
|
|||
sigmaMultiplier = learnedWeights.GetLinkSigma(linkID)
|
||||
}
|
||||
|
||||
// Use the sigma-aware version if we have learned sigma
|
||||
if sigmaMultiplier != 0 {
|
||||
// Use the sigma-aware version if we have learned sigma, and apply spatial weights if available
|
||||
if e.spatialWeightLearner != nil {
|
||||
// Create spatial weight function for this link
|
||||
linkID := lm.NodeMAC + "-" + lm.PeerMAC
|
||||
spatialWeightFunc := func(x, z float64) float64 {
|
||||
return e.spatialWeightLearner.GetSpatialWeight(linkID, x, z)
|
||||
}
|
||||
|
||||
// Apply both sigma and spatial weights
|
||||
e.grid.AddLinkInfluenceWithSpatialWeights(posA.X, posA.Z, posB.X, posB.Z, weight, sigmaMultiplier, spatialWeightFunc)
|
||||
} else if sigmaMultiplier != 0 {
|
||||
e.grid.AddLinkInfluenceWithSigma(posA.X, posA.Z, posB.X, posB.Z, weight, sigmaMultiplier)
|
||||
} else {
|
||||
e.grid.AddLinkInfluence(posA.X, posA.Z, posB.X, posB.Z, weight)
|
||||
|
|
|
|||
|
|
@ -118,6 +118,67 @@ func (g *Grid) AddLinkInfluenceWithSigma(ax, az, bx, bz, weight, sigmaMultiplier
|
|||
}
|
||||
}
|
||||
|
||||
// AddLinkInfluenceWithSpatialWeights paints Fresnel-zone influence with per-cell spatial weights.
|
||||
// spatialWeightFunc is a function that takes (x, z) position and returns a weight multiplier for this link.
|
||||
// This enables Fresnel zone weight refinement based on learned spatial patterns.
|
||||
func (g *Grid) AddLinkInfluenceWithSpatialWeights(ax, az, bx, bz, weight, sigmaMultiplier float64, spatialWeightFunc func(x, z float64) float64) {
|
||||
if weight <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ab := math.Sqrt((bx-ax)*(bx-ax) + (bz-az)*(bz-az))
|
||||
if ab < 0.1 {
|
||||
return // degenerate link
|
||||
}
|
||||
|
||||
// σ is chosen so the first Fresnel zone (excess = λ/2 ≈ 0.062m at 2.4GHz)
|
||||
// maps to ~1σ, giving comfortable spatial spread. In practice a wider
|
||||
// sigma (0.5m) gives better localisation for indoor multipath.
|
||||
baseSigma := math.Max(ab*0.25, 0.5)
|
||||
|
||||
// Apply learned sigma multiplier
|
||||
sigma := baseSigma
|
||||
if sigmaMultiplier > 0 {
|
||||
sigma = baseSigma * sigmaMultiplier
|
||||
// Clamp to reasonable range
|
||||
if sigma < 0.2 {
|
||||
sigma = 0.2
|
||||
}
|
||||
if sigma > 2.0 {
|
||||
sigma = 2.0
|
||||
}
|
||||
}
|
||||
|
||||
twoSigSq := 2 * sigma * sigma
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
for row := 0; row < g.rows; row++ {
|
||||
pz := g.originZ + (float64(row)+0.5)*g.cellSize
|
||||
for col := 0; col < g.cols; col++ {
|
||||
px := g.originX + (float64(col)+0.5)*g.cellSize
|
||||
|
||||
dAP := math.Sqrt((px-ax)*(px-ax) + (pz-az)*(pz-az))
|
||||
dPB := math.Sqrt((px-bx)*(px-bx) + (pz-bz)*(pz-bz))
|
||||
excess := dAP + dPB - ab
|
||||
|
||||
if excess < 0 {
|
||||
excess = 0
|
||||
}
|
||||
|
||||
// Apply spatial weight for this cell position
|
||||
cellWeight := weight
|
||||
if spatialWeightFunc != nil {
|
||||
cellWeight = weight * spatialWeightFunc(px, pz)
|
||||
}
|
||||
|
||||
influence := cellWeight * math.Exp(-(excess * excess) / twoSigSq)
|
||||
g.cells[row*g.cols+col] += influence
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize scales the grid so the maximum cell value is 1.0.
|
||||
// Returns false if the grid is all zero.
|
||||
func (g *Grid) Normalize() bool {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue