Fixed event handling to properly keep the state changes, plus a few other fixes
This commit is contained in:
1 parent
55884190fa
commit
c5768ad9c0
8 files changed
+1064
-83
No files matched your search
+35
-21
@@ -8,7 +8,8 @@ The library currently has:
|
||||
- ✅ Dormand-Prince 4(5) adaptive explicit RK method with dense output
|
||||
- ✅ Tsit5 explicit RK method with dense output
|
||||
- ✅ PI step size controller
|
||||
- ✅ Basic continuous callbacks with zero-crossing detection
|
||||
- ✅ Continuous callbacks with zero-crossing detection, whose effects can change
|
||||
the state, the parameters, or end the solution
|
||||
- ✅ Solution interpolation interface
|
||||
- ✅ Generic over array dimensions at compile-time
|
||||
- ✅ Support for ordinary and second-order ODE problems
|
||||
@@ -79,6 +80,18 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
|
||||
### Callbacks
|
||||
|
||||
- [x] **[Callback Effects That Can Change the State](features/42-callback-effects.md)** ✅ COMPLETED
|
||||
- An effect's write to the state was overwritten by the step it was located in,
|
||||
so a callback could stop a solution but not change one
|
||||
- `effect(&mut self)`, termination at the crossing, both sides of a jump
|
||||
recorded, the controller reset across one, and a defined order for two events
|
||||
in one step
|
||||
- Raised by `astrokit`, which needs a Modified Rodrigues Parameter shadow set
|
||||
swap mid-propagation
|
||||
- **Dependencies**: None
|
||||
- **Effort**: Medium
|
||||
- **Status**: All success criteria met; benches unchanged within run-to-run drift
|
||||
|
||||
- [ ] **[Discrete Callbacks](features/05-discrete-callbacks.md)**
|
||||
- Event detection based on conditions (not zero-crossings)
|
||||
- Useful for time-based events, iteration counts, etc.
|
||||
@@ -93,13 +106,13 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
|
||||
### Solution Interface
|
||||
|
||||
- [ ] **[Saveat Functionality](features/07-saveat.md)**
|
||||
- [ ] **[Saveat Functionality](features/07-saveat-functionality.md)**
|
||||
- Save solution at specific timepoints
|
||||
- Dense vs sparse saving strategies
|
||||
- **Dependencies**: None
|
||||
- **Effort**: Medium
|
||||
|
||||
- [ ] **[Solution Derivatives](features/08-solution-derivatives.md)**
|
||||
- [ ] **Solution Derivatives** (no plan written yet)
|
||||
- Access derivatives at any time point via interpolation
|
||||
- `solution.derivative(t)` interface
|
||||
- **Dependencies**: None
|
||||
@@ -111,7 +124,7 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
|
||||
### Algorithms
|
||||
|
||||
- [ ] **[DP8 (Dormand-Prince 8th order)](features/09-dp8-method.md)**
|
||||
- [ ] **DP8 (Dormand-Prince 8th order)** (no plan written yet)
|
||||
- 8th order explicit RK for very high accuracy
|
||||
- Complements Vern7 for algorithm selection
|
||||
- **Dependencies**: None
|
||||
@@ -123,7 +136,7 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
- **Dependencies**: Linear solver infrastructure, Nordsieck representation
|
||||
- **Effort**: Large
|
||||
|
||||
- [ ] **[Rodas4/Rodas5P](features/11-rodas-methods.md)**
|
||||
- [ ] **[Rodas4/Rodas5P](features/11-rodas4-rodas5p-methods.md)**
|
||||
- Higher-order Rosenbrock methods (4th/5th order)
|
||||
- Better accuracy for stiff problems
|
||||
- **Dependencies**: Rosenbrock23
|
||||
@@ -138,7 +151,7 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
- **Dependencies**: At least one stiff solver (Rosenbrock23 or FBDF)
|
||||
- **Effort**: Large
|
||||
|
||||
- [ ] **[Default Algorithm Selection](features/13-default-algorithm.md)**
|
||||
- [ ] **[Default Algorithm Selection](features/13-default-algorithm-selection.md)**
|
||||
- Smart defaults based on problem characteristics
|
||||
- `solve(problem)` without specifying algorithm
|
||||
- **Dependencies**: Auto-switching, multiple algorithms
|
||||
@@ -146,7 +159,7 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
|
||||
### Initialization
|
||||
|
||||
- [ ] **[Automatic Initial Step Size](features/14-initial-stepsize.md)**
|
||||
- [ ] **[Automatic Initial Step Size](features/14-automatic-initial-step-size.md)**
|
||||
- Algorithm to determine good initial dt
|
||||
- Based on local Lipschitz estimate
|
||||
- **Dependencies**: None
|
||||
@@ -154,19 +167,19 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
|
||||
### Callbacks
|
||||
|
||||
- [ ] **[PresetTimeCallback](features/15-preset-time-callback.md)**
|
||||
- [ ] **[PresetTimeCallback](features/15-presettimecallback.md)**
|
||||
- Trigger callbacks at specific predetermined times
|
||||
- Important for time-varying forcing functions
|
||||
- **Dependencies**: Discrete callbacks
|
||||
- **Effort**: Small
|
||||
|
||||
- [ ] **[TerminateSteadyState](features/16-terminate-steady-state.md)**
|
||||
- [ ] **[TerminateSteadyState](features/16-terminatesteadystate.md)**
|
||||
- Auto-detect when solution reaches steady state
|
||||
- Stop integration early
|
||||
- **Dependencies**: Discrete callbacks
|
||||
- **Effort**: Small
|
||||
|
||||
- [ ] **[SavingCallback](features/17-saving-callback.md)**
|
||||
- [ ] **[SavingCallback](features/17-savingcallback.md)**
|
||||
- Custom saving logic beyond default
|
||||
- For memory-efficient large-scale simulations
|
||||
- **Dependencies**: CallbackSet
|
||||
@@ -194,7 +207,7 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
|
||||
### Algorithms
|
||||
|
||||
- [ ] **[Low-Storage Runge-Kutta](features/20-low-storage-rk.md)**
|
||||
- [ ] **[Low-Storage Runge-Kutta](features/20-low-storage-runge-kutta.md)**
|
||||
- 2N, 3N, 4N storage variants
|
||||
- Critical for large-scale problems
|
||||
- **Dependencies**: None
|
||||
@@ -212,7 +225,7 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
- **Dependencies**: Second-order ODE infrastructure (already exists)
|
||||
- **Effort**: Medium
|
||||
|
||||
- [ ] **[Verner Methods Suite](features/23-verner-methods.md)**
|
||||
- [ ] **[Verner Methods Suite](features/23-verner-methods-suite.md)**
|
||||
- Vern6, Vern8, Vern9
|
||||
- Complete Verner family for different accuracy needs
|
||||
- **Dependencies**: Vern7
|
||||
@@ -258,19 +271,19 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
|
||||
### Advanced Callbacks
|
||||
|
||||
- [ ] **[VectorContinuousCallback](features/30-vector-continuous-callback.md)**
|
||||
- [ ] **[VectorContinuousCallback](features/30-vectorcontinuouscallback.md)**
|
||||
- Multiple simultaneous event detection
|
||||
- More efficient than multiple callbacks
|
||||
- **Dependencies**: CallbackSet
|
||||
- **Effort**: Medium
|
||||
|
||||
- [ ] **[PositiveDomain](features/31-positive-domain.md)**
|
||||
- [ ] **[PositiveDomain](features/31-positivedomain.md)**
|
||||
- Enforce positivity constraints
|
||||
- Important for physical systems
|
||||
- **Dependencies**: CallbackSet
|
||||
- **Effort**: Small
|
||||
|
||||
- [ ] **[ManifoldProjection](features/32-manifold-projection.md)**
|
||||
- [ ] **[ManifoldProjection](features/32-manifoldprojection.md)**
|
||||
- Project solution onto constraint manifolds
|
||||
- For constrained mechanical systems
|
||||
- **Dependencies**: CallbackSet
|
||||
@@ -278,14 +291,14 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- [ ] **[Nonlinear Solver Infrastructure](features/33-nonlinear-solver.md)**
|
||||
- [ ] **[Nonlinear Solver Infrastructure](features/33-nonlinear-solver-infrastructure.md)**
|
||||
- Newton's method
|
||||
- Quasi-Newton methods
|
||||
- Generic nonlinear solver interface
|
||||
- **Dependencies**: Linear solver infrastructure
|
||||
- **Effort**: Large
|
||||
|
||||
- [ ] **[Krylov Linear Solvers](features/34-krylov-solvers.md)**
|
||||
- [ ] **[Krylov Linear Solvers](features/34-krylov-linear-solvers.md)**
|
||||
- GMRES, BiCGStab
|
||||
- For large sparse systems
|
||||
- **Dependencies**: Linear solver infrastructure
|
||||
@@ -355,12 +368,12 @@ Each algorithm implementation should include:
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
Total Features: 41
|
||||
- Tier 1: 10 features (6/10 complete) ✅
|
||||
Total Features: 42
|
||||
- Tier 1: 11 features (7/11 complete) ✅
|
||||
- Tier 2: 11 features (0/11 complete)
|
||||
- Tier 3: 20 features (0/20 complete)
|
||||
|
||||
**Overall Progress: 14.6% (6/41 features complete)**
|
||||
**Overall Progress: 16.7% (7/42 features complete)**
|
||||
|
||||
### Completed Features
|
||||
1. ✅ BS3 (Bogacki-Shampine 3/2) - Tier 1 (2025-10-23)
|
||||
@@ -369,5 +382,6 @@ Total Features: 41
|
||||
4. ✅ Rosenbrock23 - Tier 1 (2025-10-24)
|
||||
5. ✅ Tolerances Trait - Tier 1 (2026-09-30)
|
||||
6. ✅ Runtime Integrator Selection - Tier 1 (2026-09-30)
|
||||
7. ✅ Callback Effects That Can Change the State - Tier 1 (2026-10-01)
|
||||
|
||||
Last updated: 2026-09-30
|
||||
Last updated: 2026-10-01
|
||||
@@ -0,0 +1,231 @@
|
||||
# Feature: Callback Effects That Can Change the State
|
||||
|
||||
**Status**: completed (2026-10-01)
|
||||
|
||||
## Overview
|
||||
|
||||
Continuous callbacks located their crossings correctly and then threw away what
|
||||
the effect did about them. In `Problem::solve`:
|
||||
|
||||
```rust
|
||||
callback.effect(&mut self.ode); // writes to ode.y
|
||||
}
|
||||
}
|
||||
self.ode.y = new_y; // and here it goes
|
||||
self.ode.t += curr_step;
|
||||
```
|
||||
|
||||
The effect ran on the pre-step state, and the two lines after the callback loop
|
||||
overwrote `ode.y` with the state the integrator had produced. Anything the effect
|
||||
wrote to the state was lost. `stop` worked only because it writes `t_end`
|
||||
instead.
|
||||
|
||||
Reproduced with `dy/dt = 1` from 0 to 10, `y0 = 0`, an event of `5 - y[0]` and an
|
||||
effect of `ode.y = Vector1::new(100.0)`: the solution ended at `y = 10`, exactly
|
||||
as it does with an empty effect, where it should end near 105.
|
||||
|
||||
So the machinery was in place for the easy half of event handling — detect a
|
||||
condition and stop — and could not do the other half: detect a condition, change
|
||||
the state, and carry on.
|
||||
|
||||
## Why This Feature Matters
|
||||
|
||||
A rotating rigid body parameterized by Modified Rodrigues Parameters has a
|
||||
singularity at a full turn. It is avoided by swapping the parameter vector for
|
||||
the shadow set `-sigma / |sigma|^2` — the same attitude, different parameters —
|
||||
whenever `|sigma|` exceeds 1. The event is `1 - |sigma|`, the effect is that
|
||||
substitution, and the integration has to continue from the substituted state.
|
||||
Any arc longer than a half turn crosses it, so this is the common case rather
|
||||
than an edge case.
|
||||
|
||||
The same shape of problem covers a body entering or leaving shadow, a vehicle
|
||||
whose mass or area changes partway through a run, and a bouncing ball. All of
|
||||
them are a state discontinuity located by an event, which is the one thing the
|
||||
callbacks could not express.
|
||||
|
||||
## What Was Done
|
||||
|
||||
### The effect runs after the crossing is committed
|
||||
|
||||
The event branch now commits the shortened step before calling the effect:
|
||||
|
||||
1. the step is shortened to land on the crossing, and the state there is
|
||||
recorded;
|
||||
2. `ode.t` and `ode.y` are set to that crossing;
|
||||
3. the effect is called, with the ODE at the crossing;
|
||||
4. whatever the effect leaves in `ode.y` is what the integration continues from.
|
||||
|
||||
Detection still reads the state at the start of the step — an event function is
|
||||
asked about both ends — so nothing can be committed until every callback has been
|
||||
asked. That is why this is a reordering of the branch rather than a swap of two
|
||||
lines.
|
||||
|
||||
### `effect(&mut self)`
|
||||
|
||||
`Callback::effect` takes `&mut self`, so a callback can record that it has fired,
|
||||
count its firings, or keep anything else it needs across them. `Problem` holds
|
||||
`Vec<&'a mut dyn Callback<T, D, P>>` and `with_callback` takes `&'a mut dyn
|
||||
Callback`, which is the breaking part of the change: a registered callback has to
|
||||
be declared `mut` and passed as `&mut`.
|
||||
|
||||
`FnCallback` still holds its two closures by shared reference and so still has no
|
||||
state of its own; a callback that needs state implements `Callback` on a struct,
|
||||
which is what [Discrete Callbacks](05-discrete-callbacks.md) will do with its
|
||||
`has_fired` field.
|
||||
|
||||
`Problem` lost its `#[derive(Clone)]`, which a `&mut` field cannot support. It
|
||||
was never used.
|
||||
|
||||
### A terminal effect ends the solution at the crossing
|
||||
|
||||
`ODE::finished` existed, was never set and was never read. It now means what its
|
||||
name says: `ODE::terminate` sets it, the solve loop checks it, and `stop` is
|
||||
`ode.terminate()`.
|
||||
|
||||
Behaviorally this is close to what `stop` already did. Writing `t_end = ode.t`
|
||||
also ended the loop, and because the step had already been shortened to the
|
||||
crossing, the last recorded point was the crossing — the solution did not in fact
|
||||
run a step past the event. What the old `stop` did do was mutate the problem's own
|
||||
end time, so a solution could no longer report the `t_end` it was asked for. Now
|
||||
`t_end` is left alone and `finished` says why the solution is short.
|
||||
|
||||
### Both sides of a jump are recorded
|
||||
|
||||
Where an effect changes the state, the trajectory genuinely has two values at
|
||||
that time, and the solution holds both: the same `t` appears twice in `times`,
|
||||
with the state at the crossing and the state the effect left. The `dense` entry
|
||||
between them spans zero time and is never interpolated into — `interpolate` at
|
||||
exactly that time hits the search's exact-match branch, and walks to the last
|
||||
entry at that time, so it reports the state the effect left. That is the value the
|
||||
trajectory carries forward, and it is the one a caller asking "where is the body
|
||||
at this instant" wants; the value it arrived with is the entry before it in
|
||||
`states`.
|
||||
|
||||
This is what DifferentialEquations.jl does, where the two are `u_left` and
|
||||
`u_right`.
|
||||
|
||||
### The controller is reset across a jump
|
||||
|
||||
`Controller` grew a required `reset` method, which discards the error history and
|
||||
leaves the next step guess alone. `solve` calls it when an effect changes the
|
||||
state. The history is a claim about how the error behaves along a smooth
|
||||
solution, and across a discontinuity it is not about anything.
|
||||
|
||||
Keyed on the state changing rather than on the effect running at all, so that a
|
||||
callback which only observes does not throw away a good history every time it
|
||||
fires. An effect that changes only the parameters is a discontinuity the solver
|
||||
cannot see, and does not reset the controller.
|
||||
|
||||
`PIDController::reset` already existed as an inherent method and became the trait
|
||||
implementation; `PIController::reset` restores `factor_old` to its initial value.
|
||||
|
||||
### Two events in one step
|
||||
|
||||
Defined and tested: the earliest crossing in the step fires, the step ends there,
|
||||
and any later event is still ahead and is found again on the following step. A
|
||||
tie goes to registration order.
|
||||
|
||||
The old behavior was to walk the callbacks in registration order, each shortening
|
||||
what the last one left. That could apply a later event's effect and then truncate
|
||||
the step to before it, so the effect was applied at a time the solution never
|
||||
reached. One pass to find the earliest crossing costs an extra root find only
|
||||
when more than one callback crosses in the same step.
|
||||
|
||||
### Crossings at exactly zero, and not firing twice at one
|
||||
|
||||
The test was `before * after < 0.0`, which misses a crossing that starts or ends
|
||||
exactly on zero. Now:
|
||||
|
||||
- `before == 0.0` is a crossing at the step's own start, located at `h = 0`;
|
||||
- `after == 0.0` is a crossing at the step's end;
|
||||
- opposite signs are located with Brent as before.
|
||||
|
||||
The first of those needs a guard, because an effect is free to leave its event
|
||||
function sitting on the zero it fired at. The shadow set swap does exactly that:
|
||||
`|sigma|` is 1 on both sides of it, so `1 - |sigma|` is still zero after the
|
||||
effect. Without a guard the callback is re-detected at the start of the next
|
||||
step, located at `h = 0`, fired again, forever.
|
||||
|
||||
The guard is that the step beginning at a firing searches from
|
||||
`EVENT_PROBE_FRACTION` of itself — a millionth — rather than from its start, for
|
||||
that callback alone. Both the `before` sign and Brent's bracket start there, so
|
||||
the event just handled is out of reach while a second crossing later in the same
|
||||
step is still found. `solve` tracks the time each callback last fired to know
|
||||
when to do this.
|
||||
|
||||
The first attempt at this guard was to skip a callback for the whole step that
|
||||
begins at its firing, which is simpler and wrong: a bouncing ball bounced once
|
||||
and then fell through the floor, because the step after the bounce contained the
|
||||
next landing and the callback was not looking. That is in the test suite now.
|
||||
|
||||
A fraction of a step is not a satisfying thing to have to choose, and this is the
|
||||
least bad option found rather than a good one. Alternatives considered: an
|
||||
absolute tolerance on the event value, which is unit-dependent and no better
|
||||
founded; taking the sign from the step before the firing, which still leaves
|
||||
Brent bracketing a root at `h = 0`; and refusing a located crossing below some
|
||||
`h`, which is the same arbitrary fraction one step further down. If something
|
||||
cleaner turns up, this is the place to change it.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- An even number of crossings inside one step is still missed: the two ends of
|
||||
the step agree in sign and nothing looks in between. Documented on
|
||||
`Problem::solve`.
|
||||
- A crossing inside the first millionth of the step that begins at a firing is
|
||||
missed, which is the cost of the no-refiring guard. It takes an event that
|
||||
recurs almost immediately after one was handled.
|
||||
- The crossing time is located in real arithmetic and carries no derivative.
|
||||
Deliberate: differentiating through Brent's iterations gives the derivative of
|
||||
the iterative procedure rather than the sensitivity of the event, which is
|
||||
wrong without being loud about it. The state at the crossing comes from one
|
||||
ordinary step of the integrator, so it does carry derivatives.
|
||||
- An effect that changes the parameters rather than the state does not reset the
|
||||
controller, and does not record two points.
|
||||
|
||||
## Testing
|
||||
|
||||
In `src/problem.rs`:
|
||||
|
||||
- the reproduction above, ending near 105 rather than 10;
|
||||
- both sides of the jump present in `times`/`states`, interpolation either side of
|
||||
it following the branch it is on, and interpolation at the jump itself giving
|
||||
the state the effect left;
|
||||
- `stop` ending the solution at the crossing, with nothing past it, `t_end`
|
||||
untouched and `finished` set;
|
||||
- a callback counting its own firings and acting only on the first, which needs
|
||||
the `&mut self`;
|
||||
- two events in one step firing earliest-first, with the registration order
|
||||
reversed to show it does not matter;
|
||||
- an effect that lands back on its own event firing once there rather than
|
||||
looping, and firing again when the event genuinely recurs;
|
||||
- a bouncing ball, bouncing at the three analytic bounce times inside the
|
||||
interval and interpolating correctly inside each arc;
|
||||
- an event function already zero at the start of the integration firing there;
|
||||
- the controller reset asked for once across a jump and not at all for an effect
|
||||
that changes nothing, measured with a controller that counts the resets;
|
||||
- a jump under dual numbers, where the derivative through the effect is exact
|
||||
(in `src/lib.rs`, with the autodiff tests);
|
||||
- the Modified Rodrigues Parameter swap itself, against the closed form for a
|
||||
constant angular velocity: one swap, at exactly half a turn, `|sigma| <= 1`
|
||||
throughout, and the final attitude equal to `-cot(theta / 4)` about the
|
||||
rotation axis.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- An effect that writes to `ode.y` changes what is integrated from. ✅
|
||||
- An effect can record something about itself. ✅
|
||||
- A terminated solution ends at the crossing. ✅
|
||||
- A jump is recorded on both sides. ✅
|
||||
- The step size controller forgets its history across a jump. ✅
|
||||
- The order of two events in one step is defined and tested. ✅
|
||||
- No measurable change on the criterion benches. ✅ Measured before and after on
|
||||
the same machine in one sitting: `bench_orbit` -1.02%, `bench_simple_1d`
|
||||
+0.36%, `bench_interpolation_1d` -0.67%, all three within criterion's noise
|
||||
threshold. Which is the right reading of them: re-running the *identical*
|
||||
binary moved `bench_simple_1d` by -0.80% and `bench_interpolation_1d` by
|
||||
+1.47%, so run-to-run drift here is about a percent either way and is larger
|
||||
than the difference being looked for. No bench registers a callback, so what
|
||||
they measure is the no-callback path, which gains a loop over an empty slice
|
||||
and a `finished` check per step.
|
||||
|
||||
**Effort**: Medium
|
||||
Reference in new issue
Block a user