232 lines
11 KiB
Markdown
232 lines
11 KiB
Markdown
# 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
|