feat: polish & windviz & deploy
This commit is contained in:
parent
81b8e763bd
commit
465ad00f7b
78 changed files with 20622 additions and 2154 deletions
|
|
@ -1,284 +0,0 @@
|
|||
// Package admin implements dataset-management HTTP endpoints used by the
|
||||
// stratoflights operator console.
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/v1/admin/datasets list stored datasets
|
||||
// POST /api/v1/admin/datasets trigger a download
|
||||
// DELETE /api/v1/admin/datasets/{name} delete a stored dataset by filename
|
||||
// GET /api/v1/admin/jobs list all jobs
|
||||
// GET /api/v1/admin/jobs/{id} fetch one job
|
||||
// DELETE /api/v1/admin/jobs/{id} cancel a running job
|
||||
// GET /api/v1/admin/status service status summary
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"predictor-refactored/internal/api/httpjson"
|
||||
"predictor-refactored/internal/datasets"
|
||||
)
|
||||
|
||||
// Handler serves all /api/v1/admin/* endpoints.
|
||||
type Handler struct {
|
||||
mgr *datasets.Manager
|
||||
start time.Time
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// New wires an admin handler.
|
||||
func New(mgr *datasets.Manager, log *zap.Logger) *Handler {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &Handler{mgr: mgr, start: time.Now().UTC(), log: log}
|
||||
}
|
||||
|
||||
// Register installs admin routes on mux.
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/admin/datasets", h.listDatasets)
|
||||
mux.HandleFunc("POST /api/v1/admin/datasets", h.triggerDownload)
|
||||
mux.HandleFunc("DELETE /api/v1/admin/datasets/{name}", h.deleteDataset)
|
||||
mux.HandleFunc("GET /api/v1/admin/jobs", h.listJobs)
|
||||
mux.HandleFunc("GET /api/v1/admin/jobs/{id}", h.getJob)
|
||||
mux.HandleFunc("DELETE /api/v1/admin/jobs/{id}", h.cancelJob)
|
||||
mux.HandleFunc("GET /api/v1/admin/status", h.status)
|
||||
}
|
||||
|
||||
// datasetDTO is the JSON shape of one stored dataset.
|
||||
type datasetDTO struct {
|
||||
Filename string `json:"filename"`
|
||||
Epoch string `json:"epoch"`
|
||||
Subset *subsetDTO `json:"subset,omitempty"`
|
||||
Coverage *coverageDTO `json:"coverage,omitempty"`
|
||||
Loaded bool `json:"loaded"`
|
||||
}
|
||||
|
||||
type subsetDTO struct {
|
||||
Region *datasets.Region `json:"region,omitempty"`
|
||||
HourRange *datasets.HourRange `json:"hour_range,omitempty"`
|
||||
Members []int `json:"members,omitempty"`
|
||||
}
|
||||
|
||||
type coverageDTO struct {
|
||||
Region datasets.Region `json:"region"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
}
|
||||
|
||||
// listDatasets handles GET /api/v1/admin/datasets.
|
||||
func (h *Handler) listDatasets(w http.ResponseWriter, _ *http.Request) {
|
||||
stored, err := h.mgr.ListEpochs()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
loaded := h.mgr.LoadedDatasets()
|
||||
loadedByName := make(map[string]datasets.LoadedDatasetInfo, len(loaded))
|
||||
for _, ld := range loaded {
|
||||
loadedByName[ld.ID.Filename()] = ld
|
||||
}
|
||||
|
||||
out := struct {
|
||||
Source string `json:"source"`
|
||||
Datasets []datasetDTO `json:"datasets"`
|
||||
}{Source: h.mgr.Source(), Datasets: make([]datasetDTO, 0, len(stored))}
|
||||
|
||||
for _, id := range stored {
|
||||
dto := datasetDTO{
|
||||
Filename: id.Filename(),
|
||||
Epoch: id.Epoch.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if !id.Subset.IsGlobal() {
|
||||
dto.Subset = &subsetDTO{
|
||||
Region: id.Subset.Region,
|
||||
HourRange: id.Subset.HourRange,
|
||||
Members: id.Subset.Members,
|
||||
}
|
||||
}
|
||||
if ld, ok := loadedByName[id.Filename()]; ok {
|
||||
dto.Loaded = true
|
||||
dto.Coverage = &coverageDTO{
|
||||
Region: ld.Coverage.Region,
|
||||
StartTime: ld.Coverage.StartTime.UTC().Format(time.RFC3339),
|
||||
EndTime: ld.Coverage.EndTime.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
out.Datasets = append(out.Datasets, dto)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// triggerDownload handles POST /api/v1/admin/datasets.
|
||||
//
|
||||
// Body:
|
||||
// {"latest": true} — refresh the latest global dataset
|
||||
// {"epoch": "2026-03-28T06:00:00Z", "subset": {...}} — explicit dataset
|
||||
func (h *Handler) triggerDownload(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Epoch string `json:"epoch,omitempty"`
|
||||
Latest bool `json:"latest,omitempty"`
|
||||
Subset *datasets.SubsetSpec `json:"subset,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !body.Latest && body.Epoch == "" {
|
||||
writeError(w, http.StatusBadRequest, "specify either epoch or latest=true")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Latest {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
jobID, err := h.mgr.Refresh(ctx, 0)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
|
||||
return
|
||||
}
|
||||
|
||||
epoch, err := time.Parse(time.RFC3339, body.Epoch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid epoch: "+err.Error())
|
||||
return
|
||||
}
|
||||
id := datasets.DatasetID{Epoch: epoch.UTC()}
|
||||
if body.Subset != nil {
|
||||
id.Subset = *body.Subset
|
||||
}
|
||||
jobID := h.mgr.Download(id)
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
|
||||
}
|
||||
|
||||
// deleteDataset handles DELETE /api/v1/admin/datasets/{name}.
|
||||
//
|
||||
// {name} is the dataset filename (DatasetID.Filename()) as returned by GET.
|
||||
func (h *Handler) deleteDataset(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
stored, err := h.mgr.ListEpochs()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, id := range stored {
|
||||
if id.Filename() == name {
|
||||
if err := h.mgr.Remove(id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "dataset not found")
|
||||
}
|
||||
|
||||
// listJobs handles GET /api/v1/admin/jobs.
|
||||
func (h *Handler) listJobs(w http.ResponseWriter, _ *http.Request) {
|
||||
jobs := h.mgr.ListJobs()
|
||||
out := make([]jobDTO, 0, len(jobs))
|
||||
for _, j := range jobs {
|
||||
out = append(out, toDTO(j))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// getJob handles GET /api/v1/admin/jobs/{id}.
|
||||
func (h *Handler) getJob(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
job, ok := h.mgr.GetJob(id)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "job not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toDTO(job))
|
||||
}
|
||||
|
||||
// cancelJob handles DELETE /api/v1/admin/jobs/{id}.
|
||||
func (h *Handler) cancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !h.mgr.CancelJob(id) {
|
||||
writeError(w, http.StatusConflict, "job not found or already terminal")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// status handles GET /api/v1/admin/status — a consolidated dashboard view.
|
||||
func (h *Handler) status(w http.ResponseWriter, _ *http.Request) {
|
||||
jobs := h.mgr.ListJobs()
|
||||
stored, _ := h.mgr.ListEpochs()
|
||||
loaded := h.mgr.LoadedDatasets()
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, j := range jobs {
|
||||
counts[string(j.Status)]++
|
||||
}
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
|
||||
resp := struct {
|
||||
Source string `json:"source"`
|
||||
Uptime string `json:"uptime"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
MemoryMB uint64 `json:"memory_mb"`
|
||||
JobsByStatus map[string]int `json:"jobs_by_status"`
|
||||
Stored int `json:"stored_datasets"`
|
||||
Loaded int `json:"loaded_datasets"`
|
||||
}{
|
||||
Source: h.mgr.Source(),
|
||||
Uptime: time.Since(h.start).Round(time.Second).String(),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
MemoryMB: mem.Alloc / 1024 / 1024,
|
||||
JobsByStatus: counts,
|
||||
Stored: len(stored),
|
||||
Loaded: len(loaded),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type jobDTO struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Dataset string `json:"dataset"`
|
||||
Epoch string `json:"epoch"`
|
||||
Status string `json:"status"`
|
||||
StartedAt string `json:"started_at"`
|
||||
EndedAt string `json:"ended_at,omitempty"`
|
||||
Err string `json:"error,omitempty"`
|
||||
Total int `json:"total_units"`
|
||||
Done int `json:"done_units"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
func toDTO(j datasets.JobInfo) jobDTO {
|
||||
dto := jobDTO{
|
||||
ID: j.ID,
|
||||
Source: j.Source,
|
||||
Dataset: j.Dataset.Filename(),
|
||||
Epoch: j.Dataset.Epoch.UTC().Format(time.RFC3339),
|
||||
Status: string(j.Status),
|
||||
StartedAt: j.StartedAt.UTC().Format(time.RFC3339),
|
||||
Err: j.Err,
|
||||
Total: j.Total,
|
||||
Done: j.Done,
|
||||
Bytes: j.Bytes,
|
||||
}
|
||||
if j.EndedAt != nil {
|
||||
dto.EndedAt = j.EndedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
var writeJSON = httpjson.Write
|
||||
var writeError = httpjson.Error
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
package async
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"predictor-refactored/internal/api/httpjson"
|
||||
"predictor-refactored/internal/api/v2"
|
||||
)
|
||||
|
||||
// Handler implements the /api/v1/predictions{,/{id}} endpoints.
|
||||
type Handler struct {
|
||||
mgr *Manager
|
||||
}
|
||||
|
||||
// NewHandler wires a handler.
|
||||
func NewHandler(mgr *Manager) *Handler { return &Handler{mgr: mgr} }
|
||||
|
||||
// Register installs the async routes on mux.
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/predictions", h.create)
|
||||
mux.HandleFunc("GET /api/v1/predictions/{id}", h.get)
|
||||
mux.HandleFunc("DELETE /api/v1/predictions/{id}", h.cancel)
|
||||
}
|
||||
|
||||
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
var req v2.PredictionRequest
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
info, accepted := h.mgr.Enqueue(req)
|
||||
if !accepted {
|
||||
writeJSON(w, http.StatusServiceUnavailable, info)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/api/v1/predictions/"+info.ID)
|
||||
writeJSON(w, http.StatusAccepted, info)
|
||||
}
|
||||
|
||||
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
info, ok := h.mgr.Get(id)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "prediction job not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
func (h *Handler) cancel(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !h.mgr.Cancel(id) {
|
||||
writeError(w, http.StatusConflict, "job not found or already terminal")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
var writeJSON = httpjson.Write
|
||||
var writeError = httpjson.Error
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
// Package async implements the asynchronous prediction endpoints
|
||||
// (/api/v1/predictions{,/{id}}) and the worker pool that executes them.
|
||||
// Package async runs profile-driven predictions on a bounded worker pool and
|
||||
// retains their results in memory for a configurable TTL. It is the engine
|
||||
// behind the asynchronous prediction endpoints; the HTTP surface itself is
|
||||
// the ogen-generated server in the parent package.
|
||||
//
|
||||
// Each enqueued request is assigned a job ID; the result is held in
|
||||
// memory for a configurable TTL after completion.
|
||||
// The package is decoupled from the request/response wire types: a RunFunc is
|
||||
// injected at construction, so this file imports only the generated API types
|
||||
// it stores and returns.
|
||||
package async
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
|
@ -13,12 +17,13 @@ import (
|
|||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"predictor-refactored/internal/api/v2"
|
||||
"predictor-refactored/internal/datasets"
|
||||
"predictor-refactored/internal/elevation"
|
||||
"predictor-refactored/internal/metrics"
|
||||
apirest "predictor-refactored/pkg/rest"
|
||||
)
|
||||
|
||||
// RunFunc executes one prediction synchronously.
|
||||
type RunFunc func(req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error)
|
||||
|
||||
// Status is the lifecycle state of a prediction job.
|
||||
type Status string
|
||||
|
||||
|
|
@ -30,20 +35,20 @@ const (
|
|||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
// JobInfo is the externally-visible snapshot of one prediction job.
|
||||
// JobInfo is a snapshot of one prediction job.
|
||||
type JobInfo struct {
|
||||
ID string `json:"id"`
|
||||
Status Status `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Result *v2.PredictionResponse `json:"result,omitempty"`
|
||||
ID string
|
||||
Status Status
|
||||
CreatedAt time.Time
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
Error string
|
||||
Result *apirest.PredictionV2Response
|
||||
}
|
||||
|
||||
type job struct {
|
||||
id string
|
||||
req v2.PredictionRequest
|
||||
req *apirest.PredictionV2Request
|
||||
createdAt time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
|
|
@ -51,19 +56,15 @@ type job struct {
|
|||
startedAt time.Time
|
||||
completedAt time.Time
|
||||
errStr string
|
||||
result *v2.PredictionResponse
|
||||
cancel chan struct{}
|
||||
result *apirest.PredictionV2Response
|
||||
}
|
||||
|
||||
func (j *job) snapshot() JobInfo {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
info := JobInfo{
|
||||
ID: j.id,
|
||||
Status: j.status,
|
||||
CreatedAt: j.createdAt,
|
||||
Error: j.errStr,
|
||||
Result: j.result,
|
||||
ID: j.id, Status: j.status, CreatedAt: j.createdAt,
|
||||
Error: j.errStr, Result: j.result,
|
||||
}
|
||||
if !j.startedAt.IsZero() {
|
||||
t := j.startedAt
|
||||
|
|
@ -76,16 +77,14 @@ func (j *job) snapshot() JobInfo {
|
|||
return info
|
||||
}
|
||||
|
||||
// Manager runs a fixed pool of workers to execute prediction jobs and
|
||||
// retains their results for the configured TTL.
|
||||
// Manager runs a fixed pool of workers and retains job results for a TTL.
|
||||
type Manager struct {
|
||||
mgr *datasets.Manager
|
||||
elev *elevation.Dataset
|
||||
run RunFunc
|
||||
metrics metrics.Sink
|
||||
log *zap.Logger
|
||||
|
||||
queue chan *job
|
||||
ttl time.Duration
|
||||
queue chan *job
|
||||
ttl time.Duration
|
||||
|
||||
jobsMu sync.RWMutex
|
||||
jobs map[string]*job
|
||||
|
|
@ -97,16 +96,14 @@ type Manager struct {
|
|||
|
||||
// Config controls Manager construction.
|
||||
type Config struct {
|
||||
// Workers is the maximum concurrent prediction executions.
|
||||
Workers int
|
||||
// QueueSize bounds the number of jobs waiting to start.
|
||||
QueueSize int
|
||||
// ResultTTL is how long completed/failed jobs are retained in memory.
|
||||
ResultTTL time.Duration
|
||||
Workers int // max concurrent executions
|
||||
QueueSize int // pending-queue bound
|
||||
ResultTTL time.Duration // retention of terminal jobs
|
||||
}
|
||||
|
||||
// New constructs a Manager with the given config and starts the workers.
|
||||
func New(cfg Config, mgr *datasets.Manager, elev *elevation.Dataset, sink metrics.Sink, log *zap.Logger) *Manager {
|
||||
// New constructs a Manager and starts its workers. run executes one
|
||||
// prediction; sink and log may be nil.
|
||||
func New(cfg Config, run RunFunc, sink metrics.Sink, log *zap.Logger) *Manager {
|
||||
if cfg.Workers <= 0 {
|
||||
cfg.Workers = 4
|
||||
}
|
||||
|
|
@ -123,7 +120,7 @@ func New(cfg Config, mgr *datasets.Manager, elev *elevation.Dataset, sink metric
|
|||
log = zap.NewNop()
|
||||
}
|
||||
m := &Manager{
|
||||
mgr: mgr, elev: elev, metrics: sink, log: log,
|
||||
run: run, metrics: sink, log: log,
|
||||
queue: make(chan *job, cfg.QueueSize),
|
||||
jobs: make(map[string]*job),
|
||||
ttl: cfg.ResultTTL,
|
||||
|
|
@ -138,15 +135,14 @@ func New(cfg Config, mgr *datasets.Manager, elev *elevation.Dataset, sink metric
|
|||
return m
|
||||
}
|
||||
|
||||
// Enqueue creates a new job from req and returns its snapshot.
|
||||
// Returns false when the queue is full.
|
||||
func (m *Manager) Enqueue(req v2.PredictionRequest) (JobInfo, bool) {
|
||||
// Enqueue creates a job from req and returns its snapshot. The bool is false
|
||||
// when the queue is full (the returned job is marked failed).
|
||||
func (m *Manager) Enqueue(req *apirest.PredictionV2Request) (JobInfo, bool) {
|
||||
j := &job{
|
||||
id: uuid.New().String(),
|
||||
req: req,
|
||||
createdAt: time.Now().UTC(),
|
||||
status: StatusPending,
|
||||
cancel: make(chan struct{}),
|
||||
}
|
||||
m.jobsMu.Lock()
|
||||
m.jobs[j.id] = j
|
||||
|
|
@ -156,7 +152,6 @@ func (m *Manager) Enqueue(req v2.PredictionRequest) (JobInfo, bool) {
|
|||
case m.queue <- j:
|
||||
return j.snapshot(), true
|
||||
default:
|
||||
// Queue full — mark the job failed and return it.
|
||||
j.mu.Lock()
|
||||
j.status = StatusFailed
|
||||
j.errStr = "prediction queue full"
|
||||
|
|
@ -177,8 +172,11 @@ func (m *Manager) Get(id string) (JobInfo, bool) {
|
|||
return j.snapshot(), true
|
||||
}
|
||||
|
||||
// Cancel marks a not-yet-started job as cancelled. Returns false when the
|
||||
// job is unknown or already terminal.
|
||||
// Cancel marks a still-queued job cancelled. Returns false when the job is
|
||||
// unknown or already running/terminal — a running prediction cannot be
|
||||
// interrupted (the worker would otherwise overwrite the cancelled status with
|
||||
// its result), so callers get an honest "too late" rather than a 204 that the
|
||||
// worker silently undoes.
|
||||
func (m *Manager) Cancel(id string) bool {
|
||||
m.jobsMu.RLock()
|
||||
j, ok := m.jobs[id]
|
||||
|
|
@ -187,22 +185,19 @@ func (m *Manager) Cancel(id string) bool {
|
|||
return false
|
||||
}
|
||||
j.mu.Lock()
|
||||
terminal := j.status == StatusComplete || j.status == StatusFailed || j.status == StatusCancelled
|
||||
if terminal {
|
||||
j.mu.Unlock()
|
||||
defer j.mu.Unlock()
|
||||
if j.status != StatusPending {
|
||||
return false
|
||||
}
|
||||
j.status = StatusCancelled
|
||||
j.completedAt = time.Now().UTC()
|
||||
j.mu.Unlock()
|
||||
close(j.cancel)
|
||||
return true
|
||||
}
|
||||
|
||||
// Inflight returns the count of running jobs.
|
||||
// Inflight returns the number of running jobs.
|
||||
func (m *Manager) Inflight() int64 { return m.inflight.Load() }
|
||||
|
||||
// Close shuts down workers and the evictor.
|
||||
// Close stops the workers and the evictor.
|
||||
func (m *Manager) Close() {
|
||||
close(m.closed)
|
||||
close(m.queue)
|
||||
|
|
@ -212,41 +207,49 @@ func (m *Manager) Close() {
|
|||
func (m *Manager) worker() {
|
||||
defer m.wg.Done()
|
||||
for j := range m.queue {
|
||||
// Check cancellation before starting.
|
||||
j.mu.Lock()
|
||||
cancelled := j.status == StatusCancelled
|
||||
if !cancelled {
|
||||
j.status = StatusRunning
|
||||
j.startedAt = time.Now().UTC()
|
||||
}
|
||||
j.mu.Unlock()
|
||||
if cancelled {
|
||||
continue
|
||||
}
|
||||
m.inflight.Add(1)
|
||||
j.mu.Lock()
|
||||
j.status = StatusRunning
|
||||
j.startedAt = time.Now().UTC()
|
||||
j.mu.Unlock()
|
||||
|
||||
resp, err := v2.Run(m.mgr, m.elev, j.req)
|
||||
|
||||
j.mu.Lock()
|
||||
j.completedAt = time.Now().UTC()
|
||||
if err != nil {
|
||||
j.status = StatusFailed
|
||||
j.errStr = err.Error()
|
||||
} else {
|
||||
j.status = StatusComplete
|
||||
j.result = resp
|
||||
}
|
||||
j.mu.Unlock()
|
||||
m.inflight.Add(-1)
|
||||
|
||||
if err == nil {
|
||||
m.metrics.Prediction("async", j.completedAt.Sub(j.startedAt), nil)
|
||||
} else {
|
||||
m.metrics.Prediction("async", j.completedAt.Sub(j.startedAt), err)
|
||||
}
|
||||
m.execute(j)
|
||||
}
|
||||
}
|
||||
|
||||
// execute runs one job, recovering from a panic in the injected RunFunc so a
|
||||
// single bad prediction can't leak the inflight counter or kill the worker.
|
||||
func (m *Manager) execute(j *job) {
|
||||
m.inflight.Add(1)
|
||||
defer m.inflight.Add(-1)
|
||||
|
||||
resp, err := func() (resp *apirest.PredictionV2Response, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("prediction panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
return m.run(j.req)
|
||||
}()
|
||||
|
||||
j.mu.Lock()
|
||||
j.completedAt = time.Now().UTC()
|
||||
if err != nil {
|
||||
j.status = StatusFailed
|
||||
j.errStr = err.Error()
|
||||
} else {
|
||||
j.status = StatusComplete
|
||||
j.result = resp
|
||||
}
|
||||
dur := j.completedAt.Sub(j.startedAt)
|
||||
j.mu.Unlock()
|
||||
m.metrics.Prediction("async", dur, err)
|
||||
}
|
||||
|
||||
func (m *Manager) evictor() {
|
||||
defer m.wg.Done()
|
||||
ticker := time.NewTicker(m.ttl / 4)
|
||||
|
|
|
|||
189
internal/api/datasets.go
Normal file
189
internal/api/datasets.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"predictor-refactored/internal/datasets"
|
||||
apirest "predictor-refactored/pkg/rest"
|
||||
)
|
||||
|
||||
// ListDatasets implements GET /api/v1/admin/datasets.
|
||||
func (h *Handler) ListDatasets(_ context.Context) (*apirest.DatasetList, error) {
|
||||
stored, err := h.mgr.ListEpochs()
|
||||
if err != nil {
|
||||
return nil, apiError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
loaded := make(map[string]datasets.LoadedDatasetInfo)
|
||||
for _, ld := range h.mgr.LoadedDatasets() {
|
||||
loaded[ld.ID.Filename()] = ld
|
||||
}
|
||||
|
||||
out := &apirest.DatasetList{Source: h.mgr.Source(), Datasets: make([]apirest.DatasetEntry, 0, len(stored))}
|
||||
for _, id := range stored {
|
||||
entry := apirest.DatasetEntry{
|
||||
Filename: id.Filename(),
|
||||
Epoch: id.Epoch.UTC(),
|
||||
}
|
||||
if !id.Subset.IsGlobal() {
|
||||
entry.Subset = apirest.NewOptSubsetSpec(subsetToAPI(id.Subset))
|
||||
}
|
||||
if ld, ok := loaded[id.Filename()]; ok {
|
||||
entry.Loaded = true
|
||||
entry.Coverage = apirest.NewOptCoverage(coverageToAPI(ld.Coverage))
|
||||
}
|
||||
out.Datasets = append(out.Datasets, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TriggerDatasetDownload implements POST /api/v1/admin/datasets.
|
||||
func (h *Handler) TriggerDatasetDownload(ctx context.Context, req *apirest.DownloadRequest) (*apirest.DownloadAccepted, error) {
|
||||
if req.Latest.Or(false) {
|
||||
dctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
jobID, err := h.mgr.Refresh(dctx, 0)
|
||||
if err != nil {
|
||||
return nil, apiError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return &apirest.DownloadAccepted{JobID: jobID}, nil
|
||||
}
|
||||
|
||||
epoch, ok := req.Epoch.Get()
|
||||
if !ok {
|
||||
return nil, apiError(http.StatusBadRequest, "specify either epoch or latest=true")
|
||||
}
|
||||
id := datasets.DatasetID{Epoch: epoch.UTC()}
|
||||
if s, ok := req.Subset.Get(); ok {
|
||||
id.Subset = subsetFromAPI(s)
|
||||
}
|
||||
return &apirest.DownloadAccepted{JobID: h.mgr.Download(id)}, nil
|
||||
}
|
||||
|
||||
// DeleteDataset implements DELETE /api/v1/admin/datasets/{name}.
|
||||
func (h *Handler) DeleteDataset(_ context.Context, params apirest.DeleteDatasetParams) error {
|
||||
stored, err := h.mgr.ListEpochs()
|
||||
if err != nil {
|
||||
return apiError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
for _, id := range stored {
|
||||
if id.Filename() == params.Name {
|
||||
if err := h.mgr.Remove(id); err != nil {
|
||||
return apiError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return apiError(http.StatusNotFound, "dataset not found")
|
||||
}
|
||||
|
||||
// ListDatasetJobs implements GET /api/v1/admin/jobs.
|
||||
func (h *Handler) ListDatasetJobs(_ context.Context) ([]apirest.DownloadJob, error) {
|
||||
jobs := h.mgr.ListJobs()
|
||||
out := make([]apirest.DownloadJob, 0, len(jobs))
|
||||
for _, j := range jobs {
|
||||
out = append(out, downloadJobToAPI(j))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetDatasetJob implements GET /api/v1/admin/jobs/{id}.
|
||||
func (h *Handler) GetDatasetJob(_ context.Context, params apirest.GetDatasetJobParams) (*apirest.DownloadJob, error) {
|
||||
j, ok := h.mgr.GetJob(params.ID)
|
||||
if !ok {
|
||||
return nil, apiError(http.StatusNotFound, "job not found")
|
||||
}
|
||||
dto := downloadJobToAPI(j)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// CancelDatasetJob implements DELETE /api/v1/admin/jobs/{id}.
|
||||
func (h *Handler) CancelDatasetJob(_ context.Context, params apirest.CancelDatasetJobParams) error {
|
||||
if !h.mgr.CancelJob(params.ID) {
|
||||
return apiError(http.StatusConflict, "job not found or already terminal")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServiceStatus implements GET /api/v1/admin/status.
|
||||
func (h *Handler) GetServiceStatus(_ context.Context) (*apirest.StatusResponse, error) {
|
||||
jobs := h.mgr.ListJobs()
|
||||
stored, _ := h.mgr.ListEpochs()
|
||||
loaded := h.mgr.LoadedDatasets()
|
||||
|
||||
byStatus := apirest.StatusResponseJobsByStatus{}
|
||||
for _, j := range jobs {
|
||||
byStatus[string(j.Status)]++
|
||||
}
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
|
||||
return &apirest.StatusResponse{
|
||||
Source: h.mgr.Source(),
|
||||
Uptime: time.Since(h.started).Round(time.Second).String(),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
MemoryMB: int64(mem.Alloc / 1024 / 1024),
|
||||
JobsByStatus: byStatus,
|
||||
StoredDatasets: len(stored),
|
||||
LoadedDatasets: len(loaded),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- dataset mapping helpers ----------------------------------------------
|
||||
|
||||
func downloadJobToAPI(j datasets.JobInfo) apirest.DownloadJob {
|
||||
dto := apirest.DownloadJob{
|
||||
ID: j.ID,
|
||||
Source: j.Source,
|
||||
Dataset: j.Dataset.Filename(),
|
||||
Epoch: j.Dataset.Epoch.UTC(),
|
||||
Status: apirest.DownloadJobStatus(j.Status),
|
||||
StartedAt: j.StartedAt.UTC(),
|
||||
TotalUnits: j.Total,
|
||||
DoneUnits: j.Done,
|
||||
Bytes: j.Bytes,
|
||||
}
|
||||
if j.EndedAt != nil {
|
||||
dto.EndedAt = apirest.NewOptDateTime(j.EndedAt.UTC())
|
||||
}
|
||||
if j.Err != "" {
|
||||
dto.Error = apirest.NewOptString(j.Err)
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
func subsetToAPI(s datasets.SubsetSpec) apirest.SubsetSpec {
|
||||
out := apirest.SubsetSpec{Members: s.Members}
|
||||
if s.Region != nil {
|
||||
out.Region = apirest.NewOptRegion(regionToAPI(*s.Region))
|
||||
}
|
||||
if s.HourRange != nil {
|
||||
out.HourRange = apirest.NewOptHourRange(apirest.HourRange{MinHour: s.HourRange.MinHour, MaxHour: s.HourRange.MaxHour})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func subsetFromAPI(s apirest.SubsetSpec) datasets.SubsetSpec {
|
||||
out := datasets.SubsetSpec{Members: s.Members}
|
||||
if r, ok := s.Region.Get(); ok {
|
||||
out.Region = &datasets.Region{MinLat: r.MinLat, MaxLat: r.MaxLat, MinLng: r.MinLng, MaxLng: r.MaxLng}
|
||||
}
|
||||
if hr, ok := s.HourRange.Get(); ok {
|
||||
out.HourRange = &datasets.HourRange{MinHour: hr.MinHour, MaxHour: hr.MaxHour}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func regionToAPI(r datasets.Region) apirest.Region {
|
||||
return apirest.Region{MinLat: r.MinLat, MaxLat: r.MaxLat, MinLng: r.MinLng, MaxLng: r.MaxLng}
|
||||
}
|
||||
|
||||
func coverageToAPI(c datasets.Coverage) apirest.Coverage {
|
||||
return apirest.Coverage{
|
||||
Region: regionToAPI(c.Region),
|
||||
StartTime: c.StartTime.UTC(),
|
||||
EndTime: c.EndTime.UTC(),
|
||||
}
|
||||
}
|
||||
48
internal/api/docs/docs.go
Normal file
48
internal/api/docs/docs.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Package docs serves the human-facing API documentation: the OpenAPI
|
||||
// document and a ReDoc rendering of it. The spec is embedded in the binary
|
||||
// (see package apispec) so the documentation needs no external files or a
|
||||
// separate server.
|
||||
package docs
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
apispec "predictor-refactored/api"
|
||||
)
|
||||
|
||||
// redocHTML renders the embedded spec with ReDoc loaded from a CDN.
|
||||
const redocHTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>stratoflights-predictor API</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>body { margin: 0; padding: 0; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="/openapi.yaml"></redoc>
|
||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Handler serves the documentation endpoints.
|
||||
type Handler struct{}
|
||||
|
||||
// New returns a docs Handler.
|
||||
func New() *Handler { return &Handler{} }
|
||||
|
||||
// Register installs GET /docs and GET /openapi.yaml on mux.
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /openapi.yaml", h.spec)
|
||||
mux.HandleFunc("GET /docs", h.redoc)
|
||||
}
|
||||
|
||||
func (h *Handler) spec(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
_, _ = w.Write(apispec.Spec)
|
||||
}
|
||||
|
||||
func (h *Handler) redoc(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(redocHTML))
|
||||
}
|
||||
70
internal/api/handler.go
Normal file
70
internal/api/handler.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"predictor-refactored/internal/api/async"
|
||||
"predictor-refactored/internal/datasets"
|
||||
"predictor-refactored/internal/elevation"
|
||||
"predictor-refactored/internal/engine"
|
||||
"predictor-refactored/internal/metrics"
|
||||
"predictor-refactored/internal/windviz"
|
||||
apirest "predictor-refactored/pkg/rest"
|
||||
)
|
||||
|
||||
// Handler implements the ogen-generated apirest.Handler interface for every
|
||||
// operation in the OpenAPI spec. Operation methods are grouped by concern
|
||||
// across prediction.go, datasets.go, and wind.go.
|
||||
type Handler struct {
|
||||
mgr *datasets.Manager
|
||||
elev *elevation.Dataset
|
||||
async *async.Manager
|
||||
metrics metrics.Sink
|
||||
cache *windviz.Cache
|
||||
started time.Time
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
var _ apirest.Handler = (*Handler)(nil)
|
||||
|
||||
// terrain returns the elevation dataset as an engine.TerrainProvider, or an
|
||||
// untyped nil interface when no elevation dataset is loaded. Returning the
|
||||
// concrete nil *elevation.Dataset directly would produce a non-nil interface
|
||||
// wrapping a nil pointer, which then panics on first use — so the nil check
|
||||
// must happen here, on the concrete type.
|
||||
func (h *Handler) terrain() engine.TerrainProvider {
|
||||
if h.elev == nil {
|
||||
return nil
|
||||
}
|
||||
return h.elev
|
||||
}
|
||||
|
||||
// NewError converts an error returned by a handler into the spec's default
|
||||
// error response. Handlers return *apirest.DefaultErrorStatusCode (via the
|
||||
// apiError helper) to control the status code; anything else is a 500.
|
||||
func (h *Handler) NewError(_ context.Context, err error) *apirest.DefaultErrorStatusCode {
|
||||
var coded *apirest.DefaultErrorStatusCode
|
||||
if errors.As(err, &coded) {
|
||||
return coded
|
||||
}
|
||||
h.log.Error("unhandled handler error", zap.Error(err))
|
||||
return apiError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// apiError builds a coded error response carrying an HTTP status.
|
||||
func apiError(status int, description string) *apirest.DefaultErrorStatusCode {
|
||||
return &apirest.DefaultErrorStatusCode{
|
||||
StatusCode: status,
|
||||
Response: apirest.Error{
|
||||
Error: apirest.ErrorError{
|
||||
Type: http.StatusText(status),
|
||||
Description: description,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
// Package httpjson holds the tiny JSON response helpers shared across
|
||||
// the admin, v2, and async handlers.
|
||||
package httpjson
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Write writes body as JSON with the given status code.
|
||||
func Write(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// Error writes a standard error JSON body with the given status code.
|
||||
//
|
||||
// Shape: {"error": {"type": "...", "description": "..."}}
|
||||
func Error(w http.ResponseWriter, status int, description string) {
|
||||
Write(w, status, map[string]any{
|
||||
"error": map[string]string{
|
||||
"type": http.StatusText(status),
|
||||
"description": description,
|
||||
},
|
||||
})
|
||||
}
|
||||
217
internal/api/mapping.go
Normal file
217
internal/api/mapping.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"predictor-refactored/internal/api/async"
|
||||
"predictor-refactored/internal/engine"
|
||||
apirest "predictor-refactored/pkg/rest"
|
||||
)
|
||||
|
||||
// normalizeLng folds a longitude into [0, 360) for internal use.
|
||||
func normalizeLng(lng float64) float64 {
|
||||
if lng < 0 {
|
||||
return lng + 360
|
||||
}
|
||||
return lng
|
||||
}
|
||||
|
||||
// signedLng converts an internal [0, 360) longitude back to [-180, 180).
|
||||
func signedLng(lng float64) float64 {
|
||||
if lng > 180 {
|
||||
return lng - 360
|
||||
}
|
||||
return lng
|
||||
}
|
||||
|
||||
// buildProfile translates an API prediction request into an engine profile
|
||||
// using the engine's model/constraint registry.
|
||||
// maxProfileStages bounds the propagator chain length to keep a single
|
||||
// request's work bounded.
|
||||
const maxProfileStages = 32
|
||||
|
||||
func buildProfile(req *apirest.PredictionV2Request, deps engine.BuildDeps) (engine.Profile, error) {
|
||||
if len(req.Profile) == 0 {
|
||||
return engine.Profile{}, fmt.Errorf("profile must contain at least one stage")
|
||||
}
|
||||
if len(req.Profile) > maxProfileStages {
|
||||
return engine.Profile{}, fmt.Errorf("profile has %d stages; maximum is %d", len(req.Profile), maxProfileStages)
|
||||
}
|
||||
|
||||
step := 60.0
|
||||
tol := 0.01
|
||||
if o, ok := req.Options.Get(); ok {
|
||||
step = o.StepSeconds.Or(step)
|
||||
tol = o.Tolerance.Or(tol)
|
||||
}
|
||||
if step <= 0 || step > 3600 {
|
||||
return engine.Profile{}, fmt.Errorf("options.step_seconds must be in (0, 3600], got %g", step)
|
||||
}
|
||||
if tol <= 0 || tol >= 1 {
|
||||
return engine.Profile{}, fmt.Errorf("options.tolerance must be in (0, 1), got %g", tol)
|
||||
}
|
||||
|
||||
dir := engine.Forward
|
||||
if req.Direction.Or(apirest.PredictionV2RequestDirectionForward) == apirest.PredictionV2RequestDirectionReverse {
|
||||
dir = engine.Reverse
|
||||
}
|
||||
|
||||
props := make([]*engine.Propagator, len(req.Profile))
|
||||
for i, stage := range req.Profile {
|
||||
if stage.Name == "" {
|
||||
return engine.Profile{}, fmt.Errorf("stage %d: name is required", i)
|
||||
}
|
||||
built, err := engine.BuildModel(toEngineModelSpec(stage.Model), deps)
|
||||
if err != nil {
|
||||
return engine.Profile{}, fmt.Errorf("stage %q model: %w", stage.Name, err)
|
||||
}
|
||||
constraints, err := toEngineConstraints(stage.Constraints, deps)
|
||||
if err != nil {
|
||||
return engine.Profile{}, fmt.Errorf("stage %q: %w", stage.Name, err)
|
||||
}
|
||||
props[i] = &engine.Propagator{
|
||||
Name: stage.Name,
|
||||
Step: step,
|
||||
Model: built.Model,
|
||||
BuildModel: built.Build,
|
||||
Constraints: constraints,
|
||||
Tolerance: tol,
|
||||
}
|
||||
}
|
||||
for i, stage := range req.Profile {
|
||||
idx, ok := stage.FallbackIndex.Get()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if idx < 0 || idx >= len(props) {
|
||||
return engine.Profile{}, fmt.Errorf("stage %q: fallback_index %d out of range", stage.Name, idx)
|
||||
}
|
||||
props[i].Fallback = props[idx]
|
||||
}
|
||||
|
||||
globals, err := toEngineConstraints(req.Globals, deps)
|
||||
if err != nil {
|
||||
return engine.Profile{}, fmt.Errorf("globals: %w", err)
|
||||
}
|
||||
return engine.Profile{Stages: props, Direction: dir, Globals: globals}, nil
|
||||
}
|
||||
|
||||
func toEngineModelSpec(m apirest.ModelSpec) engine.ModelSpec {
|
||||
out := engine.ModelSpec{
|
||||
Type: string(m.Type),
|
||||
Rate: m.Rate.Or(0),
|
||||
SeaLevelRate: m.SeaLevelRate.Or(0),
|
||||
IncludeWind: m.IncludeWind.Or(false),
|
||||
}
|
||||
for _, s := range m.Segments {
|
||||
out.Segments = append(out.Segments, engine.PiecewiseSegmentSpec{
|
||||
Until: s.Until,
|
||||
Rate: s.Rate,
|
||||
Reference: string(s.Reference.Or(apirest.PiecewiseSegmentReferenceAbsolute)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toEngineConstraints(specs []apirest.ConstraintSpec, deps engine.BuildDeps) ([]engine.Constraint, error) {
|
||||
out := make([]engine.Constraint, 0, len(specs))
|
||||
for i, s := range specs {
|
||||
c, err := engine.BuildConstraint(toEngineConstraintSpec(s), deps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("constraint[%d]: %w", i, err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func toEngineConstraintSpec(c apirest.ConstraintSpec) engine.ConstraintSpec {
|
||||
spec := engine.ConstraintSpec{
|
||||
Type: string(c.Type),
|
||||
Op: string(c.Op.Or("")),
|
||||
Limit: c.Limit.Or(0),
|
||||
Action: string(c.Action.Or(apirest.ConstraintSpecActionStop)),
|
||||
Mode: string(c.Mode.Or("")),
|
||||
Label: c.Label.Or(""),
|
||||
}
|
||||
for _, v := range c.Vertices {
|
||||
spec.Vertices = append(spec.Vertices, engine.PolygonVertex{Lat: v.Lat, Lng: v.Lng})
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
// stageResultToAPI maps one engine stage result to the API representation.
|
||||
func stageResultToAPI(r engine.Result) apirest.StageResult {
|
||||
out := apirest.StageResult{
|
||||
Name: r.Propagator,
|
||||
Outcome: apirest.StageResultOutcome(r.Outcome.String()),
|
||||
Events: eventsToAPI(r.Events),
|
||||
}
|
||||
if r.Constraint != nil {
|
||||
out.Constraint = apirest.NewOptString(r.ConstraintName)
|
||||
out.Termination = apirest.NewOptTerminationInfo(apirest.TerminationInfo{
|
||||
ViolationTime: time.Unix(int64(r.ViolationTime), 0).UTC(),
|
||||
ViolationState: geoStateToAPI(r.ViolationState),
|
||||
RefinedTime: time.Unix(int64(r.RefinedTime), 0).UTC(),
|
||||
RefinedState: geoStateToAPI(r.RefinedState),
|
||||
})
|
||||
}
|
||||
n := r.Path.Len()
|
||||
out.Trajectory = make([]apirest.TrajectoryPoint, n)
|
||||
for i := range n {
|
||||
t, p := r.Path.At(i)
|
||||
out.Trajectory[i] = apirest.TrajectoryPoint{
|
||||
Time: time.Unix(int64(t), 0).UTC(),
|
||||
Latitude: p.Lat,
|
||||
Longitude: signedLng(p.Lng),
|
||||
Altitude: p.Altitude,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func geoStateToAPI(s engine.State) apirest.GeoState {
|
||||
return apirest.GeoState{Lat: s.Lat, Lng: signedLng(s.Lng), Altitude: s.Altitude}
|
||||
}
|
||||
|
||||
func eventsToAPI(in []engine.EventSummary) []apirest.EventSummary {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]apirest.EventSummary, 0, len(in))
|
||||
for _, e := range in {
|
||||
out = append(out, apirest.EventSummary{
|
||||
Type: e.Type,
|
||||
Count: e.Count,
|
||||
FirstTime: apirest.NewOptFloat64(e.FirstTime),
|
||||
LastTime: apirest.NewOptFloat64(e.LastTime),
|
||||
FirstState: apirest.NewOptGeoState(geoStateToAPI(e.FirstState)),
|
||||
LastState: apirest.NewOptGeoState(geoStateToAPI(e.LastState)),
|
||||
Message: apirest.NewOptString(e.Message),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// asyncJobToAPI maps an async job snapshot to the API PredictionJob.
|
||||
func asyncJobToAPI(info async.JobInfo) *apirest.PredictionJob {
|
||||
job := &apirest.PredictionJob{
|
||||
ID: info.ID,
|
||||
Status: apirest.PredictionJobStatus(info.Status),
|
||||
CreatedAt: info.CreatedAt,
|
||||
}
|
||||
if info.StartedAt != nil {
|
||||
job.StartedAt = apirest.NewOptDateTime(*info.StartedAt)
|
||||
}
|
||||
if info.CompletedAt != nil {
|
||||
job.CompletedAt = apirest.NewOptDateTime(*info.CompletedAt)
|
||||
}
|
||||
if info.Error != "" {
|
||||
job.Error = apirest.NewOptString(info.Error)
|
||||
}
|
||||
if info.Result != nil {
|
||||
job.Result = apirest.NewOptPredictionV2Response(*info.Result)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
|
@ -1,51 +1,34 @@
|
|||
// Package middleware contains HTTP and ogen middleware used by the API layer.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ogen-go/ogen/middleware"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// OgenLogging is an ogen middleware that logs request duration and outcome.
|
||||
// statusCoder is implemented by ogen's *...StatusCode error wrappers.
|
||||
type statusCoder interface{ GetStatusCode() int }
|
||||
|
||||
// OgenLogging is an ogen middleware that logs each operation's duration and
|
||||
// outcome. Handler errors carrying a 4xx/5xx-class status are logged at the
|
||||
// appropriate level: client errors (and expected 503s during startup) at
|
||||
// warn without a stacktrace, server errors at error.
|
||||
func OgenLogging(log *zap.Logger) middleware.Middleware {
|
||||
return func(req middleware.Request, next func(req middleware.Request) (middleware.Response, error)) (middleware.Response, error) {
|
||||
lg := log.With(zap.String("op", req.OperationID))
|
||||
start := time.Now()
|
||||
resp, err := next(req)
|
||||
dur := time.Since(start)
|
||||
if err != nil {
|
||||
lg.Error("request failed", zap.Duration("duration", dur), zap.Error(err))
|
||||
lg := log.With(zap.String("operation", req.OperationID), zap.Duration("duration", time.Since(start)))
|
||||
|
||||
if err == nil {
|
||||
lg.Info("request completed")
|
||||
return resp, err
|
||||
}
|
||||
if sc, ok := err.(statusCoder); ok && sc.GetStatusCode() < 500 {
|
||||
lg.Warn("request rejected", zap.Int("status", sc.GetStatusCode()), zap.NamedError("reason", err))
|
||||
} else {
|
||||
lg.Info("request completed", zap.Duration("duration", dur))
|
||||
lg.Error("request failed", zap.Error(err))
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
}
|
||||
|
||||
// statusRecorder captures the response status for HTTPLogging.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// HTTPLogging wraps the given http.Handler with a per-request log line.
|
||||
func HTTPLogging(log *zap.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: 200}
|
||||
next.ServeHTTP(rec, r)
|
||||
log.Info("http",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.Int("status", rec.status),
|
||||
zap.Duration("duration", time.Since(start)))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
239
internal/api/prediction.go
Normal file
239
internal/api/prediction.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"predictor-refactored/internal/engine"
|
||||
"predictor-refactored/internal/weather"
|
||||
apirest "predictor-refactored/pkg/rest"
|
||||
)
|
||||
|
||||
// ReadinessCheck implements GET /ready.
|
||||
func (h *Handler) ReadinessCheck(_ context.Context) (*apirest.ReadinessResponse, error) {
|
||||
resp := &apirest.ReadinessResponse{}
|
||||
if field := h.mgr.Active(); field != nil {
|
||||
resp.Status = apirest.ReadinessResponseStatusOk
|
||||
resp.DatasetTime = apirest.NewOptDateTime(field.Epoch())
|
||||
} else {
|
||||
resp.Status = apirest.ReadinessResponseStatusNotReady
|
||||
resp.ErrorMessage = apirest.NewOptString("no dataset loaded")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// PerformPredictionV2 implements POST /api/v2/prediction.
|
||||
func (h *Handler) PerformPredictionV2(_ context.Context, req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error) {
|
||||
resp, err := h.runPredictionV2(req)
|
||||
if err == nil {
|
||||
h.metrics.Prediction("v2", resp.CompletedAt.Sub(resp.StartedAt), nil)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// CreatePredictionJob implements POST /api/v1/predictions.
|
||||
func (h *Handler) CreatePredictionJob(_ context.Context, req *apirest.PredictionV2Request) (*apirest.PredictionJob, error) {
|
||||
info, accepted := h.async.Enqueue(req)
|
||||
if !accepted {
|
||||
return nil, apiError(http.StatusServiceUnavailable, info.Error)
|
||||
}
|
||||
return asyncJobToAPI(info), nil
|
||||
}
|
||||
|
||||
// GetPredictionJob implements GET /api/v1/predictions/{id}.
|
||||
func (h *Handler) GetPredictionJob(_ context.Context, params apirest.GetPredictionJobParams) (*apirest.PredictionJob, error) {
|
||||
info, ok := h.async.Get(params.ID)
|
||||
if !ok {
|
||||
return nil, apiError(http.StatusNotFound, "prediction job not found")
|
||||
}
|
||||
return asyncJobToAPI(info), nil
|
||||
}
|
||||
|
||||
// CancelPredictionJob implements DELETE /api/v1/predictions/{id}.
|
||||
func (h *Handler) CancelPredictionJob(_ context.Context, params apirest.CancelPredictionJobParams) error {
|
||||
if !h.async.Cancel(params.ID) {
|
||||
return apiError(http.StatusConflict, "job not found or already terminal")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runPredictionV2 is the synchronous prediction core, shared by the v2
|
||||
// endpoint and the async worker pool.
|
||||
func (h *Handler) runPredictionV2(req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error) {
|
||||
// Validate the request shape before checking dataset availability, so a
|
||||
// malformed request is a 400 regardless of startup state.
|
||||
lat := req.Launch.Latitude
|
||||
rawLng := req.Launch.Longitude
|
||||
alt := req.Launch.Altitude.Or(0)
|
||||
if lat < -90 || lat > 90 {
|
||||
return nil, apiError(http.StatusBadRequest, "launch.latitude must be in [-90, 90]")
|
||||
}
|
||||
if rawLng < -180 || rawLng >= 360 {
|
||||
return nil, apiError(http.StatusBadRequest, "launch.longitude must be in [-180, 360)")
|
||||
}
|
||||
lng := normalizeLng(rawLng)
|
||||
|
||||
field := h.mgr.Active()
|
||||
if field == nil {
|
||||
return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded, service is starting up")
|
||||
}
|
||||
|
||||
events := engine.NewEventSink()
|
||||
deps := engine.BuildDeps{Wind: field, Events: events, Terrain: h.terrain()}
|
||||
|
||||
prof, err := buildProfile(req, deps)
|
||||
if err != nil {
|
||||
return nil, apiError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
started := time.Now().UTC()
|
||||
results := prof.Run(float64(req.Launch.Time.Unix()), engine.State{Lat: lat, Lng: lng, Altitude: alt}, events)
|
||||
completed := time.Now().UTC()
|
||||
|
||||
resp := &apirest.PredictionV2Response{
|
||||
Stages: make([]apirest.StageResult, 0, len(results)),
|
||||
Events: eventsToAPI(events.Snapshot()),
|
||||
Dataset: apirest.DatasetInfo{Source: field.Source(), Epoch: field.Epoch()},
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
}
|
||||
for _, r := range results {
|
||||
resp.Stages = append(resp.Stages, stageResultToAPI(r))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// PerformPrediction implements GET /api/v1/prediction (Tawhiri-compatible).
|
||||
func (h *Handler) PerformPrediction(_ context.Context, params apirest.PerformPredictionParams) (*apirest.PredictionResponse, error) {
|
||||
field := h.mgr.Active()
|
||||
if field == nil {
|
||||
return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded, service is starting up")
|
||||
}
|
||||
|
||||
profileKind := "standard_profile"
|
||||
if p, ok := params.Profile.Get(); ok {
|
||||
profileKind = string(p)
|
||||
}
|
||||
ascentRate := params.AscentRate.Or(5)
|
||||
descentRate := params.DescentRate.Or(5)
|
||||
launchAlt := params.LaunchAltitude.Or(0)
|
||||
lng := normalizeLng(params.LaunchLongitude)
|
||||
launchTime := float64(params.LaunchDatetime.Unix())
|
||||
|
||||
events := engine.NewEventSink()
|
||||
var stageNames []string
|
||||
var prof engine.Profile
|
||||
switch profileKind {
|
||||
case "standard_profile":
|
||||
stageNames = []string{"ascent", "descent"}
|
||||
prof = standardProfile(field, h.terrain(), events, ascentRate, params.BurstAltitude.Or(28000), descentRate)
|
||||
case "float_profile":
|
||||
stopTime := params.LaunchDatetime.Add(24 * time.Hour)
|
||||
if v, ok := params.StopDatetime.Get(); ok {
|
||||
stopTime = v
|
||||
}
|
||||
stageNames = []string{"ascent", "float"}
|
||||
prof = floatProfile(field, events, ascentRate, params.FloatAltitude.Or(25000), stopTime)
|
||||
default:
|
||||
return nil, apiError(http.StatusBadRequest, "unknown profile: "+profileKind)
|
||||
}
|
||||
|
||||
started := time.Now().UTC()
|
||||
results := prof.Run(launchTime, engine.State{Lat: params.LaunchLatitude, Lng: lng, Altitude: launchAlt}, events)
|
||||
completed := time.Now().UTC()
|
||||
h.metrics.Prediction(profileKind, completed.Sub(started), nil)
|
||||
|
||||
resp := &apirest.PredictionResponse{
|
||||
Metadata: apirest.PredictionResponseMetadata{StartDatetime: started, CompleteDatetime: completed},
|
||||
}
|
||||
for i, r := range results {
|
||||
name := "ascent"
|
||||
if i < len(stageNames) {
|
||||
name = stageNames[i]
|
||||
}
|
||||
resp.Prediction = append(resp.Prediction, tawhiriItem(name, r))
|
||||
}
|
||||
resp.Request = apirest.NewOptPredictionResponseRequest(apirest.PredictionResponseRequest{
|
||||
Dataset: apirest.NewOptString(field.Epoch().Format("2006-01-02T15:04:05Z")),
|
||||
LaunchLatitude: apirest.NewOptFloat64(params.LaunchLatitude),
|
||||
LaunchLongitude: apirest.NewOptFloat64(params.LaunchLongitude),
|
||||
LaunchDatetime: apirest.NewOptString(params.LaunchDatetime.Format(time.RFC3339)),
|
||||
LaunchAltitude: params.LaunchAltitude,
|
||||
})
|
||||
if ev := events.Snapshot(); len(ev) > 0 {
|
||||
resp.Warnings = apirest.NewOptPredictionResponseWarnings(apirest.PredictionResponseWarnings{})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// standardProfile builds the Tawhiri ascent → descent chain.
|
||||
func standardProfile(field weather.WindField, elev engine.TerrainProvider, events *engine.EventSink, ascentRate, burst, descentRate float64) engine.Profile {
|
||||
wind := engine.WindTransport(field, events)
|
||||
descentTerm := []engine.Constraint{engine.Altitude{Op: engine.OpLessEqual, Limit: 0, On: engine.ActionStop}}
|
||||
if elev != nil {
|
||||
descentTerm = []engine.Constraint{engine.TerrainContact{Provider: elev, On: engine.ActionStop}}
|
||||
}
|
||||
return engine.Profile{
|
||||
Direction: engine.Forward,
|
||||
Stages: []*engine.Propagator{
|
||||
{
|
||||
Name: "ascent",
|
||||
Step: 60,
|
||||
Model: engine.Sum(engine.ConstantRate(ascentRate), wind),
|
||||
Constraints: []engine.Constraint{engine.Altitude{Op: engine.OpGreaterEqual, Limit: burst, On: engine.ActionStop}},
|
||||
},
|
||||
{
|
||||
Name: "descent",
|
||||
Step: 60,
|
||||
Model: engine.Sum(engine.ParachuteDescent(descentRate), wind),
|
||||
Constraints: descentTerm,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// floatProfile builds the Tawhiri ascent → float chain.
|
||||
func floatProfile(field weather.WindField, events *engine.EventSink, ascentRate, floatAlt float64, stopTime time.Time) engine.Profile {
|
||||
wind := engine.WindTransport(field, events)
|
||||
return engine.Profile{
|
||||
Direction: engine.Forward,
|
||||
Stages: []*engine.Propagator{
|
||||
{
|
||||
Name: "ascent",
|
||||
Step: 60,
|
||||
Model: engine.Sum(engine.ConstantRate(ascentRate), wind),
|
||||
Constraints: []engine.Constraint{engine.Altitude{Op: engine.OpGreaterEqual, Limit: floatAlt, On: engine.ActionStop}},
|
||||
},
|
||||
{
|
||||
Name: "float",
|
||||
Step: 60,
|
||||
Model: wind,
|
||||
Constraints: []engine.Constraint{engine.Time{Op: engine.OpGreater, Limit: float64(stopTime.Unix()), On: engine.ActionStop}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// tawhiriItem maps one engine stage result to a v1 prediction item.
|
||||
func tawhiriItem(name string, r engine.Result) apirest.PredictionResponsePredictionItem {
|
||||
stage := apirest.PredictionResponsePredictionItemStageAscent
|
||||
switch name {
|
||||
case "descent":
|
||||
stage = apirest.PredictionResponsePredictionItemStageDescent
|
||||
case "float":
|
||||
stage = apirest.PredictionResponsePredictionItemStageFloat
|
||||
}
|
||||
n := r.Path.Len()
|
||||
traj := make([]apirest.TawhiriPoint, 0, n)
|
||||
for i := range n {
|
||||
t, p := r.Path.At(i)
|
||||
traj = append(traj, apirest.TawhiriPoint{
|
||||
Datetime: time.Unix(int64(t), 0).UTC(),
|
||||
Latitude: p.Lat,
|
||||
Longitude: signedLng(p.Lng),
|
||||
Altitude: p.Altitude,
|
||||
})
|
||||
}
|
||||
return apirest.PredictionResponsePredictionItem{Stage: stage, Trajectory: traj}
|
||||
}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
// Package tawhiri implements the legacy Tawhiri-compatible HTTP endpoint
|
||||
// (GET /api/v1/prediction). The request/response shapes match the original
|
||||
// Cambridge University Spaceflight predictor for drop-in compatibility.
|
||||
//
|
||||
// Internally the handler builds an engine.Profile from query parameters
|
||||
// and dispatches it through the same engine path as the new v2 endpoint.
|
||||
package tawhiri
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"predictor-refactored/internal/datasets"
|
||||
"predictor-refactored/internal/elevation"
|
||||
"predictor-refactored/internal/engine"
|
||||
"predictor-refactored/internal/metrics"
|
||||
"predictor-refactored/internal/weather"
|
||||
api "predictor-refactored/pkg/rest"
|
||||
)
|
||||
|
||||
// Handler implements api.Handler (ogen-generated interface).
|
||||
type Handler struct {
|
||||
mgr *datasets.Manager
|
||||
elev *elevation.Dataset
|
||||
metrics metrics.Sink
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// New wires a Handler.
|
||||
func New(mgr *datasets.Manager, elev *elevation.Dataset, sink metrics.Sink, log *zap.Logger) *Handler {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
if sink == nil {
|
||||
sink = metrics.Noop()
|
||||
}
|
||||
return &Handler{mgr: mgr, elev: elev, metrics: sink, log: log}
|
||||
}
|
||||
|
||||
var _ api.Handler = (*Handler)(nil)
|
||||
|
||||
// PerformPrediction runs the Tawhiri-style prediction.
|
||||
func (h *Handler) PerformPrediction(_ context.Context, params api.PerformPredictionParams) (*api.PredictionResponse, error) {
|
||||
field := h.mgr.Active()
|
||||
if field == nil {
|
||||
return nil, newError(http.StatusServiceUnavailable, "no dataset loaded, service is starting up")
|
||||
}
|
||||
|
||||
profileKind := optString(params.Profile, "standard_profile")
|
||||
ascentRate := optFloat(params.AscentRate, 5.0)
|
||||
burstAltitude := optFloat(params.BurstAltitude, 28000.0)
|
||||
descentRate := optFloat(params.DescentRate, 5.0)
|
||||
launchAlt := optFloat(params.LaunchAltitude, 0.0)
|
||||
|
||||
lng := params.LaunchLongitude
|
||||
if lng < 0 {
|
||||
lng += 360
|
||||
}
|
||||
launchTime := float64(params.LaunchDatetime.Unix())
|
||||
|
||||
events := engine.NewEventSink()
|
||||
|
||||
var stageNames []string
|
||||
var prof engine.Profile
|
||||
switch profileKind {
|
||||
case "standard_profile":
|
||||
stageNames = []string{"ascent", "descent"}
|
||||
prof = standardProfile(field, h.elev, events, ascentRate, burstAltitude, descentRate)
|
||||
case "float_profile":
|
||||
floatAlt := optFloat(params.FloatAltitude, 25000.0)
|
||||
stopTime := params.LaunchDatetime.Add(24 * time.Hour)
|
||||
if v, ok := params.StopDatetime.Get(); ok {
|
||||
stopTime = v
|
||||
}
|
||||
stageNames = []string{"ascent", "float"}
|
||||
prof = floatProfile(field, events, ascentRate, floatAlt, stopTime)
|
||||
default:
|
||||
return nil, newError(http.StatusBadRequest, "unknown profile: "+profileKind)
|
||||
}
|
||||
|
||||
started := time.Now().UTC()
|
||||
results := prof.Run(launchTime, engine.State{Lat: params.LaunchLatitude, Lng: lng, Altitude: launchAlt}, events)
|
||||
completed := time.Now().UTC()
|
||||
h.metrics.Prediction(profileKind, completed.Sub(started), nil)
|
||||
|
||||
resp := &api.PredictionResponse{
|
||||
Metadata: api.PredictionResponseMetadata{
|
||||
StartDatetime: started,
|
||||
CompleteDatetime: completed,
|
||||
},
|
||||
}
|
||||
|
||||
for i, r := range results {
|
||||
stageName := "ascent"
|
||||
if i < len(stageNames) {
|
||||
stageName = stageNames[i]
|
||||
}
|
||||
resp.Prediction = append(resp.Prediction, buildPredictionItem(stageName, r))
|
||||
}
|
||||
|
||||
resp.Request = api.NewOptPredictionResponseRequest(api.PredictionResponseRequest{
|
||||
Dataset: api.NewOptString(field.Epoch().Format("2006-01-02T15:04:05Z")),
|
||||
LaunchLatitude: api.NewOptFloat64(params.LaunchLatitude),
|
||||
LaunchLongitude: api.NewOptFloat64(params.LaunchLongitude),
|
||||
LaunchDatetime: api.NewOptString(params.LaunchDatetime.Format(time.RFC3339)),
|
||||
LaunchAltitude: params.LaunchAltitude,
|
||||
})
|
||||
|
||||
if ev := events.Snapshot(); len(ev) > 0 {
|
||||
// Preserve the OpenAPI-defined Warnings shape (open object).
|
||||
resp.Warnings = api.NewOptPredictionResponseWarnings(api.PredictionResponseWarnings{})
|
||||
}
|
||||
|
||||
h.log.Info("prediction complete",
|
||||
zap.String("profile", profileKind),
|
||||
zap.Int("stages", len(results)),
|
||||
zap.Duration("elapsed", completed.Sub(started)))
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// standardProfile constructs the ascent → descent profile.
|
||||
func standardProfile(field weather.WindField, elev *elevation.Dataset, events *engine.EventSink, ascentRate, burstAltitude, descentRate float64) engine.Profile {
|
||||
wind := engine.WindTransport(field, events)
|
||||
descentTerm := []engine.Constraint{engine.Altitude{Op: engine.OpLessEqual, Limit: 0, On: engine.ActionStop}}
|
||||
if elev != nil {
|
||||
descentTerm = []engine.Constraint{engine.TerrainContact{Provider: elev, On: engine.ActionStop}}
|
||||
}
|
||||
return engine.Profile{
|
||||
Direction: engine.Forward,
|
||||
Stages: []*engine.Propagator{
|
||||
{
|
||||
Name: "ascent",
|
||||
Step: 60,
|
||||
Model: engine.Sum(engine.ConstantRate(ascentRate), wind),
|
||||
Constraints: []engine.Constraint{engine.Altitude{Op: engine.OpGreaterEqual, Limit: burstAltitude, On: engine.ActionStop}},
|
||||
},
|
||||
{
|
||||
Name: "descent",
|
||||
Step: 60,
|
||||
Model: engine.Sum(engine.ParachuteDescent(descentRate), wind),
|
||||
Constraints: descentTerm,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// floatProfile constructs the ascent → float profile.
|
||||
func floatProfile(field weather.WindField, events *engine.EventSink, ascentRate, floatAlt float64, stopTime time.Time) engine.Profile {
|
||||
wind := engine.WindTransport(field, events)
|
||||
return engine.Profile{
|
||||
Direction: engine.Forward,
|
||||
Stages: []*engine.Propagator{
|
||||
{
|
||||
Name: "ascent",
|
||||
Step: 60,
|
||||
Model: engine.Sum(engine.ConstantRate(ascentRate), wind),
|
||||
Constraints: []engine.Constraint{engine.Altitude{Op: engine.OpGreaterEqual, Limit: floatAlt, On: engine.ActionStop}},
|
||||
},
|
||||
{
|
||||
Name: "float",
|
||||
Step: 60,
|
||||
Model: wind,
|
||||
Constraints: []engine.Constraint{engine.Time{Op: engine.OpGreater, Limit: float64(stopTime.Unix()), On: engine.ActionStop}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildPredictionItem(stageName string, r engine.Result) api.PredictionResponsePredictionItem {
|
||||
var stageEnum api.PredictionResponsePredictionItemStage
|
||||
switch stageName {
|
||||
case "descent":
|
||||
stageEnum = api.PredictionResponsePredictionItemStageDescent
|
||||
case "float":
|
||||
stageEnum = api.PredictionResponsePredictionItemStageFloat
|
||||
default:
|
||||
stageEnum = api.PredictionResponsePredictionItemStageAscent
|
||||
}
|
||||
traj := make([]api.PredictionResponsePredictionItemTrajectoryItem, 0, len(r.Points))
|
||||
for _, pt := range r.Points {
|
||||
ptLng := pt.Lng
|
||||
if ptLng > 180 {
|
||||
ptLng -= 360
|
||||
}
|
||||
traj = append(traj, api.PredictionResponsePredictionItemTrajectoryItem{
|
||||
Datetime: time.Unix(int64(pt.Time), 0).UTC(),
|
||||
Latitude: pt.Lat,
|
||||
Longitude: ptLng,
|
||||
Altitude: pt.Altitude,
|
||||
})
|
||||
}
|
||||
return api.PredictionResponsePredictionItem{Stage: stageEnum, Trajectory: traj}
|
||||
}
|
||||
|
||||
// ReadinessCheck reports whether a dataset is currently loaded.
|
||||
func (h *Handler) ReadinessCheck(_ context.Context) (*api.ReadinessResponse, error) {
|
||||
resp := &api.ReadinessResponse{}
|
||||
if field := h.mgr.Active(); field != nil {
|
||||
resp.Status = api.ReadinessResponseStatusOk
|
||||
resp.DatasetTime = api.NewOptDateTime(field.Epoch())
|
||||
} else {
|
||||
resp.Status = api.ReadinessResponseStatusNotReady
|
||||
resp.ErrorMessage = api.NewOptString("no dataset loaded")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// NewError implements the ogen Handler interface for unhandled errors.
|
||||
func (h *Handler) NewError(_ context.Context, err error) *api.ErrorStatusCode {
|
||||
var statusErr *api.ErrorStatusCode
|
||||
if errors.As(err, &statusErr) {
|
||||
return statusErr
|
||||
}
|
||||
h.log.Error("unhandled error", zap.Error(err))
|
||||
return newError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
func newError(status int, description string) *api.ErrorStatusCode {
|
||||
return &api.ErrorStatusCode{
|
||||
StatusCode: status,
|
||||
Response: api.Error{
|
||||
Error: api.ErrorError{
|
||||
Type: http.StatusText(status),
|
||||
Description: description,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// optString returns the option's value if set, else fallback.
|
||||
func optString[T ~string](o interface {
|
||||
Get() (T, bool)
|
||||
}, fallback string) string {
|
||||
if v, ok := o.Get(); ok {
|
||||
return string(v)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// optFloat returns the option's float64 value if set, else fallback.
|
||||
func optFloat(o api.OptFloat64, fallback float64) float64 {
|
||||
if v, ok := o.Get(); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
// Package api wires together every HTTP-facing component of the service:
|
||||
//
|
||||
// - Tawhiri-compatible v1 endpoints generated from the OpenAPI spec (ogen);
|
||||
// - The new v2 prediction endpoint;
|
||||
// - Dataset and job admin endpoints under /api/v1/admin/;
|
||||
// - Optional Prometheus-format metrics endpoint.
|
||||
// Package api is the HTTP surface of the service. Every REST operation is
|
||||
// defined in the OpenAPI spec (api/rest/predictor.swagger.yml) and served by
|
||||
// the ogen-generated server in pkg/rest; this package implements the
|
||||
// generated Handler interface and wires the server together with the
|
||||
// non-OpenAPI endpoints (Prometheus metrics, ReDoc docs).
|
||||
package api
|
||||
|
||||
import (
|
||||
|
|
@ -14,22 +13,22 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"predictor-refactored/internal/api/admin"
|
||||
"predictor-refactored/internal/api/async"
|
||||
"predictor-refactored/internal/api/docs"
|
||||
"predictor-refactored/internal/api/middleware"
|
||||
"predictor-refactored/internal/api/tawhiri"
|
||||
v2 "predictor-refactored/internal/api/v2"
|
||||
"predictor-refactored/internal/datasets"
|
||||
"predictor-refactored/internal/elevation"
|
||||
"predictor-refactored/internal/metrics"
|
||||
"predictor-refactored/internal/windviz"
|
||||
apirest "predictor-refactored/pkg/rest"
|
||||
)
|
||||
|
||||
// Server is the top-level HTTP server.
|
||||
type Server struct {
|
||||
port int
|
||||
mux *http.ServeMux
|
||||
log *zap.Logger
|
||||
port int
|
||||
mux *http.ServeMux
|
||||
async *async.Manager
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// Deps are the runtime dependencies the API layer needs.
|
||||
|
|
@ -39,8 +38,14 @@ type Deps struct {
|
|||
Metrics metrics.Sink
|
||||
MetricsHandler http.Handler // optional; mounted at MetricsPath when non-nil
|
||||
MetricsPath string
|
||||
AsyncManager *async.Manager // optional; mounts /api/v1/predictions when non-nil
|
||||
Log *zap.Logger
|
||||
EnableWind bool
|
||||
WindCache *windviz.Cache // optional; created if nil and EnableWind
|
||||
|
||||
AsyncWorkers int
|
||||
AsyncQueueSize int
|
||||
AsyncResultTTL time.Duration
|
||||
|
||||
Log *zap.Logger
|
||||
}
|
||||
|
||||
// New wires the HTTP server. The returned Server is not yet started.
|
||||
|
|
@ -51,53 +56,55 @@ func New(port int, d Deps) (*Server, error) {
|
|||
if d.Metrics == nil {
|
||||
d.Metrics = metrics.Noop()
|
||||
}
|
||||
if d.EnableWind && d.WindCache == nil {
|
||||
d.WindCache = windviz.NewCache(64, 10*time.Minute)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h := &Handler{
|
||||
mgr: d.Manager,
|
||||
elev: d.Elevation,
|
||||
metrics: d.Metrics,
|
||||
cache: d.WindCache,
|
||||
started: time.Now().UTC(),
|
||||
log: d.Log,
|
||||
}
|
||||
// The async worker pool runs the same prediction core as the synchronous
|
||||
// endpoint; inject it so async stays decoupled from the wire types.
|
||||
h.async = async.New(async.Config{
|
||||
Workers: d.AsyncWorkers,
|
||||
QueueSize: d.AsyncQueueSize,
|
||||
ResultTTL: d.AsyncResultTTL,
|
||||
}, h.runPredictionV2, d.Metrics, d.Log)
|
||||
|
||||
// ogen-generated server handles the Tawhiri-compat surface
|
||||
// (GET /api/v1/prediction and GET /ready).
|
||||
tw := tawhiri.New(d.Manager, d.Elevation, d.Metrics, d.Log)
|
||||
ogenSrv, err := apirest.NewServer(tw, apirest.WithMiddleware(middleware.OgenLogging(d.Log)))
|
||||
ogenSrv, err := apirest.NewServer(h, apirest.WithMiddleware(middleware.OgenLogging(d.Log)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ogen server: %w", err)
|
||||
}
|
||||
|
||||
// New primary prediction endpoint.
|
||||
v2h := v2.New(d.Manager, d.Elevation, d.Metrics, d.Log)
|
||||
mux.Handle("/api/v2/prediction", v2h)
|
||||
|
||||
// Admin endpoints.
|
||||
adminH := admin.New(d.Manager, d.Log)
|
||||
adminH.Register(mux)
|
||||
|
||||
// Async prediction endpoints (optional).
|
||||
if d.AsyncManager != nil {
|
||||
asyncH := async.NewHandler(d.AsyncManager)
|
||||
asyncH.Register(mux)
|
||||
}
|
||||
|
||||
// Metrics endpoint.
|
||||
mux := http.NewServeMux()
|
||||
// Liveness: always 200 while the process is up, independent of whether a
|
||||
// dataset is loaded. Container/orchestrator health checks use this; the
|
||||
// readiness of the data plane is /ready (an OpenAPI operation).
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"alive"}`))
|
||||
})
|
||||
docs.New().Register(mux)
|
||||
if d.MetricsHandler != nil && d.MetricsPath != "" {
|
||||
mux.Handle(d.MetricsPath, d.MetricsHandler)
|
||||
}
|
||||
|
||||
// Fallback to the ogen-generated routes (v1 + ready) for anything else.
|
||||
// The ogen server owns every OpenAPI route; mount it last as the catch-all.
|
||||
mux.Handle("/", ogenSrv)
|
||||
|
||||
return &Server{
|
||||
port: port,
|
||||
mux: mux,
|
||||
log: d.Log,
|
||||
}, nil
|
||||
return &Server{port: port, mux: mux, async: h.async, log: d.Log}, nil
|
||||
}
|
||||
|
||||
// Run starts the HTTP server and blocks until it returns.
|
||||
//
|
||||
// The handler chain is: CORS → request logger → mux.
|
||||
// Run starts the HTTP server and blocks until ctx is cancelled or the server
|
||||
// fails. The handler chain is CORS → mux (ogen routes + docs + metrics).
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.port),
|
||||
Handler: middleware.CORS(middleware.HTTPLogging(s.log, s.mux)),
|
||||
Handler: middleware.CORS(s.mux),
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
|
|
@ -115,3 +122,10 @@ func (s *Server) Run(ctx context.Context) error {
|
|||
return srv.Shutdown(shutdownCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// Close releases background resources (the async worker pool).
|
||||
func (s *Server) Close() {
|
||||
if s.async != nil {
|
||||
s.async.Close()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"predictor-refactored/internal/api/httpjson"
|
||||
"predictor-refactored/internal/datasets"
|
||||
"predictor-refactored/internal/elevation"
|
||||
"predictor-refactored/internal/engine"
|
||||
"predictor-refactored/internal/metrics"
|
||||
)
|
||||
|
||||
// Handler serves POST /api/v2/prediction.
|
||||
type Handler struct {
|
||||
mgr *datasets.Manager
|
||||
elev *elevation.Dataset
|
||||
metrics metrics.Sink
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// New wires a v2 Handler.
|
||||
func New(mgr *datasets.Manager, elev *elevation.Dataset, sink metrics.Sink, log *zap.Logger) *Handler {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
if sink == nil {
|
||||
sink = metrics.Noop()
|
||||
}
|
||||
return &Handler{mgr: mgr, elev: elev, metrics: sink, log: log}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "use POST")
|
||||
return
|
||||
}
|
||||
|
||||
var req PredictionRequest
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateRequest(req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := Run(h.mgr, h.elev, req)
|
||||
if err != nil {
|
||||
if perr, ok := err.(*PredictionError); ok {
|
||||
writeError(w, perr.Status, perr.Description)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.metrics.Prediction("v2", resp.CompletedAt.Sub(resp.StartedAt), nil)
|
||||
h.log.Info("v2 prediction complete",
|
||||
zap.Int("stages", len(resp.Stages)),
|
||||
zap.Duration("elapsed", resp.CompletedAt.Sub(resp.StartedAt)))
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// PredictionError carries an HTTP status alongside the message so async
|
||||
// callers can map the failure back to a useful HTTP response.
|
||||
type PredictionError struct {
|
||||
Status int
|
||||
Description string
|
||||
}
|
||||
|
||||
func (e *PredictionError) Error() string { return e.Description }
|
||||
|
||||
// Run executes a PredictionRequest against the manager's active wind field.
|
||||
// Shared between the sync /api/v2/prediction handler and the async
|
||||
// /api/v1/predictions worker.
|
||||
func Run(mgr *datasets.Manager, elev *elevation.Dataset, req PredictionRequest) (*PredictionResponse, error) {
|
||||
field := mgr.Active()
|
||||
if field == nil {
|
||||
return nil, &PredictionError{Status: http.StatusServiceUnavailable, Description: "no dataset loaded, service is starting up"}
|
||||
}
|
||||
|
||||
lng := req.Launch.Longitude
|
||||
if lng < 0 {
|
||||
lng += 360
|
||||
}
|
||||
|
||||
events := engine.NewEventSink()
|
||||
deps := engine.BuildDeps{Wind: field, Events: events}
|
||||
if elev != nil {
|
||||
deps.Terrain = elev
|
||||
}
|
||||
|
||||
prof, err := buildProfile(req, deps)
|
||||
if err != nil {
|
||||
return nil, &PredictionError{Status: http.StatusBadRequest, Description: err.Error()}
|
||||
}
|
||||
|
||||
started := time.Now().UTC()
|
||||
results := prof.Run(float64(req.Launch.Time.Unix()), engine.State{
|
||||
Lat: req.Launch.Latitude, Lng: lng, Altitude: req.Launch.Altitude,
|
||||
}, events)
|
||||
completed := time.Now().UTC()
|
||||
|
||||
resp := &PredictionResponse{
|
||||
Stages: make([]StageResult, 0, len(results)),
|
||||
Events: events.Snapshot(),
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
Dataset: DatasetInfo{Source: field.Source(), Epoch: field.Epoch()},
|
||||
}
|
||||
for _, r := range results {
|
||||
resp.Stages = append(resp.Stages, toStageResult(r))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func toStageResult(r engine.Result) StageResult {
|
||||
stage := StageResult{
|
||||
Name: r.Propagator,
|
||||
Outcome: r.Outcome.String(),
|
||||
Events: r.Events,
|
||||
}
|
||||
if r.Constraint != nil {
|
||||
stage.Constraint = r.ConstraintName
|
||||
stage.Termination = &TerminationInfo{
|
||||
ViolationTime: time.Unix(int64(r.ViolationTime), 0).UTC(),
|
||||
ViolationState: r.ViolationState,
|
||||
RefinedTime: time.Unix(int64(r.RefinedTime), 0).UTC(),
|
||||
RefinedState: r.RefinedState,
|
||||
}
|
||||
}
|
||||
stage.Trajectory = make([]TrajectoryPoint, len(r.Points))
|
||||
for i, pt := range r.Points {
|
||||
ptLng := pt.Lng
|
||||
if ptLng > 180 {
|
||||
ptLng -= 360
|
||||
}
|
||||
stage.Trajectory[i] = TrajectoryPoint{
|
||||
Time: time.Unix(int64(pt.Time), 0).UTC(),
|
||||
Latitude: pt.Lat,
|
||||
Longitude: ptLng,
|
||||
Altitude: pt.Altitude,
|
||||
}
|
||||
}
|
||||
return stage
|
||||
}
|
||||
|
||||
func validateRequest(req PredictionRequest) error {
|
||||
if req.Launch.Latitude < -90 || req.Launch.Latitude > 90 {
|
||||
return fmt.Errorf("launch.latitude must be in [-90, 90]")
|
||||
}
|
||||
if req.Launch.Longitude < -180 || req.Launch.Longitude >= 360 {
|
||||
return fmt.Errorf("launch.longitude must be in [-180, 360)")
|
||||
}
|
||||
if len(req.Profile) == 0 {
|
||||
return fmt.Errorf("profile must contain at least one stage")
|
||||
}
|
||||
for i, s := range req.Profile {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("profile[%d].name is required", i)
|
||||
}
|
||||
if s.Model.Type == "" {
|
||||
return fmt.Errorf("profile[%d].model.type is required", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var writeJSON = httpjson.Write
|
||||
var writeError = httpjson.Error
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"predictor-refactored/internal/engine"
|
||||
)
|
||||
|
||||
// buildProfile translates a PredictionRequest into an engine.Profile via
|
||||
// the engine registry.
|
||||
func buildProfile(req PredictionRequest, deps engine.BuildDeps) (engine.Profile, error) {
|
||||
if len(req.Profile) == 0 {
|
||||
return engine.Profile{}, fmt.Errorf("profile must contain at least one stage")
|
||||
}
|
||||
|
||||
step := req.Options.StepSeconds
|
||||
if step == 0 {
|
||||
step = 60
|
||||
}
|
||||
tol := req.Options.Tolerance
|
||||
if tol == 0 {
|
||||
tol = 0.01
|
||||
}
|
||||
|
||||
dir := engine.Forward
|
||||
switch req.Direction {
|
||||
case "", "forward":
|
||||
dir = engine.Forward
|
||||
case "reverse":
|
||||
dir = engine.Reverse
|
||||
default:
|
||||
return engine.Profile{}, fmt.Errorf("unknown direction %q", req.Direction)
|
||||
}
|
||||
|
||||
props := make([]*engine.Propagator, len(req.Profile))
|
||||
for i, stage := range req.Profile {
|
||||
if stage.Name == "" {
|
||||
return engine.Profile{}, fmt.Errorf("stage %d: name is required", i)
|
||||
}
|
||||
built, err := engine.BuildModel(stage.Model, deps)
|
||||
if err != nil {
|
||||
return engine.Profile{}, fmt.Errorf("stage %q model: %w", stage.Name, err)
|
||||
}
|
||||
constraints, err := buildConstraintList(stage.Constraints, deps)
|
||||
if err != nil {
|
||||
return engine.Profile{}, fmt.Errorf("stage %q: %w", stage.Name, err)
|
||||
}
|
||||
props[i] = &engine.Propagator{
|
||||
Name: stage.Name,
|
||||
Step: step,
|
||||
Model: built.Model,
|
||||
BuildModel: built.Build,
|
||||
Constraints: constraints,
|
||||
Tolerance: tol,
|
||||
}
|
||||
}
|
||||
|
||||
for i, stage := range req.Profile {
|
||||
if stage.FallbackIndex == nil {
|
||||
continue
|
||||
}
|
||||
idx := *stage.FallbackIndex
|
||||
if idx < 0 || idx >= len(props) {
|
||||
return engine.Profile{}, fmt.Errorf("stage %q: fallback_index %d out of range", stage.Name, idx)
|
||||
}
|
||||
props[i].Fallback = props[idx]
|
||||
}
|
||||
|
||||
globals, err := buildConstraintList(req.Globals, deps)
|
||||
if err != nil {
|
||||
return engine.Profile{}, fmt.Errorf("globals: %w", err)
|
||||
}
|
||||
|
||||
return engine.Profile{Stages: props, Direction: dir, Globals: globals}, nil
|
||||
}
|
||||
|
||||
func buildConstraintList(specs []engine.ConstraintSpec, deps engine.BuildDeps) ([]engine.Constraint, error) {
|
||||
out := make([]engine.Constraint, 0, len(specs))
|
||||
for i, spec := range specs {
|
||||
c, err := engine.BuildConstraint(spec, deps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("constraint[%d]: %w", i, err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
// Package v2 implements the profile-driven prediction endpoint.
|
||||
//
|
||||
// Endpoint: POST /api/v2/prediction
|
||||
//
|
||||
// The request schema is built on the engine package's ConstraintSpec and
|
||||
// ModelSpec, so adding new model or constraint types in the engine requires
|
||||
// no changes here — they become available automatically via the registry.
|
||||
package v2
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"predictor-refactored/internal/engine"
|
||||
)
|
||||
|
||||
// PredictionRequest is the body of POST /api/v2/prediction.
|
||||
type PredictionRequest struct {
|
||||
Launch Launch `json:"launch"`
|
||||
Profile []StageSpec `json:"profile"`
|
||||
Globals []engine.ConstraintSpec `json:"globals,omitempty"`
|
||||
Options Options `json:"options,omitempty"`
|
||||
Direction string `json:"direction,omitempty"` // "forward" (default) or "reverse"
|
||||
}
|
||||
|
||||
// Launch is the initial state of the balloon (or, for reverse predictions,
|
||||
// the known landing point).
|
||||
type Launch struct {
|
||||
Time time.Time `json:"time"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Altitude float64 `json:"altitude"`
|
||||
}
|
||||
|
||||
// StageSpec is one entry in the propagator chain.
|
||||
type StageSpec struct {
|
||||
Name string `json:"name"`
|
||||
Model engine.ModelSpec `json:"model"`
|
||||
Constraints []engine.ConstraintSpec `json:"constraints,omitempty"`
|
||||
// FallbackIndex, when set, points to another stage in the same profile
|
||||
// to transfer to on ActionFallback constraints.
|
||||
FallbackIndex *int `json:"fallback_index,omitempty"`
|
||||
}
|
||||
|
||||
// Options tweaks integrator behaviour.
|
||||
type Options struct {
|
||||
StepSeconds float64 `json:"step_seconds,omitempty"`
|
||||
Tolerance float64 `json:"tolerance,omitempty"`
|
||||
}
|
||||
|
||||
// PredictionResponse is the body of a successful POST response.
|
||||
type PredictionResponse struct {
|
||||
Stages []StageResult `json:"stages"`
|
||||
Events []engine.EventSummary `json:"events,omitempty"`
|
||||
Dataset DatasetInfo `json:"dataset"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
// StageResult is the outcome of one stage.
|
||||
type StageResult struct {
|
||||
Name string `json:"name"`
|
||||
Outcome string `json:"outcome"`
|
||||
Constraint string `json:"constraint,omitempty"`
|
||||
Termination *TerminationInfo `json:"termination,omitempty"`
|
||||
Events []engine.EventSummary `json:"events,omitempty"`
|
||||
Trajectory []TrajectoryPoint `json:"trajectory"`
|
||||
}
|
||||
|
||||
// TerminationInfo exposes the violation+refinement detail from the engine.
|
||||
type TerminationInfo struct {
|
||||
ViolationTime time.Time `json:"violation_time"`
|
||||
ViolationState engine.State `json:"violation_state"`
|
||||
RefinedTime time.Time `json:"refined_time"`
|
||||
RefinedState engine.State `json:"refined_state"`
|
||||
}
|
||||
|
||||
// TrajectoryPoint is one sampled point of the trajectory.
|
||||
type TrajectoryPoint struct {
|
||||
Time time.Time `json:"time"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Altitude float64 `json:"altitude"`
|
||||
}
|
||||
|
||||
// DatasetInfo identifies the wind dataset used.
|
||||
type DatasetInfo struct {
|
||||
Source string `json:"source"`
|
||||
Epoch time.Time `json:"epoch"`
|
||||
}
|
||||
|
||||
// ErrorResponse is the JSON error shape.
|
||||
type ErrorResponse struct {
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
// ErrorBody is the error detail.
|
||||
type ErrorBody struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
92
internal/api/wind.go
Normal file
92
internal/api/wind.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"predictor-refactored/internal/windviz"
|
||||
apirest "predictor-refactored/pkg/rest"
|
||||
)
|
||||
|
||||
// GetWindMeta implements GET /api/v1/wind/meta.
|
||||
func (h *Handler) GetWindMeta(_ context.Context) (*apirest.WindMeta, error) {
|
||||
field := h.mgr.Active()
|
||||
if field == nil {
|
||||
return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded")
|
||||
}
|
||||
return &apirest.WindMeta{
|
||||
Source: field.Source(),
|
||||
Epoch: field.Epoch().UTC(),
|
||||
DefaultStep: 1.0,
|
||||
MinStep: 0.25,
|
||||
SuggestedAltitudes: []int{0, 1000, 5000, 10000, 15000, 20000, 30000},
|
||||
Bbox: apirest.Region{MinLat: -90, MaxLat: 90, MinLng: 0, MaxLng: 360},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetWindField implements GET /api/v1/wind/field.
|
||||
func (h *Handler) GetWindField(_ context.Context, params apirest.GetWindFieldParams) ([]apirest.WindComponent, error) {
|
||||
field := h.mgr.Active()
|
||||
if field == nil {
|
||||
return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded")
|
||||
}
|
||||
|
||||
when := field.Epoch()
|
||||
if t, ok := params.Time.Get(); ok {
|
||||
when = t
|
||||
}
|
||||
req := windviz.Request{
|
||||
Time: float64(when.Unix()),
|
||||
Altitude: params.Altitude.Or(0),
|
||||
MinLat: params.MinLat.Or(0),
|
||||
MaxLat: params.MaxLat.Or(0),
|
||||
MinLng: params.MinLng.Or(0),
|
||||
MaxLng: params.MaxLng.Or(0),
|
||||
Step: params.Step.Or(0),
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s|%v|%.3f|%.3f|%.3f|%.3f|%.3f|%.3f",
|
||||
field.Source(), req.Time, req.Altitude, req.MinLat, req.MaxLat, req.MinLng, req.MaxLng, req.Step)
|
||||
if h.cache != nil {
|
||||
if cached, ok := h.cache.Get(key); ok {
|
||||
return windFieldToAPI(cached), nil
|
||||
}
|
||||
}
|
||||
|
||||
out, err := windviz.Rasterize(field, req)
|
||||
if err != nil {
|
||||
return nil, apiError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
if h.cache != nil {
|
||||
h.cache.Put(key, out)
|
||||
}
|
||||
return windFieldToAPI(out), nil
|
||||
}
|
||||
|
||||
// windFieldToAPI maps a rasterized field to the generated component slice.
|
||||
func windFieldToAPI(f windviz.Field) []apirest.WindComponent {
|
||||
out := make([]apirest.WindComponent, 0, len(f))
|
||||
for _, c := range f {
|
||||
out = append(out, apirest.WindComponent{
|
||||
Header: apirest.WindHeader{
|
||||
ParameterCategory: c.Header.ParameterCategory,
|
||||
ParameterNumber: c.Header.ParameterNumber,
|
||||
ParameterNumberName: apirest.NewOptString(c.Header.ParameterNumberName),
|
||||
ParameterUnit: apirest.NewOptString(c.Header.ParameterUnit),
|
||||
Nx: c.Header.Nx,
|
||||
Ny: c.Header.Ny,
|
||||
Lo1: c.Header.Lo1,
|
||||
La1: c.Header.La1,
|
||||
Lo2: c.Header.Lo2,
|
||||
La2: c.Header.La2,
|
||||
Dx: c.Header.Dx,
|
||||
Dy: c.Header.Dy,
|
||||
RefTime: c.Header.RefTime,
|
||||
ForecastTime: c.Header.ForecastTime,
|
||||
},
|
||||
Data: c.Data,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue