predictor/internal/api/v2/handler.go
2026-05-23 00:55:35 +09:00

177 lines
4.7 KiB
Go

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