251 lines
8 KiB
Go
251 lines
8 KiB
Go
// 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
|
|
}
|