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