package api import ( "fmt" "time" "predictor-refactored/internal/api/async" "predictor-refactored/internal/engine" apirest "predictor-refactored/pkg/rest" ) // normalizeLng folds a longitude into [0, 360) for internal use. func normalizeLng(lng float64) float64 { if lng < 0 { return lng + 360 } return lng } // signedLng converts an internal [0, 360) longitude back to [-180, 180). func signedLng(lng float64) float64 { if lng > 180 { return lng - 360 } return lng } // buildProfile translates an API prediction request into an engine profile // using the engine's model/constraint registry. // maxProfileStages bounds the propagator chain length to keep a single // request's work bounded. const maxProfileStages = 32 func buildProfile(req *apirest.PredictionV2Request, deps engine.BuildDeps) (engine.Profile, error) { if len(req.Profile) == 0 { return engine.Profile{}, fmt.Errorf("profile must contain at least one stage") } if len(req.Profile) > maxProfileStages { return engine.Profile{}, fmt.Errorf("profile has %d stages; maximum is %d", len(req.Profile), maxProfileStages) } step := 60.0 tol := 0.01 if o, ok := req.Options.Get(); ok { step = o.StepSeconds.Or(step) tol = o.Tolerance.Or(tol) } if step <= 0 || step > 3600 { return engine.Profile{}, fmt.Errorf("options.step_seconds must be in (0, 3600], got %g", step) } if tol <= 0 || tol >= 1 { return engine.Profile{}, fmt.Errorf("options.tolerance must be in (0, 1), got %g", tol) } dir := engine.Forward if req.Direction.Or(apirest.PredictionV2RequestDirectionForward) == apirest.PredictionV2RequestDirectionReverse { dir = engine.Reverse } props := make([]*engine.Propagator, len(req.Profile)) for i, stage := range req.Profile { if stage.Name == "" { return engine.Profile{}, fmt.Errorf("stage %d: name is required", i) } built, err := engine.BuildModel(toEngineModelSpec(stage.Model), deps) if err != nil { return engine.Profile{}, fmt.Errorf("stage %q model: %w", stage.Name, err) } constraints, err := toEngineConstraints(stage.Constraints, deps) if err != nil { return engine.Profile{}, fmt.Errorf("stage %q: %w", stage.Name, err) } props[i] = &engine.Propagator{ Name: stage.Name, Step: step, Model: built.Model, BuildModel: built.Build, Constraints: constraints, Tolerance: tol, } } for i, stage := range req.Profile { idx, ok := stage.FallbackIndex.Get() if !ok { continue } if idx < 0 || idx >= len(props) { return engine.Profile{}, fmt.Errorf("stage %q: fallback_index %d out of range", stage.Name, idx) } props[i].Fallback = props[idx] } globals, err := toEngineConstraints(req.Globals, deps) if err != nil { return engine.Profile{}, fmt.Errorf("globals: %w", err) } return engine.Profile{Stages: props, Direction: dir, Globals: globals}, nil } func toEngineModelSpec(m apirest.ModelSpec) engine.ModelSpec { out := engine.ModelSpec{ Type: string(m.Type), Rate: m.Rate.Or(0), SeaLevelRate: m.SeaLevelRate.Or(0), IncludeWind: m.IncludeWind.Or(false), } for _, s := range m.Segments { out.Segments = append(out.Segments, engine.PiecewiseSegmentSpec{ Until: s.Until, Rate: s.Rate, Reference: string(s.Reference.Or(apirest.PiecewiseSegmentReferenceAbsolute)), }) } return out } func toEngineConstraints(specs []apirest.ConstraintSpec, deps engine.BuildDeps) ([]engine.Constraint, error) { out := make([]engine.Constraint, 0, len(specs)) for i, s := range specs { c, err := engine.BuildConstraint(toEngineConstraintSpec(s), deps) if err != nil { return nil, fmt.Errorf("constraint[%d]: %w", i, err) } out = append(out, c) } return out, nil } func toEngineConstraintSpec(c apirest.ConstraintSpec) engine.ConstraintSpec { spec := engine.ConstraintSpec{ Type: string(c.Type), Op: string(c.Op.Or("")), Limit: c.Limit.Or(0), Action: string(c.Action.Or(apirest.ConstraintSpecActionStop)), Mode: string(c.Mode.Or("")), Label: c.Label.Or(""), } for _, v := range c.Vertices { spec.Vertices = append(spec.Vertices, engine.PolygonVertex{Lat: v.Lat, Lng: v.Lng}) } return spec } // stageResultToAPI maps one engine stage result to the API representation. func stageResultToAPI(r engine.Result) apirest.StageResult { out := apirest.StageResult{ Name: r.Propagator, Outcome: apirest.StageResultOutcome(r.Outcome.String()), Events: eventsToAPI(r.Events), } if r.Constraint != nil { out.Constraint = apirest.NewOptString(r.ConstraintName) out.Termination = apirest.NewOptTerminationInfo(apirest.TerminationInfo{ ViolationTime: time.Unix(int64(r.ViolationTime), 0).UTC(), ViolationState: geoStateToAPI(r.ViolationState), RefinedTime: time.Unix(int64(r.RefinedTime), 0).UTC(), RefinedState: geoStateToAPI(r.RefinedState), }) } n := r.Path.Len() out.Trajectory = make([]apirest.TrajectoryPoint, n) for i := range n { t, p := r.Path.At(i) out.Trajectory[i] = apirest.TrajectoryPoint{ Time: time.Unix(int64(t), 0).UTC(), Latitude: p.Lat, Longitude: signedLng(p.Lng), Altitude: p.Altitude, } } return out } func geoStateToAPI(s engine.State) apirest.GeoState { return apirest.GeoState{Lat: s.Lat, Lng: signedLng(s.Lng), Altitude: s.Altitude} } func eventsToAPI(in []engine.EventSummary) []apirest.EventSummary { if len(in) == 0 { return nil } out := make([]apirest.EventSummary, 0, len(in)) for _, e := range in { out = append(out, apirest.EventSummary{ Type: e.Type, Count: e.Count, FirstTime: apirest.NewOptFloat64(e.FirstTime), LastTime: apirest.NewOptFloat64(e.LastTime), FirstState: apirest.NewOptGeoState(geoStateToAPI(e.FirstState)), LastState: apirest.NewOptGeoState(geoStateToAPI(e.LastState)), Message: apirest.NewOptString(e.Message), }) } return out } // asyncJobToAPI maps an async job snapshot to the API PredictionJob. func asyncJobToAPI(info async.JobInfo) *apirest.PredictionJob { job := &apirest.PredictionJob{ ID: info.ID, Status: apirest.PredictionJobStatus(info.Status), CreatedAt: info.CreatedAt, } if info.StartedAt != nil { job.StartedAt = apirest.NewOptDateTime(*info.StartedAt) } if info.CompletedAt != nil { job.CompletedAt = apirest.NewOptDateTime(*info.CompletedAt) } if info.Error != "" { job.Error = apirest.NewOptString(info.Error) } if info.Result != nil { job.Result = apirest.NewOptPredictionV2Response(*info.Result) } return job }