// 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 }