package v2 import ( "encoding/json" "fmt" "net/http" "time" "go.uber.org/zap" "predictor-refactored/internal/api/httpjson" "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 } resp, err := Run(h.mgr, h.elev, req) if err != nil { if perr, ok := err.(*PredictionError); ok { writeError(w, perr.Status, perr.Description) return } writeError(w, http.StatusInternalServerError, err.Error()) return } h.metrics.Prediction("v2", resp.CompletedAt.Sub(resp.StartedAt), nil) h.log.Info("v2 prediction complete", zap.Int("stages", len(resp.Stages)), zap.Duration("elapsed", resp.CompletedAt.Sub(resp.StartedAt))) writeJSON(w, http.StatusOK, resp) } // PredictionError carries an HTTP status alongside the message so async // callers can map the failure back to a useful HTTP response. type PredictionError struct { Status int Description string } func (e *PredictionError) Error() string { return e.Description } // Run executes a PredictionRequest against the manager's active wind field. // Shared between the sync /api/v2/prediction handler and the async // /api/v1/predictions worker. func Run(mgr *datasets.Manager, elev *elevation.Dataset, req PredictionRequest) (*PredictionResponse, error) { field := mgr.Active() if field == nil { return nil, &PredictionError{Status: http.StatusServiceUnavailable, Description: "no dataset loaded, service is starting up"} } lng := req.Launch.Longitude if lng < 0 { lng += 360 } events := engine.NewEventSink() deps := engine.BuildDeps{Wind: field, Events: events} if elev != nil { deps.Terrain = elev } prof, err := buildProfile(req, deps) if err != nil { return nil, &PredictionError{Status: http.StatusBadRequest, Description: err.Error()} } started := time.Now().UTC() results := prof.Run(float64(req.Launch.Time.Unix()), engine.State{ Lat: req.Launch.Latitude, Lng: lng, Altitude: req.Launch.Altitude, }, events) completed := time.Now().UTC() resp := &PredictionResponse{ Stages: make([]StageResult, 0, len(results)), Events: events.Snapshot(), StartedAt: started, CompletedAt: completed, Dataset: DatasetInfo{Source: field.Source(), Epoch: field.Epoch()}, } for _, r := range results { resp.Stages = append(resp.Stages, toStageResult(r)) } return resp, nil } func toStageResult(r engine.Result) StageResult { stage := StageResult{ Name: r.Propagator, Outcome: r.Outcome.String(), Events: r.Events, } if r.Constraint != nil { stage.Constraint = r.ConstraintName stage.Termination = &TerminationInfo{ ViolationTime: time.Unix(int64(r.ViolationTime), 0).UTC(), ViolationState: r.ViolationState, RefinedTime: time.Unix(int64(r.RefinedTime), 0).UTC(), RefinedState: r.RefinedState, } } 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, } } return stage } 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 } var writeJSON = httpjson.Write var writeError = httpjson.Error