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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue