173 lines
4.1 KiB
Go
173 lines
4.1 KiB
Go
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)
|
|
}
|