engine refactor

This commit is contained in:
Anatoly Antonov 2026-05-23 00:55:35 +09:00
parent 9e663db9dc
commit 81b8e763bd
37 changed files with 3532 additions and 1639 deletions

View file

@ -3,29 +3,33 @@
//
// Endpoints:
//
// GET /api/v1/admin/datasets list stored epochs
// POST /api/v1/admin/datasets trigger a download
// DELETE /api/v1/admin/datasets/{epoch} delete a stored epoch
// 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/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
log *zap.Logger
mgr *datasets.Manager
start time.Time
log *zap.Logger
}
// New wires an admin handler.
@ -33,52 +37,94 @@ func New(mgr *datasets.Manager, log *zap.Logger) *Handler {
if log == nil {
log = zap.NewNop()
}
return &Handler{mgr: mgr, log: log}
return &Handler{mgr: mgr, start: time.Now().UTC(), log: log}
}
// Register installs admin routes on mux. Routes are mounted under
// /api/v1/admin/...
// 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/{epoch}", h.deleteDataset)
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) {
epochs, err := h.mgr.ListEpochs()
stored, err := h.mgr.ListEpochs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
active := ""
if a := h.mgr.Active(); a != nil {
active = a.Epoch().UTC().Format(time.RFC3339)
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"`
Active string `json:"active,omitempty"`
Epochs []string `json:"epochs"`
}{
Source: h.mgr.Source(),
Active: active,
}
for _, e := range epochs {
out.Epochs = append(out.Epochs, e.UTC().Format(time.RFC3339))
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: {"epoch": "2026-03-28T06:00:00Z"} OR {"latest": true}.
// 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"`
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())
@ -89,7 +135,6 @@ func (h *Handler) triggerDownload(w http.ResponseWriter, r *http.Request) {
return
}
var epoch time.Time
if body.Latest {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
@ -102,29 +147,40 @@ func (h *Handler) triggerDownload(w http.ResponseWriter, r *http.Request) {
return
}
var err error
epoch, err = time.Parse(time.RFC3339, body.Epoch)
epoch, err := time.Parse(time.RFC3339, body.Epoch)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid epoch: "+err.Error())
return
}
jobID := h.mgr.Download(epoch)
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/{epoch}.
// 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) {
rawEpoch := r.PathValue("epoch")
epoch, err := time.Parse(time.RFC3339, rawEpoch)
name := r.PathValue("name")
stored, err := h.mgr.ListEpochs()
if err != nil {
writeError(w, http.StatusBadRequest, "invalid epoch: "+err.Error())
return
}
if err := h.mgr.RemoveEpoch(epoch); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
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.
@ -158,24 +214,59 @@ func (h *Handler) cancelJob(w http.ResponseWriter, r *http.Request) {
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"`
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"`
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,
Epoch: j.Epoch.UTC().Format(time.RFC3339),
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,
@ -189,18 +280,5 @@ func toDTO(j datasets.JobInfo) jobDTO {
return dto
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, description string) {
writeJSON(w, status, map[string]any{
"error": map[string]string{
"type": http.StatusText(status),
"description": description,
},
})
}
var writeJSON = httpjson.Write
var writeError = httpjson.Error

View file

@ -0,0 +1,63 @@
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

View file

@ -0,0 +1,276 @@
// Package async implements the asynchronous prediction endpoints
// (/api/v1/predictions{,/{id}}) and the worker pool that executes them.
//
// Each enqueued request is assigned a job ID; the result is held in
// memory for a configurable TTL after completion.
package async
import (
"sync"
"sync/atomic"
"time"
"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"
)
// Status is the lifecycle state of a prediction job.
type Status string
const (
StatusPending Status = "pending"
StatusRunning Status = "running"
StatusComplete Status = "complete"
StatusFailed Status = "failed"
StatusCancelled Status = "cancelled"
)
// JobInfo is the externally-visible 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"`
}
type job struct {
id string
req v2.PredictionRequest
createdAt time.Time
mu sync.Mutex
status Status
startedAt time.Time
completedAt time.Time
errStr string
result *v2.PredictionResponse
cancel chan struct{}
}
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,
}
if !j.startedAt.IsZero() {
t := j.startedAt
info.StartedAt = &t
}
if !j.completedAt.IsZero() {
t := j.completedAt
info.CompletedAt = &t
}
return info
}
// Manager runs a fixed pool of workers to execute prediction jobs and
// retains their results for the configured TTL.
type Manager struct {
mgr *datasets.Manager
elev *elevation.Dataset
metrics metrics.Sink
log *zap.Logger
queue chan *job
ttl time.Duration
jobsMu sync.RWMutex
jobs map[string]*job
inflight atomic.Int64
closed chan struct{}
wg sync.WaitGroup
}
// 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
}
// 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 {
if cfg.Workers <= 0 {
cfg.Workers = 4
}
if cfg.QueueSize <= 0 {
cfg.QueueSize = 64
}
if cfg.ResultTTL <= 0 {
cfg.ResultTTL = time.Hour
}
if sink == nil {
sink = metrics.Noop()
}
if log == nil {
log = zap.NewNop()
}
m := &Manager{
mgr: mgr, elev: elev, metrics: sink, log: log,
queue: make(chan *job, cfg.QueueSize),
jobs: make(map[string]*job),
ttl: cfg.ResultTTL,
closed: make(chan struct{}),
}
for range cfg.Workers {
m.wg.Add(1)
go m.worker()
}
m.wg.Add(1)
go m.evictor()
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) {
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
m.jobsMu.Unlock()
select {
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"
j.completedAt = time.Now().UTC()
j.mu.Unlock()
return j.snapshot(), false
}
}
// Get returns a job's snapshot.
func (m *Manager) Get(id string) (JobInfo, bool) {
m.jobsMu.RLock()
j, ok := m.jobs[id]
m.jobsMu.RUnlock()
if !ok {
return JobInfo{}, false
}
return j.snapshot(), true
}
// Cancel marks a not-yet-started job as cancelled. Returns false when the
// job is unknown or already terminal.
func (m *Manager) Cancel(id string) bool {
m.jobsMu.RLock()
j, ok := m.jobs[id]
m.jobsMu.RUnlock()
if !ok {
return false
}
j.mu.Lock()
terminal := j.status == StatusComplete || j.status == StatusFailed || j.status == StatusCancelled
if terminal {
j.mu.Unlock()
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.
func (m *Manager) Inflight() int64 { return m.inflight.Load() }
// Close shuts down workers and the evictor.
func (m *Manager) Close() {
close(m.closed)
close(m.queue)
m.wg.Wait()
}
func (m *Manager) worker() {
defer m.wg.Done()
for j := range m.queue {
// Check cancellation before starting.
j.mu.Lock()
cancelled := j.status == StatusCancelled
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)
}
}
}
func (m *Manager) evictor() {
defer m.wg.Done()
ticker := time.NewTicker(m.ttl / 4)
defer ticker.Stop()
for {
select {
case <-m.closed:
return
case <-ticker.C:
m.evictExpired()
}
}
}
func (m *Manager) evictExpired() {
now := time.Now().UTC()
m.jobsMu.Lock()
defer m.jobsMu.Unlock()
for id, j := range m.jobs {
j.mu.Lock()
expired := !j.completedAt.IsZero() && now.Sub(j.completedAt) > m.ttl
j.mu.Unlock()
if expired {
delete(m.jobs, id)
}
}
}

View file

@ -0,0 +1,27 @@
// 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,
},
})
}

View file

@ -2,8 +2,8 @@
// (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.
// 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 (
@ -18,11 +18,11 @@ import (
"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 (the ogen-generated interface for
// performPrediction and readinessCheck).
// Handler implements api.Handler (ogen-generated interface).
type Handler struct {
mgr *datasets.Manager
elev *elevation.Dataset
@ -41,111 +41,49 @@ func New(mgr *datasets.Manager, elev *elevation.Dataset, sink metrics.Sink, log
return &Handler{mgr: mgr, elev: elev, metrics: sink, log: log}
}
// Compile-time check that Handler satisfies api.Handler.
var _ api.Handler = (*Handler)(nil)
// PerformPrediction runs the Tawhiri-style prediction.
func (h *Handler) PerformPrediction(ctx context.Context, params api.PerformPredictionParams) (*api.PredictionResponse, error) {
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")
}
// Parameters with Tawhiri defaults.
profileKind := "standard_profile"
if v, ok := params.Profile.Get(); ok {
profileKind = string(v)
}
ascentRate := 5.0
if v, ok := params.AscentRate.Get(); ok {
ascentRate = v
}
burstAltitude := 28000.0
if v, ok := params.BurstAltitude.Get(); ok {
burstAltitude = v
}
descentRate := 5.0
if v, ok := params.DescentRate.Get(); ok {
descentRate = v
}
launchAlt := 0.0
if v, ok := params.LaunchAltitude.Get(); ok {
launchAlt = v
}
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())
warnings := &engine.Warnings{}
// Build the profile.
events := engine.NewEventSink()
var stageNames []string
var prof engine.Profile
switch profileKind {
case "standard_profile":
stageNames = []string{"ascent", "descent"}
prof = engine.Profile{
Direction: engine.Forward,
Stages: []*engine.Propagator{
{
Name: "ascent",
Step: 60,
Model: engine.Sum(
engine.ConstantRate(ascentRate),
engine.WindTransport(field, warnings),
),
Constraints: []engine.Constraint{engine.MaxAltitude{Limit: burstAltitude, On: engine.ActionStop}},
},
{
Name: "descent",
Step: 60,
Model: engine.Sum(
engine.ParachuteDescent(descentRate),
engine.WindTransport(field, warnings),
),
Constraints: descentConstraints(h.elev),
},
},
}
prof = standardProfile(field, h.elev, events, ascentRate, burstAltitude, descentRate)
case "float_profile":
floatAlt := 25000.0
if v, ok := params.FloatAltitude.Get(); ok {
floatAlt = v
}
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 = engine.Profile{
Direction: engine.Forward,
Stages: []*engine.Propagator{
{
Name: "ascent",
Step: 60,
Model: engine.Sum(
engine.ConstantRate(ascentRate),
engine.WindTransport(field, warnings),
),
Constraints: []engine.Constraint{engine.MaxAltitude{Limit: floatAlt, On: engine.ActionStop}},
},
{
Name: "float",
Step: 60,
Model: engine.WindTransport(field, warnings),
Constraints: []engine.Constraint{engine.MaxTime{Limit: float64(stopTime.Unix()), On: engine.ActionStop}},
},
},
}
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})
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)
@ -161,30 +99,7 @@ func (h *Handler) PerformPrediction(ctx context.Context, params api.PerformPredi
if i < len(stageNames) {
stageName = stageNames[i]
}
stageEnum := api.PredictionResponsePredictionItemStageAscent
switch stageName {
case "descent":
stageEnum = api.PredictionResponsePredictionItemStageDescent
case "float":
stageEnum = api.PredictionResponsePredictionItemStageFloat
}
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,
})
}
resp.Prediction = append(resp.Prediction, api.PredictionResponsePredictionItem{
Stage: stageEnum,
Trajectory: traj,
})
resp.Prediction = append(resp.Prediction, buildPredictionItem(stageName, r))
}
resp.Request = api.NewOptPredictionResponseRequest(api.PredictionResponseRequest{
@ -195,7 +110,8 @@ func (h *Handler) PerformPrediction(ctx context.Context, params api.PerformPredi
LaunchAltitude: params.LaunchAltitude,
})
if warns := warnings.ToMap(); len(warns) > 0 {
if ev := events.Snapshot(); len(ev) > 0 {
// Preserve the OpenAPI-defined Warnings shape (open object).
resp.Warnings = api.NewOptPredictionResponseWarnings(api.PredictionResponseWarnings{})
}
@ -207,13 +123,78 @@ func (h *Handler) PerformPrediction(ctx context.Context, params api.PerformPredi
return resp, nil
}
// descentConstraints returns the descent termination set: TerrainContact if an
// elevation dataset is loaded, MinAltitude(0) otherwise.
func descentConstraints(elev *elevation.Dataset) []engine.Constraint {
// 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 {
return []engine.Constraint{engine.TerrainContact{Provider: elev, On: engine.ActionStop}}
descentTerm = []engine.Constraint{engine.TerrainContact{Provider: elev, On: engine.ActionStop}}
}
return []engine.Constraint{engine.MinAltitude{Limit: 0, 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.
@ -250,3 +231,21 @@ func newError(status int, description string) *api.ErrorStatusCode {
},
}
}
// 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
}

View file

@ -15,6 +15,7 @@ import (
"go.uber.org/zap"
"predictor-refactored/internal/api/admin"
"predictor-refactored/internal/api/async"
"predictor-refactored/internal/api/middleware"
"predictor-refactored/internal/api/tawhiri"
v2 "predictor-refactored/internal/api/v2"
@ -33,12 +34,13 @@ type Server struct {
// Deps are the runtime dependencies the API layer needs.
type Deps struct {
Manager *datasets.Manager
Elevation *elevation.Dataset
Metrics metrics.Sink
Manager *datasets.Manager
Elevation *elevation.Dataset
Metrics metrics.Sink
MetricsHandler http.Handler // optional; mounted at MetricsPath when non-nil
MetricsPath string
Log *zap.Logger
AsyncManager *async.Manager // optional; mounts /api/v1/predictions when non-nil
Log *zap.Logger
}
// New wires the HTTP server. The returned Server is not yet started.
@ -68,6 +70,12 @@ func New(port int, d Deps) (*Server, error) {
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.
if d.MetricsHandler != nil && d.MetricsPath != "" {
mux.Handle(d.MetricsPath, d.MetricsHandler)

View file

@ -8,6 +8,7 @@ import (
"go.uber.org/zap"
"predictor-refactored/internal/api/httpjson"
"predictor-refactored/internal/datasets"
"predictor-refactored/internal/elevation"
"predictor-refactored/internal/engine"
@ -46,85 +47,109 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if err := validateRequest(req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
field := h.mgr.Active()
if field == nil {
writeError(w, http.StatusServiceUnavailable, "no dataset loaded, service is starting up")
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"}
}
// Normalize longitude to [0, 360) for internal use.
lng := req.Launch.Longitude
if lng < 0 {
lng += 360
}
warnings := &engine.Warnings{}
var terrain engine.TerrainProvider
if h.elev != nil {
terrain = h.elev
events := engine.NewEventSink()
deps := engine.BuildDeps{Wind: field, Events: events}
if elev != nil {
deps.Terrain = elev
}
prof, err := buildProfile(req, field, terrain, warnings)
prof, err := buildProfile(req, deps)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
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,
})
Lat: req.Launch.Latitude, Lng: lng, Altitude: req.Launch.Altitude,
}, events)
completed := time.Now().UTC()
h.metrics.Prediction("v2", completed.Sub(started), nil)
resp := PredictionResponse{
resp := &PredictionResponse{
Stages: make([]StageResult, 0, len(results)),
Events: events.Snapshot(),
StartedAt: started,
CompletedAt: completed,
Dataset: DatasetInfo{
Source: field.Source(),
Epoch: field.Epoch(),
},
Dataset: DatasetInfo{Source: field.Source(), Epoch: field.Epoch()},
}
for _, r := range results {
stage := StageResult{
Name: r.Propagator,
Outcome: outcomeString(r.Outcome),
}
if r.Constraint != nil {
stage.Constraint = r.Constraint.Name()
}
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,
}
}
resp.Stages = append(resp.Stages, stage)
}
if warns := warnings.ToMap(); len(warns) > 0 {
resp.Warnings = warns
resp.Stages = append(resp.Stages, toStageResult(r))
}
return resp, nil
}
h.log.Info("v2 prediction complete",
zap.Int("stages", len(results)),
zap.Duration("elapsed", completed.Sub(started)))
writeJSON(w, http.StatusOK, resp)
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 {
@ -148,26 +173,5 @@ func validateRequest(req PredictionRequest) error {
return nil
}
func outcomeString(o engine.Outcome) string {
switch o {
case engine.OutcomeStopped:
return "stopped"
case engine.OutcomeFallback:
return "fallback"
default:
return "continued"
}
}
func writeError(w http.ResponseWriter, status int, description string) {
writeJSON(w, status, ErrorResponse{Error: ErrorBody{
Type: http.StatusText(status),
Description: description,
}})
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
var writeJSON = httpjson.Write
var writeError = httpjson.Error

View file

@ -4,14 +4,11 @@ import (
"fmt"
"predictor-refactored/internal/engine"
"predictor-refactored/internal/weather"
)
// buildProfile translates a PredictionRequest into an engine.Profile.
//
// elev may be nil when no terrain dataset is loaded; TerrainContact constraints
// will return an error in that case.
func buildProfile(req PredictionRequest, field weather.WindField, elev engine.TerrainProvider, warnings *engine.Warnings) (engine.Profile, error) {
// 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")
}
@ -37,24 +34,27 @@ func buildProfile(req PredictionRequest, field weather.WindField, elev engine.Te
props := make([]*engine.Propagator, len(req.Profile))
for i, stage := range req.Profile {
model, err := buildModel(stage.Model, field, warnings)
if err != nil {
return engine.Profile{}, fmt.Errorf("stage %q: %w", stage.Name, err)
if stage.Name == "" {
return engine.Profile{}, fmt.Errorf("stage %d: name is required", i)
}
constraints, err := buildConstraints(stage.Constraints, elev)
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: model,
Model: built.Model,
BuildModel: built.Build,
Constraints: constraints,
Tolerance: tol,
}
}
// Wire fallbacks once all stages exist.
for i, stage := range req.Profile {
if stage.FallbackIndex == nil {
continue
@ -66,80 +66,22 @@ func buildProfile(req PredictionRequest, field weather.WindField, elev engine.Te
props[i].Fallback = props[idx]
}
return engine.Profile{Stages: props, Direction: dir}, nil
}
func buildModel(spec ModelSpec, field weather.WindField, warnings *engine.Warnings) (engine.Model, error) {
var base engine.Model
switch spec.Type {
case "constant_rate":
base = engine.ConstantRate(spec.Rate)
case "parachute_descent":
if spec.SeaLevelRate <= 0 {
return nil, fmt.Errorf("parachute_descent requires positive sea_level_rate")
}
base = engine.ParachuteDescent(spec.SeaLevelRate)
case "piecewise":
segs := make([]engine.RateSegment, len(spec.Segments))
for i, s := range spec.Segments {
segs[i] = engine.RateSegment{Until: s.Until, Rate: s.Rate}
}
base = engine.Piecewise(segs)
case "wind":
if field == nil {
return nil, fmt.Errorf("wind model requires a loaded dataset")
}
return engine.WindTransport(field, warnings), nil
default:
return nil, fmt.Errorf("unknown model type %q", spec.Type)
globals, err := buildConstraintList(req.Globals, deps)
if err != nil {
return engine.Profile{}, fmt.Errorf("globals: %w", err)
}
if spec.IncludeWind {
if field == nil {
return nil, fmt.Errorf("include_wind requires a loaded dataset")
}
return engine.Sum(base, engine.WindTransport(field, warnings)), nil
}
return base, nil
return engine.Profile{Stages: props, Direction: dir, Globals: globals}, nil
}
func buildConstraints(specs []ConstraintSpec, elev engine.TerrainProvider) ([]engine.Constraint, error) {
func buildConstraintList(specs []engine.ConstraintSpec, deps engine.BuildDeps) ([]engine.Constraint, error) {
out := make([]engine.Constraint, 0, len(specs))
for _, spec := range specs {
action, err := parseAction(spec.Action)
for i, spec := range specs {
c, err := engine.BuildConstraint(spec, deps)
if err != nil {
return nil, err
}
var c engine.Constraint
switch spec.Type {
case "max_altitude":
c = engine.MaxAltitude{Limit: spec.Limit, On: action}
case "min_altitude":
c = engine.MinAltitude{Limit: spec.Limit, On: action}
case "max_time":
c = engine.MaxTime{Limit: spec.Limit, On: action}
case "terrain_contact":
if elev == nil {
return nil, fmt.Errorf("terrain_contact requires an elevation dataset")
}
c = engine.TerrainContact{Provider: elev, On: action}
default:
return nil, fmt.Errorf("unknown constraint type %q", spec.Type)
return nil, fmt.Errorf("constraint[%d]: %w", i, err)
}
out = append(out, c)
}
return out, nil
}
func parseAction(s string) (engine.Action, error) {
switch s {
case "", "stop":
return engine.ActionStop, nil
case "fallback":
return engine.ActionFallback, nil
case "clip":
return engine.ActionClip, nil
default:
return 0, fmt.Errorf("unknown constraint action %q", s)
}
}

View file

@ -1,18 +1,25 @@
// Package v2 implements the new primary prediction endpoint, which accepts a
// user-defined profile (chain of propagators with optional constraints) and
// returns the resulting trajectory.
// 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"
import (
"time"
// PredictionRequest is the request body for POST /api/v2/prediction.
"predictor-refactored/internal/engine"
)
// PredictionRequest is the body of POST /api/v2/prediction.
type PredictionRequest struct {
Launch Launch `json:"launch"`
Profile []Stage `json:"profile"`
Options Options `json:"options,omitempty"`
Direction string `json:"direction,omitempty"` // "forward" (default) or "reverse"
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,
@ -24,68 +31,47 @@ type Launch struct {
Altitude float64 `json:"altitude"`
}
// Stage is one entry in the propagator chain.
type Stage struct {
Name string `json:"name"`
Model ModelSpec `json:"model"`
Constraints []ConstraintSpec `json:"constraints,omitempty"`
// FallbackIndex, when set, points to another stage in the same profile to
// transfer to on ActionFallback constraints. Optional.
// 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"`
}
// ModelSpec describes the per-stage propagation model.
type ModelSpec struct {
// Type selects the model: "constant_rate", "parachute_descent", "piecewise", "wind".
Type string `json:"type"`
// Rate (m/s) for constant_rate.
Rate float64 `json:"rate,omitempty"`
// SeaLevelRate (m/s, positive) for parachute_descent.
SeaLevelRate float64 `json:"sea_level_rate,omitempty"`
// Segments for piecewise.
Segments []PiecewiseSegment `json:"segments,omitempty"`
// IncludeWind sums a WindTransport model into the resulting derivative,
// allowing the same stage to model both vertical motion and wind drift.
IncludeWind bool `json:"include_wind"`
}
// PiecewiseSegment is one entry in a piecewise rate schedule.
type PiecewiseSegment struct {
Until float64 `json:"until"` // UNIX seconds; segment applies for t < Until
Rate float64 `json:"rate"` // m/s
}
// ConstraintSpec describes one constraint attached to a stage.
type ConstraintSpec struct {
// Type: "max_altitude", "min_altitude", "max_time", "terrain_contact".
Type string `json:"type"`
// Limit is interpreted per Type: metres for altitude, UNIX seconds for time.
Limit float64 `json:"limit,omitempty"`
// Action: "stop" (default), "fallback", "clip".
Action string `json:"action,omitempty"`
}
// Options tweaks the integrator behaviour.
// Options tweaks integrator behaviour.
type Options struct {
StepSeconds float64 `json:"step_seconds,omitempty"`
Tolerance float64 `json:"tolerance,omitempty"`
}
// PredictionResponse is the response body for POST /api/v2/prediction.
// PredictionResponse is the body of a successful POST response.
type PredictionResponse struct {
Stages []StageResult `json:"stages"`
Warnings map[string]any `json:"warnings,omitempty"`
Dataset DatasetInfo `json:"dataset"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
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"` // "stopped" | "fallback" | "continued"
Constraint string `json:"constraint,omitempty"`
Trajectory []TrajectoryPoint `json:"trajectory"`
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.
@ -96,13 +82,13 @@ type TrajectoryPoint struct {
Altitude float64 `json:"altitude"`
}
// DatasetInfo identifies the dataset the prediction was computed against.
// DatasetInfo identifies the wind dataset used.
type DatasetInfo struct {
Source string `json:"source"`
Epoch time.Time `json:"epoch"`
}
// ErrorResponse is the JSON error shape used by both v2 and admin endpoints.
// ErrorResponse is the JSON error shape.
type ErrorResponse struct {
Error ErrorBody `json:"error"`
}