step one
This commit is contained in:
parent
7a8d5d13fa
commit
9e663db9dc
68 changed files with 5647 additions and 2958 deletions
173
internal/api/v2/handler.go
Normal file
173
internal/api/v2/handler.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
field := h.mgr.Active()
|
||||
if field == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "no dataset loaded, service is starting up")
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
prof, err := buildProfile(req, field, terrain, warnings)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
started := time.Now().UTC()
|
||||
results := prof.Run(float64(req.Launch.Time.Unix()), engine.State{
|
||||
Lat: req.Launch.Latitude,
|
||||
Lng: lng,
|
||||
Altitude: req.Launch.Altitude,
|
||||
})
|
||||
completed := time.Now().UTC()
|
||||
h.metrics.Prediction("v2", completed.Sub(started), nil)
|
||||
|
||||
resp := PredictionResponse{
|
||||
Stages: make([]StageResult, 0, len(results)),
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
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
|
||||
}
|
||||
|
||||
h.log.Info("v2 prediction complete",
|
||||
zap.Int("stages", len(results)),
|
||||
zap.Duration("elapsed", completed.Sub(started)))
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
145
internal/api/v2/profile.go
Normal file
145
internal/api/v2/profile.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package v2
|
||||
|
||||
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) {
|
||||
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 {
|
||||
model, err := buildModel(stage.Model, field, warnings)
|
||||
if err != nil {
|
||||
return engine.Profile{}, fmt.Errorf("stage %q: %w", stage.Name, err)
|
||||
}
|
||||
constraints, err := buildConstraints(stage.Constraints, elev)
|
||||
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,
|
||||
Constraints: constraints,
|
||||
Tolerance: tol,
|
||||
}
|
||||
}
|
||||
|
||||
// Wire fallbacks once all stages exist.
|
||||
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]
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func buildConstraints(specs []ConstraintSpec, elev engine.TerrainProvider) ([]engine.Constraint, error) {
|
||||
out := make([]engine.Constraint, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
action, err := parseAction(spec.Action)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
114
internal/api/v2/types.go
Normal file
114
internal/api/v2/types.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// 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.
|
||||
//
|
||||
// Endpoint: POST /api/v2/prediction
|
||||
package v2
|
||||
|
||||
import "time"
|
||||
|
||||
// PredictionRequest is the request body for 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 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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
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.
|
||||
type Options struct {
|
||||
StepSeconds float64 `json:"step_seconds,omitempty"`
|
||||
Tolerance float64 `json:"tolerance,omitempty"`
|
||||
}
|
||||
|
||||
// PredictionResponse is the response body for POST /api/v2/prediction.
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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 dataset the prediction was computed against.
|
||||
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.
|
||||
type ErrorResponse struct {
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
// ErrorBody is the error detail.
|
||||
type ErrorBody struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue