package engine import "predictor-refactored/internal/numerics" // Propagator advances state under one Model, checking a set of Constraints // after every integration step. // // When a constraint fires, the propagator binary-search refines the // violation point and emits it as its final trajectory point. The Action of // the triggering constraint controls what the surrounding Profile does // next: stop the profile, transfer to Fallback, or clip and continue. // // The per-step numerics (RK4 stepping, crossing refinement) are delegated to // the numerics package; this type owns only the orchestration: constraint // evaluation, action dispatch, and trajectory assembly. type Propagator struct { // Name identifies the propagator in trajectory metadata. Optional. Name string // Step is the magnitude of the integration step in seconds (always positive). // The Profile flips its sign for Reverse direction. Step float64 // Model is the per-second derivative function used for integration. // One of Model or BuildModel must be non-nil. If both are set, BuildModel // takes precedence (it is invoked once per stage with a StageContext). Model Model BuildModel func(ctx StageContext) Model // Constraints are evaluated after each step. The first violation wins. Constraints []Constraint BuildConstraints func(ctx StageContext) []Constraint // Fallback is the propagator to switch to when a constraint with // ActionFallback fires. Optional. Fallback *Propagator // Tolerance is the binary-search refinement tolerance in parameter // space (default 0.01, matching Tawhiri). Tolerance float64 } // estimatedSteps is the initial Path capacity; a typical balloon stage is a // few hundred 60-second steps. const estimatedSteps = 256 // DefaultMaxSteps bounds the number of integration steps a single propagator // may take. It is a safety backstop, not a physical limit: a profile whose // constraints never fire (e.g. a stage with no effective terminator) would // otherwise integrate forever and exhaust memory. At the default 60-second // step this allows ~8 simulated years, far beyond any real flight, so it only // ever trips on a misconfigured profile. const DefaultMaxSteps = 1_000_000 // run integrates the model from (t0, s0) in direction dir, returning a Result. // globals are constraints injected by the Profile and checked alongside the // propagator's local Constraints. events receives non-fatal observations. func (p *Propagator) run(ctx StageContext, t0 float64, s0 State, globals []Constraint, events *EventSink) Result { dt := p.Step * float64(ctx.Direction) tol := p.Tolerance if tol == 0 { tol = 0.01 } model := p.Model if p.BuildModel != nil { model = p.BuildModel(ctx) } constraints := p.Constraints if p.BuildConstraints != nil { constraints = p.BuildConstraints(ctx) } field := numerics.Field(model) out := Result{Propagator: p.Name, Outcome: OutcomeContinued, Path: numerics.NewPath(estimatedSteps)} out.Path.Append(t0, s0) t, s := t0, s0 for range DefaultMaxSteps { s2 := numerics.RK4Step(t, s, dt, field) t2 := t + dt c, fired := firstFiring(constraints, globals, t2, s2) if !fired { t, s = t2, s2 out.Path.Append(t, s) continue } out.ViolationTime, out.ViolationState = t2, s2 t3, s3 := numerics.RefineCrossing(t, s, t2, s2, c.Violated, tol) out.Constraint, out.ConstraintName = c, c.Name() if c.Action() == ActionClip { s3 = clipToConstraint(c, s3) out.RefinedTime, out.RefinedState = t3, s3 out.Path.Append(t3, s3) t, s = t3, s3 continue } out.RefinedTime, out.RefinedState = t3, s3 out.Path.Append(t3, s3) if c.Action() == ActionFallback { out.Outcome = OutcomeFallback } else { out.Outcome = OutcomeStopped } out.Events = events.Snapshot() return out } // Step cap reached without any constraint firing — the profile has no // effective terminator for this stage. Stop safely rather than loop forever. events.Emit("max_steps", t, s, "integration step limit reached without a constraint firing; check the stage's terminator") out.Outcome = OutcomeContinued out.Events = events.Snapshot() return out } // firstFiring scans local then global constraints for the first one whose // Violated returns true at (t, s). func firstFiring(local, globals []Constraint, t float64, s State) (Constraint, bool) { for _, c := range local { if c.Violated(t, s) { return c, true } } for _, c := range globals { if c.Violated(t, s) { return c, true } } return nil, false } // clipToConstraint adjusts s so the given constraint is exactly satisfied. // Defined only for constraints with a well-defined coordinate boundary; // others fall through unchanged. func clipToConstraint(c Constraint, s State) State { if alt, ok := c.(Altitude); ok { s.Altitude = alt.Limit } return s }