feat: add diurnal baseline REST API endpoints
Add REST API for diurnal baseline data:
- GET /api/diurnal/status - learning status for all links
- GET /api/diurnal/slots/{linkID} - slot data for specific link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f6a5d560cb
commit
50856415b3
5 changed files with 346 additions and 5 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
3ff80c294d817bffc2c455069fbc592932c211ab
|
||||
281bbefb570e6972fbc45cf641f02152e4123a6d
|
||||
|
|
|
|||
|
|
@ -3154,6 +3154,11 @@ func main() {
|
|||
tracksHandler.RegisterRoutes(r)
|
||||
log.Printf("[INFO] Tracks API registered at /api/tracks")
|
||||
|
||||
// Diurnal baseline REST API
|
||||
diurnalHandler := api.NewDiurnalHandler(pm)
|
||||
diurnalHandler.RegisterRoutes(r)
|
||||
log.Printf("[INFO] Diurnal baseline API registered at /api/diurnal/*")
|
||||
|
||||
// Backup API — streams a zip of all databases via SQLite Online Backup API
|
||||
backupHandler := api.NewBackupHandler(cfg.DataDir, version)
|
||||
r.Get("/api/backup", backupHandler.HandleBackup)
|
||||
|
|
|
|||
103
mothership/internal/api/diurnal.go
Normal file
103
mothership/internal/api/diurnal.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// Package api provides REST API handlers for diurnal baseline data.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/spaxel/mothership/internal/signal"
|
||||
)
|
||||
|
||||
// DiurnalHandler manages the diurnal baseline API endpoints.
|
||||
type DiurnalHandler struct {
|
||||
pm DiurnalProcessorManager
|
||||
}
|
||||
|
||||
// DiurnalProcessorManager defines the interface for accessing diurnal data from the signal processor.
|
||||
type DiurnalProcessorManager interface {
|
||||
GetDiurnalLearningStatus() []signal.DiurnalLearningStatus
|
||||
GetProcessor(linkID string) DiurnalLinkProcessor
|
||||
}
|
||||
|
||||
// DiurnalLinkProcessor defines the interface for accessing a single link's diurnal data.
|
||||
type DiurnalLinkProcessor interface {
|
||||
GetDiurnal() *signal.DiurnalBaseline
|
||||
}
|
||||
|
||||
// NewDiurnalHandler creates a new diurnal API handler.
|
||||
func NewDiurnalHandler(pm DiurnalProcessorManager) *DiurnalHandler {
|
||||
return &DiurnalHandler{
|
||||
pm: pm,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers diurnal endpoints.
|
||||
func (h *DiurnalHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/api/diurnal/status", h.getDiurnalStatus)
|
||||
r.Get("/api/diurnal/slots/{linkID}", h.getDiurnalSlots)
|
||||
}
|
||||
|
||||
// getDiurnalStatus handles GET /api/diurnal/status
|
||||
// Returns the diurnal learning status for all links.
|
||||
func (h *DiurnalHandler) getDiurnalStatus(w http.ResponseWriter, r *http.Request) {
|
||||
statuses := h.pm.GetDiurnalLearningStatus()
|
||||
writeJSON(w, http.StatusOK, statuses)
|
||||
}
|
||||
|
||||
// getDiurnalSlots handles GET /api/diurnal/slots/{linkID}
|
||||
// Returns the diurnal baseline slot data for a specific link.
|
||||
func (h *DiurnalHandler) getDiurnalSlots(w http.ResponseWriter, r *http.Request) {
|
||||
linkID := chi.URLParam(r, "linkID")
|
||||
if linkID == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "link_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
processor := h.pm.GetProcessor(linkID)
|
||||
if processor == nil {
|
||||
writeJSONError(w, http.StatusNotFound, "link not found")
|
||||
return
|
||||
}
|
||||
|
||||
diurnal := processor.GetDiurnal()
|
||||
if diurnal == nil {
|
||||
writeJSONError(w, http.StatusNotFound, "diurnal baseline not found")
|
||||
return
|
||||
}
|
||||
|
||||
snapshot := diurnal.GetSnapshot()
|
||||
if snapshot == nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to get diurnal snapshot")
|
||||
return
|
||||
}
|
||||
|
||||
// Build response with slot data
|
||||
response := map[string]interface{}{
|
||||
"link_id": snapshot.LinkID,
|
||||
"created_at": snapshot.Created,
|
||||
"current_hour": snapshot.Created.Hour(), // For consistency with signal package
|
||||
"slot_amplitudes": snapshot.SlotValues,
|
||||
"slot_confidences": diurnal.GetAllSlotConfidences(),
|
||||
"slot_counts": snapshot.SlotCounts,
|
||||
"is_learning": diurnal.IsLearning(),
|
||||
"learning_progress": diurnal.GetLearningProgress(),
|
||||
"is_ready": diurnal.IsReady(),
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// writeJSON writes a JSON response.
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// writeJSONError writes a JSON error response.
|
||||
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
233
mothership/internal/api/diurnal_test.go
Normal file
233
mothership/internal/api/diurnal_test.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Package api provides tests for the diurnal baseline API.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spaxel/mothership/internal/signal"
|
||||
)
|
||||
|
||||
// mockProcessorManager mocks the ProcessorManager interface for testing.
|
||||
type mockDiurnalProcessorManager struct {
|
||||
processors map[string]*mockDiurnalLinkProcessor
|
||||
}
|
||||
|
||||
type mockDiurnalLinkProcessor struct {
|
||||
diurnal *signal.DiurnalBaseline
|
||||
}
|
||||
|
||||
func (m *mockDiurnalLinkProcessor) GetDiurnal() *signal.DiurnalBaseline {
|
||||
return m.diurnal
|
||||
}
|
||||
|
||||
func (m *mockDiurnalProcessorManager) GetDiurnalLearningStatus() []signal.DiurnalLearningStatus {
|
||||
// For testing, we'll create mock status directly
|
||||
return []signal.DiurnalLearningStatus{
|
||||
{
|
||||
LinkID: "AA:BB:CC:DD:EE:FF",
|
||||
IsLearning: true,
|
||||
DaysRemaining: 5.0,
|
||||
Progress: 28.5,
|
||||
IsReady: false,
|
||||
SlotsReady: 8,
|
||||
DiurnalConfidence: 0.33,
|
||||
CreatedAt: time.Now().Add(-2 * 24 * time.Hour),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockDiurnalProcessorManager) GetProcessor(linkID string) DiurnalLinkProcessor {
|
||||
return m.processors[linkID]
|
||||
}
|
||||
|
||||
// newMockDiurnalBaseline creates a mock diurnal baseline with test data.
|
||||
func newMockDiurnalBaseline() *signal.DiurnalBaseline {
|
||||
db := signal.NewDiurnalBaseline("AA:BB:CC:DD:EE:FF", 64)
|
||||
|
||||
// Simulate having some data by directly manipulating the slots
|
||||
for i := 0; i < 24; i++ {
|
||||
slot := db.GetSlot(i)
|
||||
if slot != nil {
|
||||
// Fill with test amplitude values
|
||||
for k := 0; k < 64; k++ {
|
||||
slot.Values[k] = 0.5 + float64(i)*0.01
|
||||
}
|
||||
slot.SampleCount = 300 + i*10 // Start with minimum required samples
|
||||
slot.LastUpdate = time.Now().Add(-time.Duration(i) * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// Test getDiurnalStatus
|
||||
func TestGetDiurnalStatus(t *testing.T) {
|
||||
handler := &DiurnalHandler{
|
||||
pm: &mockDiurnalProcessorManager{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/diurnal/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.getDiurnalStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var statuses []signal.DiurnalLearningStatus
|
||||
if err := json.NewDecoder(w.Body).Decode(&statuses); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("got %d statuses, want 1", len(statuses))
|
||||
}
|
||||
|
||||
status := statuses[0]
|
||||
if status.LinkID != "AA:BB:CC:DD:EE:FF" {
|
||||
t.Errorf("link_id = %q, want AA:BB:CC:DD:EE:FF", status.LinkID)
|
||||
}
|
||||
if !status.IsLearning {
|
||||
t.Error("is_learning = false, want true")
|
||||
}
|
||||
if status.DaysRemaining != 5.0 {
|
||||
t.Errorf("days_remaining = %f, want 5.0", status.DaysRemaining)
|
||||
}
|
||||
if status.Progress != 28.5 {
|
||||
t.Errorf("progress = %f, want 28.5", status.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
// Test getDiurnalSlots
|
||||
func TestGetDiurnalSlots(t *testing.T) {
|
||||
mockDiurnal := newMockDiurnalBaseline()
|
||||
mockProc := &mockDiurnalLinkProcessor{diurnal: mockDiurnal}
|
||||
|
||||
handler := &DiurnalHandler{
|
||||
pm: &mockDiurnalProcessorManager{
|
||||
processors: map[string]*mockDiurnalLinkProcessor{
|
||||
"AA:BB:CC:DD:EE:FF": mockProc,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/diurnal/slots/AA:BB:CC:DD:EE:FF", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.getDiurnalSlots(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if response["link_id"] != "AA:BB:CC:DD:EE:FF" {
|
||||
t.Errorf("link_id = %v, want AA:BB:CC:DD:EE:FF", response["link_id"])
|
||||
}
|
||||
|
||||
// Check slot_amplitudes exists and has 24 slots
|
||||
slotAmplitudes, ok := response["slot_amplitudes"].([24][]float64)
|
||||
if !ok {
|
||||
t.Fatal("slot_amplitudes missing or wrong type")
|
||||
}
|
||||
|
||||
if len(slotAmplitudes) != 24 {
|
||||
t.Errorf("got %d slots, want 24", len(slotAmplitudes))
|
||||
}
|
||||
|
||||
// Check first slot has data
|
||||
if len(slotAmplitudes[0]) != 64 {
|
||||
t.Errorf("slot 0 has %d values, want 64", len(slotAmplitudes[0]))
|
||||
}
|
||||
|
||||
// Check confidence values exist
|
||||
slotConfidences, ok := response["slot_confidences"].([]float64)
|
||||
if !ok {
|
||||
t.Fatal("slot_confidences missing or wrong type")
|
||||
}
|
||||
|
||||
if len(slotConfidences) != 24 {
|
||||
t.Errorf("got %d confidences, want 24", len(slotConfidences))
|
||||
}
|
||||
|
||||
// Check learning status
|
||||
if response["is_learning"] != true {
|
||||
t.Error("is_learning = false, want true")
|
||||
}
|
||||
|
||||
if response["is_ready"] != false {
|
||||
t.Error("is_ready = true, want false (only 2 days old)")
|
||||
}
|
||||
}
|
||||
|
||||
// Test getDiurnalSlots - missing linkID
|
||||
func TestGetDiurnalSlots_MissingLinkID(t *testing.T) {
|
||||
handler := &DiurnalHandler{
|
||||
pm: &mockDiurnalProcessorManager{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/diurnal/slots/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.getDiurnalSlots(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
var errResp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&errResp)
|
||||
|
||||
if errResp["error"] == "" {
|
||||
t.Error("expected error message")
|
||||
}
|
||||
}
|
||||
|
||||
// Test getDiurnalSlots - link not found
|
||||
func TestGetDiurnalSlots_LinkNotFound(t *testing.T) {
|
||||
handler := &DiurnalHandler{
|
||||
pm: &mockDiurnalProcessorManager{
|
||||
processors: map[string]*mockDiurnalLinkProcessor{},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/diurnal/slots/AA:BB:CC:DD:EE:FF", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.getDiurnalSlots(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test getDiurnalSlots - nil diurnal
|
||||
func TestGetDiurnalSlots_NilDiurnal(t *testing.T) {
|
||||
mockProc := &mockDiurnalLinkProcessor{diurnal: nil}
|
||||
|
||||
handler := &DiurnalHandler{
|
||||
pm: &mockDiurnalProcessorManager{
|
||||
processors: map[string]*mockDiurnalLinkProcessor{
|
||||
"AA:BB:CC:DD:EE:FF": mockProc,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/diurnal/slots/AA:BB:CC:DD:EE:FF", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.getDiurnalSlots(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue