147 lines
4.8 KiB
Go
147 lines
4.8 KiB
Go
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
|
|
}
|