Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a73e3dc9ec | ||
|
|
c5768ad9c0 | ||
|
|
55884190fa | ||
|
|
0b6996b0fc | ||
|
|
5b40932317 | ||
|
|
8894de37d6 | ||
|
|
2ce5028529 | ||
|
|
3e6f79b9bd | ||
|
|
bbc94592e5 |
No files matched your search
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ordinary-diffeq"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
authors = ["Connor Johnstone"]
|
||||
description = "A library for solving differential equations based on the DifferentialEquations.jl julia library."
|
||||
@@ -24,6 +24,7 @@ roots = "0.0.8"
|
||||
approx = "0.5"
|
||||
num-dual = "0.15"
|
||||
criterion = "0.7.0"
|
||||
serde_json = "1.0"
|
||||
|
||||
[[bench]]
|
||||
name = "simple_1d"
|
||||
|
||||
@@ -23,12 +23,14 @@ See [benchmark report](VERN7_BENCHMARK_REPORT.md) for detailed performance analy
|
||||
### Other Features
|
||||
|
||||
- **Adaptive time stepping** with PI controller
|
||||
- **Callback events** with zero-crossing detection
|
||||
- **Callback events** with zero-crossing detection, whose effects can change the state, change the
|
||||
parameters, or end the solution at the event
|
||||
- **Dense output interpolation** at any time point
|
||||
- **Parameters** in derivative and callback functions
|
||||
- **Lazy computation** of extra interpolation stages (Vern7)
|
||||
- **PID step size control** as an alternative to the PI controller
|
||||
- **Automatic Differentiation**, the same problem can be solved with floats or also differentiated with dual numbers
|
||||
- **Runtime integrator selection**, the method can be a value, named in a configuration file rather than fixed as a type
|
||||
|
||||
### Future Improvements
|
||||
|
||||
@@ -70,19 +72,98 @@ let ode = ODE::new(&derivative, 0.0, 6.3, y0, params);
|
||||
let dp45 = DormandPrince45::new().a_tol(1e-12).r_tol(1e-6);
|
||||
let controller = PIController::default();
|
||||
|
||||
let value_too_high = FnCallback {
|
||||
let mut value_too_high = FnCallback {
|
||||
event: &|t: f64, _y: &Vector2<f64>, _p: &Params| { 5.0 - t },
|
||||
effect: &stop,
|
||||
};
|
||||
|
||||
// Solve the problem
|
||||
let mut problem = Problem::new(ode, dp45, controller).with_callback(&value_too_high);
|
||||
let mut problem = Problem::new(ode, dp45, controller).with_callback(&mut value_too_high);
|
||||
let solution = problem.solve();
|
||||
|
||||
// Can interpolate solutions to whatever you want
|
||||
let _interpolated_answer = solution.interpolate(4.4);
|
||||
```
|
||||
|
||||
## Events that change the state
|
||||
|
||||
A callback watches a function of the state for a zero crossing. When one is found the step is
|
||||
shortened to land on the crossing, the state there is recorded, and the effect runs with the ODE at
|
||||
that point — so the effect can move the state, and the integration continues from where it leaves it.
|
||||
|
||||
That is what it takes to integrate through a discontinuity. A rotating body parameterized by
|
||||
Modified Rodrigues Parameters, for instance, has a singularity at a full turn, avoided by swapping
|
||||
the parameter vector for `-sigma / |sigma|^2` — the same attitude, different parameters — whenever
|
||||
`|sigma|` passes 1:
|
||||
|
||||
```rust
|
||||
use nalgebra::{Matrix3, Vector3};
|
||||
use ordinary_diffeq::prelude::*;
|
||||
|
||||
/// MRP kinematics for a constant angular velocity.
|
||||
fn attitude_rate(_t: f64, s: Vector3<f64>, omega: &Vector3<f64>) -> Vector3<f64> {
|
||||
let skew = Matrix3::new(0.0, -s[2], s[1], s[2], 0.0, -s[0], -s[1], s[0], 0.0);
|
||||
let b = Matrix3::identity() * (1.0 - s.dot(&s)) + skew * 2.0 + s * s.transpose() * 2.0;
|
||||
b * omega * 0.25
|
||||
}
|
||||
|
||||
let mut shadow_set = FnCallback {
|
||||
event: &|_t: f64, s: &Vector3<f64>, _p: &Vector3<f64>| 1.0 - s.norm(),
|
||||
effect: &|ode: &mut ODE<'_, f64, 3, Vector3<f64>>| {
|
||||
ode.y = -ode.y / ode.y.norm_squared();
|
||||
},
|
||||
};
|
||||
|
||||
let ode = ODE::new(&attitude_rate, 0.0, 5.0, Vector3::zeros(), Vector3::new(0.0, 0.0, 1.0));
|
||||
let vern7 = Vern7::new().a_tol(1e-12).r_tol(1e-12);
|
||||
let mut problem = Problem::new(ode, vern7, PIController::default())
|
||||
.with_callback(&mut shadow_set);
|
||||
let solution = problem.solve();
|
||||
|
||||
// The parameters never approach the singularity, having swapped at half a turn.
|
||||
assert!(solution.states.iter().all(|s| s.norm() <= 1.0 + 1e-9));
|
||||
```
|
||||
|
||||
A jump like that one puts the same time in the solution twice, holding the state on each side of it,
|
||||
and makes the step size controller forget a history that described the solution before the jump.
|
||||
Interpolating at the jump reports the state the effect left, the one the trajectory goes on with.
|
||||
`effect` takes `&mut self`, so a callback that implements the trait itself rather than going through
|
||||
`FnCallback` can record that it has fired. `stop` ends the solution at the crossing, with the
|
||||
crossing as its last point and the problem's own `t_end` left alone.
|
||||
|
||||
If two events fall inside one step the earlier one fires and the step ends there; the later one is
|
||||
found again on the step after it.
|
||||
|
||||
## Choosing the integrator at runtime
|
||||
|
||||
The integrator is normally a type parameter, fixed when the code is compiled. `Method` is an enum
|
||||
over the integrators here that itself implements `Integrator`, so the choice can be a value instead:
|
||||
|
||||
```rust
|
||||
use nalgebra::Vector1;
|
||||
use ordinary_diffeq::prelude::*;
|
||||
|
||||
fn derivative(_t: f64, y: Vector1<f64>, _p: &()) -> Vector1<f64> {
|
||||
-y
|
||||
}
|
||||
|
||||
// `MethodName` derives `Serialize`/`Deserialize`, so this can come out of a
|
||||
// configuration file instead of being written here.
|
||||
let name: MethodName = serde_json::from_str(r#""Vern7""#).unwrap();
|
||||
let method: Method<1> = name.instantiate().a_tol(1e-12).r_tol(1e-12);
|
||||
|
||||
let ode = ODE::new(&derivative, 0.0, 2.0, Vector1::new(1.0), ());
|
||||
let mut problem = Problem::new(ode, method, PIController::default());
|
||||
let solution = problem.solve();
|
||||
|
||||
// And the method that ran can be recorded in a form that selects it again
|
||||
assert_eq!(method.name(), MethodName::Vern7);
|
||||
```
|
||||
|
||||
Dispatch is a `match` on the variant, not a trait object, so nothing is erased: the same `Method`
|
||||
works at a dual scalar and differentiates exactly as the integrator it wraps does. A `Vec<Method<D>>`
|
||||
holds several methods for comparison over the same problem.
|
||||
|
||||
## Automatic differentiation
|
||||
|
||||
State, time and step size are generic over the `Scalar` trait. `f64` implements it by default,
|
||||
|
||||
+39
-22
@@ -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
|
||||
@@ -68,15 +69,29 @@ Each feature below links to a detailed implementation plan in the `features/` di
|
||||
- **Effort**: Small
|
||||
- **Status**: All four integrators implement `Tolerances`; no call site changed
|
||||
|
||||
- [ ] **[Runtime Integrator Selection](features/41-runtime-integrator-selection.md)**
|
||||
- [x] **[Runtime Integrator Selection](features/41-runtime-integrator-selection.md)** ✅ COMPLETED
|
||||
- Structural facts move from associated constants to `&self` methods, so an
|
||||
enum can dispatch and an integrator can be chosen at runtime or named in a
|
||||
config file
|
||||
- **Dependencies**: None (pairs with Tolerances Trait)
|
||||
- **Effort**: Medium
|
||||
- **Status**: `Method` and `MethodName` added; benches measured at -0.7% to
|
||||
+0.4%, within criterion's noise threshold
|
||||
|
||||
### 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.
|
||||
@@ -91,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
|
||||
@@ -109,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
|
||||
@@ -121,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
|
||||
@@ -136,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
|
||||
@@ -144,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
|
||||
@@ -152,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
|
||||
@@ -192,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
|
||||
@@ -210,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
|
||||
@@ -256,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
|
||||
@@ -276,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
|
||||
@@ -353,12 +368,12 @@ Each algorithm implementation should include:
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
Total Features: 41
|
||||
- Tier 1: 10 features (5/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: 12.2% (5/41 features complete)**
|
||||
**Overall Progress: 16.7% (7/42 features complete)**
|
||||
|
||||
### Completed Features
|
||||
1. ✅ BS3 (Bogacki-Shampine 3/2) - Tier 1 (2025-10-23)
|
||||
@@ -366,5 +381,7 @@ Total Features: 41
|
||||
3. ✅ PID Controller - Tier 1 (2025-10-24)
|
||||
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
|
||||
@@ -1,6 +1,10 @@
|
||||
# Feature: Runtime Integrator Selection
|
||||
|
||||
**Status**: proposed (2026-09-30)
|
||||
**Status**: completed (2026-09-30)
|
||||
|
||||
Measured on the existing criterion benches, constants to methods: `bench_orbit`
|
||||
-0.73%, `bench_simple_1d` +0.42%, `bench_interpolation_1d` -0.09%. Criterion
|
||||
calls all three within its noise threshold, so the inlining claim below holds.
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -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
|
||||
+81
-16
@@ -1,31 +1,96 @@
|
||||
//! Conditions that interrupt an integration, and what they do when they fire.
|
||||
|
||||
use nalgebra::SVector;
|
||||
|
||||
use crate::ode::ODE;
|
||||
use crate::scalar::Scalar;
|
||||
|
||||
/// A callback function you can utilize to change your model mid-run without stopping.
|
||||
/// A condition the solver watches for, and the change it makes when it is met.
|
||||
///
|
||||
/// The solver evaluates [`event`](Callback::event) at each end of every accepted
|
||||
/// step and looks for a sign change. When it finds one it locates the crossing
|
||||
/// and applies [`effect`](Callback::effect) to the ODE there.
|
||||
/// step and looks for a sign change. When it finds one it shortens the step to
|
||||
/// land on the crossing, records the state there, and then calls
|
||||
/// [`effect`](Callback::effect) on the ODE. Whatever the effect leaves in
|
||||
/// `ode.y` is the state the integration carries on from.
|
||||
///
|
||||
/// An effect may:
|
||||
///
|
||||
/// - change the state, which records a jump: the solution holds both the value
|
||||
/// at the crossing and the value the effect left, at the same time (see
|
||||
/// [`Solution`](crate::problem::Solution));
|
||||
/// - change the parameters, which changes the right-hand side from the crossing
|
||||
/// onward;
|
||||
/// - end the solution at the crossing, by calling [`stop`] or
|
||||
/// [`ODE::terminate`];
|
||||
/// - record something about itself, since it takes `&mut self`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// A fire-once callback, which needs the `&mut self` on the effect to remember
|
||||
/// that it has run:
|
||||
///
|
||||
/// ```
|
||||
/// use nalgebra::Vector1;
|
||||
/// use ordinary_diffeq::prelude::*;
|
||||
///
|
||||
/// struct Kick {
|
||||
/// fired: usize,
|
||||
/// }
|
||||
///
|
||||
/// impl Callback<f64, 1, ()> for Kick {
|
||||
/// fn event(&self, _t: f64, y: &Vector1<f64>, _p: &()) -> f64 {
|
||||
/// 5.0 - y[0]
|
||||
/// }
|
||||
///
|
||||
/// fn effect(&mut self, ode: &mut ODE<'_, f64, 1, ()>) {
|
||||
/// self.fired += 1;
|
||||
/// ode.y = Vector1::new(100.0);
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// fn derivative(_t: f64, _y: Vector1<f64>, _p: &()) -> Vector1<f64> {
|
||||
/// Vector1::new(1.0)
|
||||
/// }
|
||||
///
|
||||
/// let ode = ODE::new(&derivative, 0.0, 10.0, Vector1::new(0.0), ());
|
||||
/// let mut kick = Kick { fired: 0 };
|
||||
/// let mut problem = Problem::new(ode, DormandPrince45::new(), PIController::default())
|
||||
/// .with_callback(&mut kick);
|
||||
/// let solution = problem.solve();
|
||||
///
|
||||
/// // The state jumped to 100 at t = 5 and kept integrating from there.
|
||||
/// assert!((solution.states.last().unwrap()[0] - 105.0).abs() < 1e-9);
|
||||
/// ```
|
||||
pub trait Callback<T: Scalar, const D: usize, P> {
|
||||
/// The function whose zero crossings mark the event.
|
||||
///
|
||||
/// Called repeatedly while the crossing is being located, so it must not
|
||||
/// depend on how many times it has been called.
|
||||
fn event(&self, t: T, y: &SVector<T, D>, params: &P) -> T;
|
||||
|
||||
/// What to do to the ODE once the crossing has been located.
|
||||
///
|
||||
/// `ode.t` and `ode.y` are the time and state at the crossing when this is
|
||||
/// called.
|
||||
fn effect(&mut self, ode: &mut ODE<'_, T, D, P>);
|
||||
}
|
||||
|
||||
/// A [`Callback`] built from a pair of closures.
|
||||
///
|
||||
/// The closures are held by shared reference, so neither can carry state of its
|
||||
/// own. A callback that has to remember something — that it has already fired,
|
||||
/// how many times, what the state was — implements [`Callback`] on a struct
|
||||
/// instead; the example on that trait shows it.
|
||||
///
|
||||
/// ```
|
||||
/// # use ordinary_diffeq::prelude::*;
|
||||
/// # use nalgebra::Vector2;
|
||||
/// # type Params = ();
|
||||
/// let too_high = FnCallback {
|
||||
/// let mut too_high = FnCallback {
|
||||
/// event: &|_t: f64, y: &Vector2<f64>, _p: &Params| 10.0 - y[0],
|
||||
/// effect: &stop,
|
||||
/// };
|
||||
/// ```
|
||||
pub trait Callback<T: Scalar, const D: usize, P> {
|
||||
/// The function whose zero crossings mark the event.
|
||||
fn event(&self, t: T, y: &SVector<T, D>, params: &P) -> T;
|
||||
|
||||
/// What to do to the ODE once the crossing has been located.
|
||||
fn effect(&self, ode: &mut ODE<'_, T, D, P>);
|
||||
}
|
||||
|
||||
/// A [`Callback`] built from a pair of closures.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FnCallback<'a, T: Scalar, const D: usize, P> {
|
||||
/// The function to check for zero crossings
|
||||
@@ -42,14 +107,14 @@ impl<T: Scalar, const D: usize, P> Callback<T, D, P> for FnCallback<'_, T, D, P>
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn effect(&self, ode: &mut ODE<'_, T, D, P>) {
|
||||
fn effect(&mut self, ode: &mut ODE<'_, T, D, P>) {
|
||||
(self.effect)(ode)
|
||||
}
|
||||
}
|
||||
|
||||
/// A convenience function for stopping the integration
|
||||
/// An effect that ends the solution at the crossing.
|
||||
pub fn stop<T: Scalar, const D: usize, P>(ode: &mut ODE<'_, T, D, P>) {
|
||||
ode.t_end = ode.t;
|
||||
ode.terminate();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+16
-9
@@ -34,6 +34,14 @@ pub trait Controller<T: Scalar> {
|
||||
fn next_step_guess(&self) -> TryStep<T>;
|
||||
|
||||
fn set_next_step_guess(&mut self, guess: TryStep<T>);
|
||||
|
||||
/// Discards the error history, leaving the next step guess alone.
|
||||
///
|
||||
/// The history is an estimate of how the error behaves along a smooth
|
||||
/// solution, so it says nothing across a discontinuity.
|
||||
/// [`Problem::solve`](crate::problem::Problem::solve) calls this when a
|
||||
/// callback effect changes the state.
|
||||
fn reset(&mut self);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -82,6 +90,9 @@ impl<T: Scalar> Controller<T> for PIController<T> {
|
||||
self.next_step_guess = guess;
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.factor_old = 1.0e-4;
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Scalar> PIController<T> {
|
||||
@@ -247,6 +258,11 @@ impl<T: Scalar> Controller<T> for PIDController<T> {
|
||||
self.next_step_guess = guess;
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.err_old = 0.0;
|
||||
self.err_older = 0.0;
|
||||
self.h_old = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Scalar> PIDController<T> {
|
||||
@@ -311,15 +327,6 @@ impl<T: Scalar> PIDController<T> {
|
||||
1e-4, // initial_h
|
||||
)
|
||||
}
|
||||
|
||||
/// Reset the controller's error history
|
||||
///
|
||||
/// Useful when switching algorithms or restarting integration
|
||||
pub fn reset(&mut self) {
|
||||
self.err_old = 0.0;
|
||||
self.err_older = 0.0;
|
||||
self.h_old = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Scalar> Default for PIDController<T> {
|
||||
|
||||
+24
-11
@@ -4,6 +4,9 @@ use super::super::ode::ODE;
|
||||
use super::{Integrator, StepResult, Tolerances};
|
||||
use crate::scalar::Scalar;
|
||||
|
||||
/// The number of stages BS3 evaluates per step.
|
||||
const STAGES: usize = 4;
|
||||
|
||||
/// Bogacki-Shampine 3/2 integrator trait for tableau coefficients
|
||||
pub trait BS3Integrator<'a> {
|
||||
const A: &'a [f64];
|
||||
@@ -155,14 +158,25 @@ impl<'a, const D: usize> Integrator<D> for BS3<D>
|
||||
where
|
||||
BS3<D>: BS3Integrator<'a>,
|
||||
{
|
||||
const ORDER: usize = 3;
|
||||
const STAGES: usize = 4;
|
||||
const ADAPTIVE: bool = true;
|
||||
const DENSE: bool = true;
|
||||
fn order(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn stages(&self) -> usize {
|
||||
STAGES
|
||||
}
|
||||
|
||||
fn adaptive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn dense(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn step<T: Scalar, P>(&self, ode: &ODE<T, D, P>, h: T) -> StepResult<T, D> {
|
||||
// Allocate storage for the 4 stages
|
||||
let mut k: Vec<SVector<T, D>> = vec![SVector::<T, D>::zeros(); Self::STAGES];
|
||||
let mut k: Vec<SVector<T, D>> = vec![SVector::<T, D>::zeros(); STAGES];
|
||||
|
||||
// Stage 1: k1 = f(t, y)
|
||||
k[0] = (ode.f)(ode.t, ode.y, &ode.params);
|
||||
@@ -271,13 +285,12 @@ mod tests {
|
||||
use nalgebra::Vector1;
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
fn test_bs3_creation() {
|
||||
let _bs3: BS3<1> = BS3::new();
|
||||
assert_eq!(BS3::<1>::ORDER, 3);
|
||||
assert_eq!(BS3::<1>::STAGES, 4);
|
||||
assert!(BS3::<1>::ADAPTIVE);
|
||||
assert!(BS3::<1>::DENSE);
|
||||
let bs3: BS3<1> = BS3::new();
|
||||
assert_eq!(bs3.order(), 3);
|
||||
assert_eq!(bs3.stages(), 4);
|
||||
assert!(bs3.adaptive());
|
||||
assert!(bs3.dense());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,6 +4,10 @@ use super::{Integrator, StepResult, Tolerances};
|
||||
use crate::ode::ODE;
|
||||
use crate::scalar::Scalar;
|
||||
|
||||
/// The number of stages Dormand-Prince 5(4) evaluates per step. Also the offset
|
||||
/// between the 5th and 4th order weights in `B`.
|
||||
const STAGES: usize = 7;
|
||||
|
||||
/// Integrator Trait
|
||||
pub trait DormandPrinceIntegrator<'a> {
|
||||
const A: &'a [f64];
|
||||
@@ -174,24 +178,35 @@ impl<'a, const D: usize> Integrator<D> for DormandPrince45<D>
|
||||
where
|
||||
DormandPrince45<D>: DormandPrinceIntegrator<'a>,
|
||||
{
|
||||
const ORDER: usize = 5;
|
||||
const STAGES: usize = 7;
|
||||
const ADAPTIVE: bool = true;
|
||||
const DENSE: bool = true;
|
||||
fn order(&self) -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn stages(&self) -> usize {
|
||||
STAGES
|
||||
}
|
||||
|
||||
fn adaptive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn dense(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn step<T: Scalar, P>(&self, ode: &ODE<T, D, P>, h: T) -> StepResult<T, D> {
|
||||
let mut k: Vec<SVector<T, D>> = vec![SVector::<T, D>::zeros(); Self::STAGES];
|
||||
let mut k: Vec<SVector<T, D>> = vec![SVector::<T, D>::zeros(); STAGES];
|
||||
let mut next_y = ode.y;
|
||||
let mut err = SVector::<T, D>::zeros();
|
||||
let mut rcont5 = SVector::<T, D>::zeros();
|
||||
// Do the first of the summations
|
||||
k[0] = (ode.f)(ode.t, ode.y, &ode.params);
|
||||
next_y += k[0] * T::lift(Self::B[0]) * h;
|
||||
err += k[0] * T::lift(Self::B[0] - Self::B[Self::STAGES]) * h;
|
||||
err += k[0] * T::lift(Self::B[0] - Self::B[STAGES]) * h;
|
||||
let rcont1 = ode.y;
|
||||
rcont5 += k[0] * h * T::lift(Self::D[0]);
|
||||
// Then the rest
|
||||
for i in 1..Self::STAGES {
|
||||
for i in 1..STAGES {
|
||||
// Compute the ks
|
||||
let mut y_term = SVector::<T, D>::zeros();
|
||||
for (j, item) in k.iter().enumerate().take(i) {
|
||||
@@ -201,12 +216,12 @@ where
|
||||
|
||||
// Use that and bis to calculate the y and error terms
|
||||
next_y += k[i] * h * T::lift(Self::B[i]);
|
||||
err += k[i] * T::lift(Self::B[i] - Self::B[i + Self::STAGES]) * h;
|
||||
err += k[i] * T::lift(Self::B[i] - Self::B[i + STAGES]) * h;
|
||||
rcont5 += k[i] * h * T::lift(Self::D[i]);
|
||||
}
|
||||
let rcont2 = next_y - ode.y;
|
||||
let rcont3 = k[0] * h - rcont2;
|
||||
let rcont4 = rcont2 - k[Self::STAGES - 1] * h - rcont3;
|
||||
let rcont4 = rcont2 - k[STAGES - 1] * h - rcont3;
|
||||
let rcont = vec![rcont1, rcont2, rcont3, rcont4, rcont5];
|
||||
// Just take the real component of the error
|
||||
let mut error_norm_sq = 0.0;
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
//! An integrator chosen at runtime.
|
||||
//!
|
||||
//! Which integrator solves a problem is normally a type parameter, fixed when
|
||||
//! the code is compiled. [`Method`] is an enum over the integrators this crate
|
||||
//! provides that itself implements [`Integrator`], so a value can stand where a
|
||||
//! type would otherwise have to: an accuracy preset can carry a method as well
|
||||
//! as a tolerance, a method can be named in a configuration file through
|
||||
//! [`MethodName`], and a `Vec<Method<D>>` can hold several for comparison.
|
||||
//!
|
||||
//! Dispatch is a `match` on the variant, which the compiler resolves per call
|
||||
//! site. There is no trait object and no scalar erasure, so autodiff keeps
|
||||
//! working through a `Method` exactly as through the integrator it wraps.
|
||||
|
||||
use nalgebra::SVector;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::bs3::BS3;
|
||||
use super::dormand_prince::DormandPrince45;
|
||||
use super::rosenbrock::Rosenbrock23;
|
||||
use super::vern7::Vern7;
|
||||
use super::{Integrator, StepResult, Tolerances};
|
||||
use crate::ode::ODE;
|
||||
use crate::scalar::Scalar;
|
||||
|
||||
/// An integrator chosen at runtime.
|
||||
///
|
||||
/// Every variant wraps a configured integrator, so tolerances are set on a
|
||||
/// `Method` the same way they are set on the integrator itself.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use ordinary_diffeq::prelude::*;
|
||||
/// use nalgebra::Vector1;
|
||||
///
|
||||
/// fn derivative(_t: f64, y: Vector1<f64>, _p: &()) -> Vector1<f64> {
|
||||
/// -y
|
||||
/// }
|
||||
///
|
||||
/// // The method is a value here, so it can come from a configuration file, a
|
||||
/// // command line flag, or an accuracy preset.
|
||||
/// let method: Method<1> = MethodName::Vern7.instantiate().a_tol(1e-12).r_tol(1e-12);
|
||||
///
|
||||
/// let ode = ODE::new(&derivative, 0.0, 2.0, Vector1::new(1.0), ());
|
||||
/// let mut problem = Problem::new(ode, method, PIController::default());
|
||||
/// let solution = problem.solve();
|
||||
///
|
||||
/// let y = solution.interpolate(1.3)[0];
|
||||
/// assert!((y - (-1.3f64).exp()).abs() < 1e-10);
|
||||
/// ```
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Method<const D: usize> {
|
||||
/// Bogacki-Shampine 3(2).
|
||||
BS3(BS3<D>),
|
||||
/// Dormand-Prince 5(4).
|
||||
DormandPrince45(DormandPrince45<D>),
|
||||
/// Verner's "most efficient" 7(6).
|
||||
Vern7(Vern7<D>),
|
||||
/// Rosenbrock-W 2(3), for stiff problems.
|
||||
Rosenbrock23(Rosenbrock23<D>),
|
||||
}
|
||||
|
||||
/// Forwards a `&self` call to whichever integrator the method holds.
|
||||
macro_rules! dispatch {
|
||||
($method:expr, $inner:ident => $call:expr) => {
|
||||
match $method {
|
||||
Method::BS3($inner) => $call,
|
||||
Method::DormandPrince45($inner) => $call,
|
||||
Method::Vern7($inner) => $call,
|
||||
Method::Rosenbrock23($inner) => $call,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Forwards a consuming call and puts the result back in the same variant.
|
||||
macro_rules! dispatch_rewrap {
|
||||
($method:expr, $inner:ident => $call:expr) => {
|
||||
match $method {
|
||||
Method::BS3($inner) => Method::BS3($call),
|
||||
Method::DormandPrince45($inner) => Method::DormandPrince45($call),
|
||||
Method::Vern7($inner) => Method::Vern7($call),
|
||||
Method::Rosenbrock23($inner) => Method::Rosenbrock23($call),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl<const D: usize> Method<D> {
|
||||
/// Which method this is, without its configuration.
|
||||
///
|
||||
/// The counterpart to [`MethodName::instantiate`], so a run can record the
|
||||
/// method it used in a form that selects the same one again.
|
||||
pub fn name(&self) -> MethodName {
|
||||
match self {
|
||||
Method::BS3(_) => MethodName::BS3,
|
||||
Method::DormandPrince45(_) => MethodName::DormandPrince45,
|
||||
Method::Vern7(_) => MethodName::Vern7,
|
||||
Method::Rosenbrock23(_) => MethodName::Rosenbrock23,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<const D: usize> Integrator<D> for Method<D> {
|
||||
fn order(&self) -> usize {
|
||||
dispatch!(self, m => m.order())
|
||||
}
|
||||
|
||||
fn stages(&self) -> usize {
|
||||
dispatch!(self, m => m.stages())
|
||||
}
|
||||
|
||||
fn adaptive(&self) -> bool {
|
||||
dispatch!(self, m => m.adaptive())
|
||||
}
|
||||
|
||||
fn dense(&self) -> bool {
|
||||
dispatch!(self, m => m.dense())
|
||||
}
|
||||
|
||||
fn main_stages(&self) -> usize {
|
||||
dispatch!(self, m => m.main_stages())
|
||||
}
|
||||
|
||||
fn extra_stages(&self) -> usize {
|
||||
dispatch!(self, m => m.extra_stages())
|
||||
}
|
||||
|
||||
fn total_dense_stages(&self) -> usize {
|
||||
dispatch!(self, m => m.total_dense_stages())
|
||||
}
|
||||
|
||||
fn step<T: Scalar, P>(&self, ode: &ODE<T, D, P>, h: T) -> StepResult<T, D> {
|
||||
dispatch!(self, m => m.step(ode, h))
|
||||
}
|
||||
|
||||
fn interpolate<T: Scalar>(
|
||||
&self,
|
||||
t_start: T,
|
||||
t_end: T,
|
||||
dense: &[SVector<T, D>],
|
||||
t: T,
|
||||
) -> SVector<T, D> {
|
||||
dispatch!(self, m => m.interpolate(t_start, t_end, dense, t))
|
||||
}
|
||||
|
||||
fn compute_extra_stages<T: Scalar, P>(
|
||||
&self,
|
||||
ode: &ODE<T, D, P>,
|
||||
t_start: T,
|
||||
y_start: SVector<T, D>,
|
||||
h: T,
|
||||
main_stages: &[SVector<T, D>],
|
||||
) -> Vec<SVector<T, D>> {
|
||||
dispatch!(self, m => m.compute_extra_stages(ode, t_start, y_start, h, main_stages))
|
||||
}
|
||||
}
|
||||
|
||||
impl<const D: usize> Tolerances<D> for Method<D> {
|
||||
fn absolute_tolerance(&self) -> SVector<f64, D> {
|
||||
dispatch!(self, m => m.absolute_tolerance())
|
||||
}
|
||||
|
||||
fn relative_tolerance(&self) -> f64 {
|
||||
dispatch!(self, m => m.relative_tolerance())
|
||||
}
|
||||
|
||||
fn a_tol(self, a_tol: f64) -> Self {
|
||||
dispatch_rewrap!(self, m => m.a_tol(a_tol))
|
||||
}
|
||||
|
||||
fn a_tol_full(self, a_tol: SVector<f64, D>) -> Self {
|
||||
dispatch_rewrap!(self, m => m.a_tol_full(a_tol))
|
||||
}
|
||||
|
||||
fn r_tol(self, r_tol: f64) -> Self {
|
||||
dispatch_rewrap!(self, m => m.r_tol(r_tol))
|
||||
}
|
||||
}
|
||||
|
||||
impl<const D: usize> From<BS3<D>> for Method<D> {
|
||||
fn from(integrator: BS3<D>) -> Self {
|
||||
Method::BS3(integrator)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const D: usize> From<DormandPrince45<D>> for Method<D> {
|
||||
fn from(integrator: DormandPrince45<D>) -> Self {
|
||||
Method::DormandPrince45(integrator)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const D: usize> From<Vern7<D>> for Method<D> {
|
||||
fn from(integrator: Vern7<D>) -> Self {
|
||||
Method::Vern7(integrator)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const D: usize> From<Rosenbrock23<D>> for Method<D> {
|
||||
fn from(integrator: Rosenbrock23<D>) -> Self {
|
||||
Method::Rosenbrock23(integrator)
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of an integrator, without its configuration.
|
||||
///
|
||||
/// Serializes as the variant name, so `integrator = "Vern7"` in a configuration
|
||||
/// file deserializes to [`MethodName::Vern7`] and
|
||||
/// [`instantiate`](MethodName::instantiate) turns it into a [`Method`] at
|
||||
/// whatever dimension the problem needs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use ordinary_diffeq::prelude::*;
|
||||
///
|
||||
/// let name: MethodName = serde_json::from_str(r#""Vern7""#).unwrap();
|
||||
/// let method: Method<6> = name.instantiate();
|
||||
/// assert_eq!(method.name(), MethodName::Vern7);
|
||||
/// ```
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum MethodName {
|
||||
/// Bogacki-Shampine 3(2).
|
||||
BS3,
|
||||
/// Dormand-Prince 5(4).
|
||||
DormandPrince45,
|
||||
/// Verner's "most efficient" 7(6).
|
||||
Vern7,
|
||||
/// Rosenbrock-W 2(3), for stiff problems.
|
||||
Rosenbrock23,
|
||||
}
|
||||
|
||||
impl MethodName {
|
||||
/// Builds the named integrator, at its default tolerances.
|
||||
pub fn instantiate<const D: usize>(&self) -> Method<D> {
|
||||
match self {
|
||||
MethodName::BS3 => Method::BS3(BS3::new()),
|
||||
MethodName::DormandPrince45 => Method::DormandPrince45(DormandPrince45::new()),
|
||||
MethodName::Vern7 => Method::Vern7(Vern7::new()),
|
||||
MethodName::Rosenbrock23 => Method::Rosenbrock23(Rosenbrock23::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::controller::PIController;
|
||||
use crate::problem::Problem;
|
||||
use nalgebra::Vector2;
|
||||
|
||||
/// The harmonic oscillator, which every method solves and which has enough
|
||||
/// curvature that steps land in different places at different tolerances.
|
||||
fn derivative(_t: f64, y: Vector2<f64>, _p: &()) -> Vector2<f64> {
|
||||
Vector2::new(y[1], -y[0])
|
||||
}
|
||||
|
||||
/// Solves to `t = 4` and reports the step times, the states, and the value
|
||||
/// interpolated at ten points that fall strictly inside steps.
|
||||
fn solve<S: Integrator<2> + Copy>(integrator: S) -> (Vec<f64>, Vec<Vector2<f64>>, Vec<f64>) {
|
||||
let ode = crate::ode::ODE::new(&derivative, 0.0, 4.0, Vector2::new(1.0, 0.0), ());
|
||||
let mut problem = Problem::new(ode, integrator, PIController::default());
|
||||
let solution = problem.solve();
|
||||
|
||||
let interpolated = (1..=10)
|
||||
.map(|i| solution.interpolate(i as f64 * 0.37)[0])
|
||||
.collect();
|
||||
(
|
||||
solution.times.clone(),
|
||||
solution.states.clone(),
|
||||
interpolated,
|
||||
)
|
||||
}
|
||||
|
||||
/// Wrapping an integrator in `Method` must not change a single bit of the
|
||||
/// answer, interpolation included — which for Vern7 exercises the lazy
|
||||
/// extra stages, the path `extra_stages` governs.
|
||||
#[test]
|
||||
fn test_method_matches_wrapped_integrator() {
|
||||
macro_rules! assert_identical {
|
||||
($integrator:expr, $variant:ident) => {{
|
||||
let integrator = $integrator;
|
||||
let (direct_t, direct_y, direct_interp) = solve(integrator);
|
||||
let (wrapped_t, wrapped_y, wrapped_interp) = solve(Method::$variant(integrator));
|
||||
|
||||
assert_eq!(
|
||||
direct_t,
|
||||
wrapped_t,
|
||||
"{} step times differ",
|
||||
stringify!($variant)
|
||||
);
|
||||
assert_eq!(
|
||||
direct_y,
|
||||
wrapped_y,
|
||||
"{} states differ",
|
||||
stringify!($variant)
|
||||
);
|
||||
assert_eq!(
|
||||
direct_interp,
|
||||
wrapped_interp,
|
||||
"{} interpolated values differ",
|
||||
stringify!($variant)
|
||||
);
|
||||
// And the step count is nontrivial, so the comparison means something.
|
||||
assert!(
|
||||
direct_t.len() > 5,
|
||||
"{} took too few steps",
|
||||
stringify!($variant)
|
||||
);
|
||||
}};
|
||||
}
|
||||
|
||||
assert_identical!(BS3::<2>::new().a_tol(1e-8).r_tol(1e-8), BS3);
|
||||
assert_identical!(
|
||||
DormandPrince45::<2>::new().a_tol(1e-10).r_tol(1e-10),
|
||||
DormandPrince45
|
||||
);
|
||||
assert_identical!(Vern7::<2>::new().a_tol(1e-12).r_tol(1e-12), Vern7);
|
||||
assert_identical!(
|
||||
Rosenbrock23::<2>::new().a_tol(1e-6).r_tol(1e-6),
|
||||
Rosenbrock23
|
||||
);
|
||||
}
|
||||
|
||||
/// The structural facts a `Method` reports are the wrapped integrator's.
|
||||
#[test]
|
||||
fn test_method_reports_wrapped_structure() {
|
||||
let vern7 = Vern7::<2>::new();
|
||||
let wrapped = Method::Vern7(vern7);
|
||||
assert_eq!(wrapped.order(), vern7.order());
|
||||
assert_eq!(wrapped.stages(), vern7.stages());
|
||||
assert_eq!(wrapped.main_stages(), vern7.main_stages());
|
||||
assert_eq!(wrapped.extra_stages(), vern7.extra_stages());
|
||||
assert_eq!(wrapped.total_dense_stages(), vern7.total_dense_stages());
|
||||
assert!(wrapped.adaptive() && wrapped.dense());
|
||||
|
||||
// Dormand-Prince has no extra stages, and the default applies through
|
||||
// the enum as it does directly.
|
||||
let dp45 = Method::DormandPrince45(DormandPrince45::<2>::new());
|
||||
assert_eq!(dp45.extra_stages(), 0);
|
||||
assert_eq!(dp45.total_dense_stages(), dp45.main_stages());
|
||||
}
|
||||
|
||||
/// A name round-trips through serde and instantiates the variant it names.
|
||||
#[test]
|
||||
fn test_method_name_round_trip() {
|
||||
for (name, json) in [
|
||||
(MethodName::BS3, r#""BS3""#),
|
||||
(MethodName::DormandPrince45, r#""DormandPrince45""#),
|
||||
(MethodName::Vern7, r#""Vern7""#),
|
||||
(MethodName::Rosenbrock23, r#""Rosenbrock23""#),
|
||||
] {
|
||||
assert_eq!(serde_json::to_string(&name).unwrap(), json);
|
||||
|
||||
let parsed: MethodName = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed, name);
|
||||
|
||||
let method: Method<3> = parsed.instantiate();
|
||||
assert_eq!(method.name(), name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tolerance setters forward through the enum and keep the variant.
|
||||
#[test]
|
||||
fn test_tolerances_through_the_enum() {
|
||||
let method: Method<2> = MethodName::BS3.instantiate().a_tol(1e-11).r_tol(1e-9);
|
||||
assert_eq!(method.name(), MethodName::BS3);
|
||||
assert_eq!(
|
||||
method.absolute_tolerance(),
|
||||
SVector::<f64, 2>::repeat(1e-11)
|
||||
);
|
||||
assert_eq!(method.relative_tolerance(), 1e-9);
|
||||
|
||||
let per = SVector::<f64, 2>::new(1e-6, 1e-12);
|
||||
let method = method.a_tol_full(per);
|
||||
assert_eq!(method.absolute_tolerance(), per);
|
||||
}
|
||||
|
||||
/// The heterogeneous collection the type parameter used to rule out.
|
||||
#[test]
|
||||
fn test_collection_of_methods() {
|
||||
let methods: Vec<Method<2>> = [
|
||||
MethodName::BS3,
|
||||
MethodName::DormandPrince45,
|
||||
MethodName::Vern7,
|
||||
MethodName::Rosenbrock23,
|
||||
]
|
||||
.iter()
|
||||
.map(|name| name.instantiate().a_tol(1e-8).r_tol(1e-8))
|
||||
.collect();
|
||||
|
||||
for method in methods {
|
||||
let (times, states, _) = solve(method);
|
||||
// cos(4) at the end of the interval, to whatever each method manages.
|
||||
let final_y = states.last().unwrap()[0];
|
||||
assert!(
|
||||
(final_y - 4.0f64.cos()).abs() < 1e-3,
|
||||
"{:?} ended at {final_y} after {} steps",
|
||||
method.name(),
|
||||
times.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The module header claims a `Method` differentiates exactly as the
|
||||
/// integrator it wraps does, which is only true if nothing erases the
|
||||
/// scalar. Vern7 is the demanding case: interpolating inside a step calls
|
||||
/// the right-hand side again, in dual arithmetic, after the solve.
|
||||
#[cfg(feature = "autodiff")]
|
||||
#[test]
|
||||
fn test_method_preserves_autodiff() {
|
||||
use num_dual::Dual64;
|
||||
|
||||
fn decay<T: crate::scalar::Scalar>(_t: T, y: nalgebra::Vector1<T>) -> nalgebra::Vector1<T> {
|
||||
-y
|
||||
}
|
||||
|
||||
let y0 = Dual64::from_re(1.0).derivative();
|
||||
let ode = crate::ode::ODE::new(
|
||||
&|t, y, _p: &()| decay(t, y),
|
||||
Dual64::lift(0.0),
|
||||
Dual64::lift(2.0),
|
||||
nalgebra::Vector1::new(y0),
|
||||
(),
|
||||
);
|
||||
let method: Method<1> = MethodName::Vern7.instantiate().a_tol(1e-12).r_tol(1e-12);
|
||||
let mut problem = Problem::new(ode, method, PIController::default());
|
||||
let solution = problem.solve();
|
||||
|
||||
let y = solution.interpolate(Dual64::lift(1.3))[0];
|
||||
|
||||
// y(t) = y0 e^-t, so both the value and dy/dy0 are e^-1.3.
|
||||
assert!((y.re - (-1.3f64).exp()).abs() < 1e-10);
|
||||
assert!((y.eps - (-1.3f64).exp()).abs() < 1e-10);
|
||||
}
|
||||
|
||||
/// `From` gets an already-configured integrator into the enum.
|
||||
#[test]
|
||||
fn test_from_integrator() {
|
||||
let method: Method<2> = Vern7::<2>::new().a_tol(1e-9).into();
|
||||
assert_eq!(method.name(), MethodName::Vern7);
|
||||
assert_eq!(method.absolute_tolerance(), SVector::<f64, 2>::repeat(1e-9));
|
||||
}
|
||||
}
|
||||
+32
-10
@@ -5,6 +5,7 @@ use crate::scalar::Scalar;
|
||||
|
||||
pub mod bs3;
|
||||
pub mod dormand_prince;
|
||||
pub mod method;
|
||||
pub mod rosenbrock;
|
||||
pub mod vern7;
|
||||
|
||||
@@ -23,27 +24,48 @@ pub struct StepResult<T: Scalar, const D: usize> {
|
||||
/// [`Integrator::interpolate`], or `None` from a method without dense
|
||||
/// output.
|
||||
///
|
||||
/// For a method with lazy dense output (`EXTRA_STAGES > 0`) the layout is
|
||||
/// `[y_start, y_end, k_1, ..., k_MAIN_STAGES]`, and the extra stages are
|
||||
/// For a method with lazy dense output (`extra_stages() > 0`) the layout is
|
||||
/// `[y_start, y_end, k_1, ..., k_main_stages]`, and the extra stages are
|
||||
/// appended by [`Integrator::compute_extra_stages`] on first use.
|
||||
pub dense: Option<Vec<SVector<T, D>>>,
|
||||
}
|
||||
|
||||
/// Integrator Trait
|
||||
///
|
||||
/// The structural facts about a method are `&self` methods rather than
|
||||
/// associated constants, so that a value can answer them. That is what lets
|
||||
/// [`Method`](method::Method) wrap the integrators in an enum and dispatch to
|
||||
/// whichever one it holds, and so lets an integrator be chosen at runtime. None
|
||||
/// of these appears in type position, and for a concrete integrator each call
|
||||
/// inlines to the constant it returns.
|
||||
pub trait Integrator<const D: usize> {
|
||||
const ORDER: usize;
|
||||
const STAGES: usize;
|
||||
const ADAPTIVE: bool;
|
||||
const DENSE: bool;
|
||||
/// The order of the method's solution.
|
||||
fn order(&self) -> usize;
|
||||
|
||||
/// Number of main stages stored in dense output (default: same as STAGES)
|
||||
const MAIN_STAGES: usize = Self::STAGES;
|
||||
/// The number of stages the method evaluates per step.
|
||||
fn stages(&self) -> usize;
|
||||
|
||||
/// Whether the method estimates its local error, and so can be stepped
|
||||
/// adaptively.
|
||||
fn adaptive(&self) -> bool;
|
||||
|
||||
/// Whether the method produces dense output.
|
||||
fn dense(&self) -> bool;
|
||||
|
||||
/// Number of main stages stored in dense output (default: same as `stages`)
|
||||
fn main_stages(&self) -> usize {
|
||||
self.stages()
|
||||
}
|
||||
|
||||
/// Number of extra stages for full-order dense output (default: 0, no extra stages)
|
||||
const EXTRA_STAGES: usize = 0;
|
||||
fn extra_stages(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Total stages when full dense output is computed
|
||||
const TOTAL_DENSE_STAGES: usize = Self::MAIN_STAGES + Self::EXTRA_STAGES;
|
||||
fn total_dense_stages(&self) -> usize {
|
||||
self.main_stages() + self.extra_stages()
|
||||
}
|
||||
|
||||
/// Advances `ode` by `h` from its current state, without mutating it.
|
||||
fn step<T: Scalar, P>(&self, ode: &ODE<T, D, P>, h: T) -> StepResult<T, D>;
|
||||
|
||||
@@ -229,10 +229,21 @@ impl<const D: usize> Default for Rosenbrock23<D> {
|
||||
}
|
||||
|
||||
impl<const D: usize> Integrator<D> for Rosenbrock23<D> {
|
||||
const ORDER: usize = 2;
|
||||
const STAGES: usize = 2;
|
||||
const ADAPTIVE: bool = true;
|
||||
const DENSE: bool = true;
|
||||
fn order(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn stages(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn adaptive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn dense(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn step<T: Scalar, P>(&self, ode: &ODE<T, D, P>, h: T) -> StepResult<T, D> {
|
||||
let t = ode.t;
|
||||
|
||||
+33
-10
@@ -4,6 +4,13 @@ use super::super::ode::ODE;
|
||||
use super::{Integrator, StepResult, Tolerances};
|
||||
use crate::scalar::Scalar;
|
||||
|
||||
/// The number of main stages Vern7 evaluates per step.
|
||||
const STAGES: usize = 10;
|
||||
|
||||
/// The number of further stages Vern7 computes, lazily, for full-order dense
|
||||
/// output.
|
||||
const EXTRA_STAGES: usize = 6;
|
||||
|
||||
/// Verner 7 integrator trait for tableau coefficients
|
||||
pub trait Vern7Integrator<'a> {
|
||||
const A: &'a [f64]; // Lower triangular A matrix (flattened)
|
||||
@@ -289,24 +296,40 @@ impl<'a, const D: usize> Integrator<D> for Vern7<D>
|
||||
where
|
||||
Vern7<D>: Vern7Integrator<'a> + Vern7ExtraStages<'a>,
|
||||
{
|
||||
const ORDER: usize = 7;
|
||||
const STAGES: usize = 10;
|
||||
const ADAPTIVE: bool = true;
|
||||
const DENSE: bool = true;
|
||||
fn order(&self) -> usize {
|
||||
7
|
||||
}
|
||||
|
||||
fn stages(&self) -> usize {
|
||||
STAGES
|
||||
}
|
||||
|
||||
fn adaptive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn dense(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// Lazy dense output configuration
|
||||
const MAIN_STAGES: usize = 10;
|
||||
const EXTRA_STAGES: usize = 6;
|
||||
fn main_stages(&self) -> usize {
|
||||
STAGES
|
||||
}
|
||||
|
||||
fn extra_stages(&self) -> usize {
|
||||
EXTRA_STAGES
|
||||
}
|
||||
|
||||
fn step<T: Scalar, P>(&self, ode: &ODE<T, D, P>, h: T) -> StepResult<T, D> {
|
||||
// Allocate storage for the 10 stages
|
||||
let mut k: Vec<SVector<T, D>> = vec![SVector::<T, D>::zeros(); Self::STAGES];
|
||||
let mut k: Vec<SVector<T, D>> = vec![SVector::<T, D>::zeros(); STAGES];
|
||||
|
||||
// Stage 1: k[0] = f(t, y)
|
||||
k[0] = (ode.f)(ode.t, ode.y, &ode.params);
|
||||
|
||||
// Compute remaining stages using the A matrix
|
||||
for i in 1..Self::STAGES {
|
||||
for i in 1..STAGES {
|
||||
let mut y_temp = ode.y;
|
||||
// A matrix is stored in lower triangular form, row by row
|
||||
// Row i has i elements (0-indexed), starting at position i*(i-1)/2
|
||||
@@ -469,7 +492,7 @@ where
|
||||
k9 * b9_theta) * h;
|
||||
|
||||
// If extra stages are available, add their contribution for full 7th order accuracy
|
||||
if dense.len() >= 2 + Self::TOTAL_DENSE_STAGES {
|
||||
if dense.len() >= 2 + STAGES + EXTRA_STAGES {
|
||||
// Extra stages are at indices 12-17
|
||||
let k11 = &dense[12];
|
||||
let k12 = &dense[13];
|
||||
@@ -563,7 +586,7 @@ where
|
||||
let k8 = &main_stages[7];
|
||||
let k9 = &main_stages[8];
|
||||
|
||||
let mut extra_stages = Vec::with_capacity(Self::EXTRA_STAGES);
|
||||
let mut extra_stages = Vec::with_capacity(EXTRA_STAGES);
|
||||
|
||||
// Stage 11: uses k1, k4-k9 (7 coefficients)
|
||||
let mut y11 = y_start;
|
||||
|
||||
+45
-2
@@ -16,6 +16,7 @@ pub mod prelude {
|
||||
pub use super::controller::{PIController, PIDController};
|
||||
pub use super::integrator::bs3::BS3;
|
||||
pub use super::integrator::dormand_prince::DormandPrince45;
|
||||
pub use super::integrator::method::{Method, MethodName};
|
||||
pub use super::integrator::rosenbrock::Rosenbrock23;
|
||||
pub use super::integrator::vern7::Vern7;
|
||||
pub use super::ode::ODE;
|
||||
@@ -51,13 +52,13 @@ mod tests {
|
||||
let dp45 = DormandPrince45::new().a_tol(1e-12).r_tol(1e-6);
|
||||
let controller = PIController::default();
|
||||
|
||||
let value_too_high = FnCallback {
|
||||
let mut value_too_high = FnCallback {
|
||||
event: &|t: f64, _y: &Vector2<f64>, _p: &Params| 5.0 - t,
|
||||
effect: &stop,
|
||||
};
|
||||
|
||||
// Solve the problem
|
||||
let mut problem = Problem::new(ode, dp45, controller).with_callback(&value_too_high);
|
||||
let mut problem = Problem::new(ode, dp45, controller).with_callback(&mut value_too_high);
|
||||
let solution = problem.solve();
|
||||
|
||||
// Can interpolate solutions to whatever you want
|
||||
@@ -321,6 +322,48 @@ mod autodiff_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Derivatives survive a callback that changes the state.
|
||||
///
|
||||
/// `y' = y` from `y0`, halved the moment it reaches 2, run to `t = 2`. It
|
||||
/// reaches 2 twice in that interval — at `ln 2` and at `2 ln 2` — and `n`
|
||||
/// halvings leave `y(2) = e^2 y0 / 2^n` whatever the times were, so the
|
||||
/// solution and its sensitivity to `y0` are both `e^2 / 4` exactly.
|
||||
///
|
||||
/// The crossing time is located in real arithmetic and carries no
|
||||
/// derivative. This problem is one where that costs nothing: the state at
|
||||
/// the crossing is fixed at 2 by the event itself, and the final state turns
|
||||
/// out not to depend on when the crossing happened. A problem where the
|
||||
/// event time does matter needs the sensitivity of the event condition,
|
||||
/// which forward mode through Brent's iterations would not give.
|
||||
#[test]
|
||||
fn test_ad_through_a_callback_jump() {
|
||||
fn derivative<T: Scalar>(_t: T, y: Vector1<T>, _p: &()) -> Vector1<T> {
|
||||
y
|
||||
}
|
||||
|
||||
let y0 = Dual64::from_re(1.0).derivative();
|
||||
let mut halve = FnCallback {
|
||||
event: &|_t: Dual64, y: &Vector1<Dual64>, _p: &()| Dual64::lift(2.0) - y[0],
|
||||
effect: &|ode: &mut ODE<'_, Dual64, 1, ()>| ode.y /= Dual64::lift(2.0),
|
||||
};
|
||||
|
||||
let ode = ODE::new(
|
||||
&derivative,
|
||||
Dual64::lift(0.0),
|
||||
Dual64::lift(2.0),
|
||||
Vector1::new(y0),
|
||||
(),
|
||||
);
|
||||
let vern7 = Vern7::new().a_tol(1e-12).r_tol(1e-12);
|
||||
let mut problem =
|
||||
Problem::new(ode, vern7, PIController::default()).with_callback(&mut halve);
|
||||
let solution = problem.solve();
|
||||
let y = solution.states.last().unwrap()[0];
|
||||
|
||||
assert_relative_eq!(y.re, 2.0f64.exp() / 4.0, max_relative = 1e-10);
|
||||
assert_relative_eq!(y.eps, 2.0f64.exp() / 4.0, max_relative = 1e-10);
|
||||
}
|
||||
|
||||
/// The same, through Vern7 rather than Dormand-Prince, which exercises a
|
||||
/// path Dormand-Prince does not have: Vern7 keeps six of its sixteen stages
|
||||
/// back and computes them only when something interpolates inside a step.
|
||||
|
||||
+12
@@ -23,6 +23,9 @@ pub struct ODE<'a, T: Scalar, const D: usize, P> {
|
||||
pub t0: T,
|
||||
pub t_end: T,
|
||||
pub h: T,
|
||||
|
||||
/// Set when the solution has been ended short of `t_end`, by a callback
|
||||
/// effect calling [`terminate`](ODE::terminate).
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
@@ -45,6 +48,15 @@ impl<'a, T: Scalar, const D: usize, P> ODE<'a, T, D, P> {
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the solution here, without disturbing `t_end`.
|
||||
///
|
||||
/// Meant for a callback effect: `Problem::solve` records the state at the
|
||||
/// crossing and then stops. [`stop`](crate::callback::stop) is this as a
|
||||
/// ready-made effect.
|
||||
pub fn terminate(&mut self) {
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+597
-37
@@ -1,4 +1,11 @@
|
||||
//! Assembling an ODE, an integrator and a controller
|
||||
//!
|
||||
//! Event location is done in real arithmetic: the event condition is a
|
||||
//! real-valued question, and Brent's method differentiated through would give
|
||||
//! the derivative of its own iteration rather than the sensitivity of the
|
||||
//! event, which is wrong in a way that does not announce itself. So the search
|
||||
//! runs on `re()` projections and the state at the crossing is produced by one
|
||||
//! ordinary step of the integrator, which does carry derivatives.
|
||||
|
||||
use nalgebra::SVector;
|
||||
use roots::{find_root_brent, SimpleConvergency};
|
||||
@@ -10,7 +17,22 @@ use super::integrator::Integrator;
|
||||
use super::ode::ODE;
|
||||
use super::scalar::Scalar;
|
||||
|
||||
#[derive(Clone)]
|
||||
/// How far into a step the event function of a callback that just fired is read,
|
||||
/// as a fraction of the step.
|
||||
///
|
||||
/// An effect is free to leave its event function sitting on the zero it fired
|
||||
/// at — the shadow set swap of a Modified Rodrigues Parameter vector does
|
||||
/// exactly that — so reading it at the start of the next step would locate the
|
||||
/// same event again, at no advance in time, forever. Reading a little way in
|
||||
/// instead gives the sign the event function has after the effect, and the
|
||||
/// search for a crossing starts from the same place, so a second crossing
|
||||
/// further into the same step is still found.
|
||||
///
|
||||
/// Small enough that a genuine crossing is very unlikely to hide inside it, and
|
||||
/// large enough that the event function has moved clear of its own rounding at
|
||||
/// the event.
|
||||
const EVENT_PROBE_FRACTION: f64 = 1e-6;
|
||||
|
||||
pub struct Problem<'a, T: Scalar, const D: usize, S, P, C>
|
||||
where
|
||||
S: Integrator<D>,
|
||||
@@ -19,7 +41,7 @@ where
|
||||
ode: ODE<'a, T, D, P>,
|
||||
integrator: S,
|
||||
controller: C,
|
||||
callbacks: Vec<&'a dyn Callback<T, D, P>>,
|
||||
callbacks: Vec<&'a mut dyn Callback<T, D, P>>,
|
||||
}
|
||||
|
||||
impl<'a, T: Scalar, const D: usize, S, P, C> Problem<'a, T, D, S, P, C>
|
||||
@@ -36,6 +58,27 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Integrates from `t0` to `t_end`, or to wherever a callback ends it.
|
||||
///
|
||||
/// Each accepted step is checked against every registered callback. When an
|
||||
/// event function changes sign across a step, the step is shortened to land
|
||||
/// on the crossing, the state there is recorded, and the callback's effect
|
||||
/// runs with the ODE at that point — so an effect may change the state, the
|
||||
/// parameters, or end the solution, and the integration carries on from
|
||||
/// whatever it leaves behind.
|
||||
///
|
||||
/// If more than one event falls inside one step, the earliest fires and the
|
||||
/// step ends there; the others are still ahead and are found again on the
|
||||
/// following step. Ties go to the order the callbacks were registered.
|
||||
///
|
||||
/// A callback does not fire twice at the same time: an effect may leave its
|
||||
/// event function sitting on the zero it fired at, and the step that begins
|
||||
/// at a firing searches from a hair inside itself rather than from its start.
|
||||
/// A second crossing later in that step is still found — a bouncing ball
|
||||
/// keeps bouncing — but one inside that first millionth of the step is not.
|
||||
///
|
||||
/// An even number of crossings inside one step is missed, in that the step's
|
||||
/// two ends agree in sign and nothing looks in between.
|
||||
pub fn solve(&mut self) -> Solution<'_, T, S, D, P> {
|
||||
let mut convergency = SimpleConvergency {
|
||||
eps: 1e-12f64,
|
||||
@@ -44,7 +87,10 @@ where
|
||||
let mut times: Vec<T> = vec![self.ode.t];
|
||||
let mut states: Vec<SVector<T, D>> = vec![self.ode.y];
|
||||
let mut dense_coefficients: Vec<RefCell<Vec<SVector<T, D>>>> = Vec::new();
|
||||
while self.ode.t.re() < self.ode.t_end.re() {
|
||||
// When each callback last fired, which is where its next search for a
|
||||
// crossing starts from rather than at — see `EVENT_PROBE_FRACTION`.
|
||||
let mut last_fired: Vec<Option<f64>> = vec![None; self.callbacks.len()];
|
||||
while !self.ode.finished && self.ode.t.re() < self.ode.t_end.re() {
|
||||
if (self.ode.t + self.controller.next_step_guess().extract()).re()
|
||||
> self.ode.t_end.re()
|
||||
{
|
||||
@@ -52,7 +98,7 @@ where
|
||||
self.controller
|
||||
.set_next_step_guess(TryStep::NotYetAccepted(self.ode.t_end - self.ode.t));
|
||||
}
|
||||
let (mut new_y, mut curr_step, mut dense_option) = if S::ADAPTIVE {
|
||||
let (new_y, curr_step, dense_option) = if self.integrator.adaptive() {
|
||||
// First, we try stepping with the "next step guess" to get the error
|
||||
let mut result = self
|
||||
.integrator
|
||||
@@ -85,32 +131,94 @@ where
|
||||
result.dense,
|
||||
)
|
||||
};
|
||||
if !self.callbacks.is_empty() {
|
||||
// Check for events occurring
|
||||
for callback in &self.callbacks {
|
||||
// The event condition is a real-valued question, so it is
|
||||
// asked in the reals — see this module's header.
|
||||
let before = callback.event(self.ode.t, &self.ode.y, &self.ode.params).re();
|
||||
let after = callback
|
||||
.event(self.ode.t + curr_step, &new_y, &self.ode.params)
|
||||
.re();
|
||||
if before * after < 0.0 {
|
||||
// If the event crossed zero, then find the root
|
||||
let f = |test_h: f64| {
|
||||
let h = T::lift(test_h);
|
||||
let test_y = self.integrator.step(&self.ode, h).y;
|
||||
callback.event(self.ode.t + h, &test_y, &self.ode.params).re()
|
||||
};
|
||||
let root =
|
||||
find_root_brent(0.0f64, curr_step.re(), &f, &mut convergency).unwrap();
|
||||
curr_step = T::lift(root);
|
||||
let result = self.integrator.step(&self.ode, curr_step);
|
||||
new_y = result.y;
|
||||
dense_option = result.dense;
|
||||
callback.effect(&mut self.ode);
|
||||
// Look for the earliest event in the step. Nothing is committed
|
||||
// while looking, because an event function is asked about the state
|
||||
// at the start of the step as well as at the end.
|
||||
let mut earliest: Option<(usize, f64)> = None;
|
||||
for (index, (callback, fired)) in
|
||||
self.callbacks.iter().zip(last_fired.iter()).enumerate()
|
||||
{
|
||||
// A callback that fired at this step's start searches from a
|
||||
// little way in instead, so that it cannot find the event it
|
||||
// just handled again.
|
||||
let search_start = if *fired == Some(self.ode.t.re()) {
|
||||
curr_step * EVENT_PROBE_FRACTION
|
||||
} else {
|
||||
T::lift(0.0)
|
||||
};
|
||||
// The event condition is a real-valued question, so it is
|
||||
// asked in the reals — see this module's header.
|
||||
let before = if search_start.re() == 0.0 {
|
||||
callback.event(self.ode.t, &self.ode.y, &self.ode.params).re()
|
||||
} else {
|
||||
let probe = self.integrator.step(&self.ode, search_start).y;
|
||||
callback
|
||||
.event(self.ode.t + search_start, &probe, &self.ode.params)
|
||||
.re()
|
||||
};
|
||||
let after = callback
|
||||
.event(self.ode.t + curr_step, &new_y, &self.ode.params)
|
||||
.re();
|
||||
let crossing = if before == 0.0 {
|
||||
// Sitting exactly on the event. At the start of a step that
|
||||
// is a crossing, and it fires there; a little way into one
|
||||
// it is an event function this step can do nothing with,
|
||||
// because the effect has already run at its zero.
|
||||
(search_start.re() == 0.0).then_some(0.0)
|
||||
} else if after == 0.0 {
|
||||
Some(curr_step.re())
|
||||
} else if before * after < 0.0 {
|
||||
let f = |test_h: f64| {
|
||||
let h = T::lift(test_h);
|
||||
let test_y = self.integrator.step(&self.ode, h).y;
|
||||
callback.event(self.ode.t + h, &test_y, &self.ode.params).re()
|
||||
};
|
||||
Some(
|
||||
find_root_brent(search_start.re(), curr_step.re(), &f, &mut convergency)
|
||||
.unwrap(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Two events in one step: the earlier one fires and the step
|
||||
// ends there, so the later one is still ahead and is found
|
||||
// again on the next step. A tie goes to registration order.
|
||||
if let Some(h_event) = crossing {
|
||||
if earliest.is_none_or(|(_, h)| h_event < h) {
|
||||
earliest = Some((index, h_event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((index, h_event)) = earliest {
|
||||
// Land on the crossing and record it, before the effect runs:
|
||||
// the trajectory does reach this state, and a terminal effect
|
||||
// should leave it as the last point of the solution.
|
||||
if h_event != 0.0 {
|
||||
let result = self.integrator.step(&self.ode, T::lift(h_event));
|
||||
self.ode.t += T::lift(h_event);
|
||||
self.ode.y = result.y;
|
||||
times.push(self.ode.t);
|
||||
states.push(self.ode.y);
|
||||
dense_coefficients.push(RefCell::new(result.dense.unwrap()));
|
||||
}
|
||||
last_fired[index] = Some(self.ode.t.re());
|
||||
let before_effect = self.ode.y;
|
||||
self.callbacks[index].effect(&mut self.ode);
|
||||
if self.ode.y != before_effect {
|
||||
// The state jumped. Both sides of it are part of the
|
||||
// solution, so both are recorded at the same time, with a
|
||||
// zero-length interval between them. The controller's error
|
||||
// history describes the solution up to the jump and says
|
||||
// nothing about the one after it.
|
||||
times.push(self.ode.t);
|
||||
states.push(self.ode.y);
|
||||
dense_coefficients.push(RefCell::new(vec![before_effect, self.ode.y]));
|
||||
self.controller.reset();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
self.ode.y = new_y;
|
||||
self.ode.t += curr_step;
|
||||
times.push(self.ode.t);
|
||||
@@ -127,12 +235,30 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_callback(mut self, callback: &'a dyn Callback<T, D, P>) -> Self {
|
||||
/// Registers a callback.
|
||||
///
|
||||
/// Callbacks are watched in the order they are registered, which decides
|
||||
/// which one fires when two events land at the same time.
|
||||
pub fn with_callback(mut self, callback: &'a mut dyn Callback<T, D, P>) -> Self {
|
||||
self.callbacks.push(callback);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// What an integration produced: the steps it took, and the means to evaluate
|
||||
/// between them.
|
||||
///
|
||||
/// `times` and `states` run in step order and are the same length. `dense` has
|
||||
/// one fewer entry, the coefficients for interpolating inside the step from
|
||||
/// `times[i]` to `times[i + 1]`.
|
||||
///
|
||||
/// A callback effect that changes the state puts the same time in `times`
|
||||
/// twice, with the state before the jump and the state after it. The pair is
|
||||
/// what the trajectory does: it reaches one value and leaves from the other.
|
||||
/// [`interpolate`](Solution::interpolate) at exactly that time reports the
|
||||
/// second of them, the state the effect left, since that is the one the
|
||||
/// trajectory carries forward; the value it arrived with is the entry before it
|
||||
/// in `states`.
|
||||
pub struct Solution<'a, T: Scalar, S, const D: usize, P>
|
||||
where
|
||||
S: Integrator<D>,
|
||||
@@ -148,6 +274,10 @@ impl<T: Scalar, S, const D: usize, P> Solution<'_, T, S, D, P>
|
||||
where
|
||||
S: Integrator<D>,
|
||||
{
|
||||
/// The state at `t`, interpolated inside whichever step contains it.
|
||||
///
|
||||
/// At the time of a jump this is the state the callback's effect left. Panics
|
||||
/// if `t` is outside the solution.
|
||||
pub fn interpolate(&self, t: T) -> SVector<T, D> {
|
||||
// First check that the t is within bounds
|
||||
let last = self.times.last().unwrap().re();
|
||||
@@ -164,7 +294,16 @@ where
|
||||
|
||||
// Then find the two t values closest to the desired t
|
||||
match times.binary_search_by(|x| x.total_cmp(&t.re())) {
|
||||
Ok(index) => self.states[index],
|
||||
Ok(index) => {
|
||||
// A jump records its time twice and the search may land on
|
||||
// either entry. Walk to the last of them, which is the state the
|
||||
// effect left and the one the trajectory goes on with.
|
||||
let mut index = index;
|
||||
while index + 1 < times.len() && times[index + 1] == times[index] {
|
||||
index += 1;
|
||||
}
|
||||
self.states[index]
|
||||
}
|
||||
Err(end_index) => {
|
||||
// Then send that to the integrator
|
||||
let t_start = self.times[end_index - 1];
|
||||
@@ -172,16 +311,24 @@ where
|
||||
let y_start = self.states[end_index - 1];
|
||||
let h = t_end - t_start;
|
||||
|
||||
if h.re() == 0.0 {
|
||||
// The two sides of a jump. Any `t` strictly inside this
|
||||
// interval would have to be the jump's own time, which the
|
||||
// search above answers exactly, so this is only reached if
|
||||
// a step of zero length was recorded some other way.
|
||||
return y_start;
|
||||
}
|
||||
|
||||
// Check if we need to compute extra stages for lazy dense output
|
||||
let dense_cell = &self.dense[end_index - 1];
|
||||
|
||||
if S::EXTRA_STAGES > 0 {
|
||||
if self.integrator.extra_stages() > 0 {
|
||||
let needs_extra = {
|
||||
let borrowed = dense_cell.borrow();
|
||||
// Dense array format: [y0, y1, k1, k2, ..., k_main]
|
||||
// If we have main stages only: 2 + MAIN_STAGES elements
|
||||
// If we have all stages: 2 + MAIN_STAGES + EXTRA_STAGES elements
|
||||
borrowed.len() < 2 + S::TOTAL_DENSE_STAGES
|
||||
// If we have main stages only: 2 + main_stages() elements
|
||||
// If we have all stages: 2 + total_dense_stages() elements
|
||||
borrowed.len() < 2 + self.integrator.total_dense_stages()
|
||||
};
|
||||
|
||||
if needs_extra {
|
||||
@@ -189,7 +336,7 @@ where
|
||||
let mut dense = dense_cell.borrow_mut();
|
||||
|
||||
// Extract main stages (skip y0 and y1 at indices 0 and 1)
|
||||
let main_stages = &dense[2..2 + S::MAIN_STAGES];
|
||||
let main_stages = &dense[2..2 + self.integrator.main_stages()];
|
||||
|
||||
// Compute extra stages lazily
|
||||
let extra_stages = self.integrator.compute_extra_stages(
|
||||
@@ -221,7 +368,16 @@ mod tests {
|
||||
use crate::integrator::dormand_prince::DormandPrince45;
|
||||
use crate::integrator::Tolerances;
|
||||
use approx::assert_relative_eq;
|
||||
use nalgebra::Vector3;
|
||||
use nalgebra::{Matrix3, Vector1, Vector2, Vector3};
|
||||
use std::cell::Cell;
|
||||
use std::f64::consts::PI;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// `dy/dt = 1`, so the state is the time elapsed and an event on the state
|
||||
/// is an event on the clock.
|
||||
fn unit_rate(_t: f64, _y: Vector1<f64>, _p: &()) -> Vector1<f64> {
|
||||
Vector1::new(1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_problem() {
|
||||
@@ -259,12 +415,12 @@ mod tests {
|
||||
let dp45 = DormandPrince45::new().a_tol(1e-12).r_tol(1e-5);
|
||||
let controller = PIController::default();
|
||||
|
||||
let value_too_high = FnCallback {
|
||||
let mut value_too_high = FnCallback {
|
||||
event: &|_: f64, y: &SVector<f64, 3>, _: &Params| 10.0 - y[0],
|
||||
effect: &stop,
|
||||
};
|
||||
|
||||
let mut problem = Problem::new(ode, dp45, controller).with_callback(&value_too_high);
|
||||
let mut problem = Problem::new(ode, dp45, controller).with_callback(&mut value_too_high);
|
||||
let solution = problem.solve();
|
||||
|
||||
assert_relative_eq!(
|
||||
@@ -274,6 +430,410 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An effect that writes to `ode.y` is what the integration carries on from.
|
||||
#[test]
|
||||
fn test_effect_changes_the_state() {
|
||||
let ode = ODE::new(&unit_rate, 0.0, 10.0, Vector1::new(0.0), ());
|
||||
let dp45 = DormandPrince45::new().a_tol(1e-12).r_tol(1e-10);
|
||||
let mut jump = FnCallback {
|
||||
event: &|_t: f64, y: &Vector1<f64>, _p: &()| 5.0 - y[0],
|
||||
effect: &|ode: &mut ODE<'_, f64, 1, ()>| ode.y = Vector1::new(100.0),
|
||||
};
|
||||
|
||||
let mut problem =
|
||||
Problem::new(ode, dp45, PIController::default()).with_callback(&mut jump);
|
||||
let solution = problem.solve();
|
||||
|
||||
// Five units of integration before the jump, five after.
|
||||
assert_relative_eq!(solution.times.last().unwrap(), &10.0, max_relative = 1e-12);
|
||||
assert_relative_eq!(
|
||||
solution.states.last().unwrap()[0],
|
||||
105.0,
|
||||
max_relative = 1e-9
|
||||
);
|
||||
}
|
||||
|
||||
/// A jump is recorded as two states at the same time, in the order the
|
||||
/// trajectory visits them.
|
||||
#[test]
|
||||
fn test_jump_records_both_sides() {
|
||||
let ode = ODE::new(&unit_rate, 0.0, 10.0, Vector1::new(0.0), ());
|
||||
let dp45 = DormandPrince45::new().a_tol(1e-12).r_tol(1e-10);
|
||||
let mut jump = FnCallback {
|
||||
event: &|_t: f64, y: &Vector1<f64>, _p: &()| 5.0 - y[0],
|
||||
effect: &|ode: &mut ODE<'_, f64, 1, ()>| ode.y = Vector1::new(100.0),
|
||||
};
|
||||
|
||||
let mut problem =
|
||||
Problem::new(ode, dp45, PIController::default()).with_callback(&mut jump);
|
||||
let solution = problem.solve();
|
||||
|
||||
let at_crossing: Vec<usize> = (0..solution.times.len())
|
||||
.filter(|&i| (solution.times[i] - 5.0).abs() < 1e-9)
|
||||
.collect();
|
||||
assert_eq!(at_crossing.len(), 2, "the two sides of the jump");
|
||||
let (left, right) = (at_crossing[0], at_crossing[1]);
|
||||
assert_eq!(right, left + 1);
|
||||
assert_relative_eq!(solution.states[left][0], 5.0, max_relative = 1e-9);
|
||||
assert_relative_eq!(solution.states[right][0], 100.0, max_relative = 1e-12);
|
||||
|
||||
// Interpolation either side of the jump follows the branch it is on,
|
||||
// and at the jump itself reports the state the effect left.
|
||||
assert_relative_eq!(solution.interpolate(4.9)[0], 4.9, max_relative = 1e-9);
|
||||
assert_relative_eq!(solution.interpolate(5.1)[0], 100.1, max_relative = 1e-9);
|
||||
assert_relative_eq!(
|
||||
solution.interpolate(solution.times[left])[0],
|
||||
100.0,
|
||||
max_relative = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
/// `stop` ends the solution at the crossing, and leaves `t_end` alone.
|
||||
#[test]
|
||||
fn test_stop_ends_at_the_crossing() {
|
||||
let ode = ODE::new(&unit_rate, 0.0, 10.0, Vector1::new(0.0), ());
|
||||
let dp45 = DormandPrince45::new().a_tol(1e-12).r_tol(1e-10);
|
||||
let mut stopper = FnCallback {
|
||||
event: &|_t: f64, y: &Vector1<f64>, _p: &()| 5.0 - y[0],
|
||||
effect: &stop,
|
||||
};
|
||||
|
||||
let mut problem =
|
||||
Problem::new(ode, dp45, PIController::default()).with_callback(&mut stopper);
|
||||
let solution = problem.solve();
|
||||
|
||||
assert_relative_eq!(solution.times.last().unwrap(), &5.0, max_relative = 1e-12);
|
||||
assert_relative_eq!(
|
||||
solution.states.last().unwrap()[0],
|
||||
5.0,
|
||||
max_relative = 1e-12
|
||||
);
|
||||
// Nothing past the event, and the problem's own end time is unchanged.
|
||||
assert!(solution.times.iter().all(|&t| t <= 5.0 + 1e-12));
|
||||
assert_eq!(solution.ode.t_end, 10.0);
|
||||
assert!(solution.ode.finished);
|
||||
}
|
||||
|
||||
/// `effect` takes `&mut self`, so a callback can count its own firings and
|
||||
/// act only on the first one.
|
||||
#[test]
|
||||
fn test_callback_can_remember_firing() {
|
||||
struct FireOnce {
|
||||
fired: usize,
|
||||
}
|
||||
|
||||
impl Callback<f64, 1, ()> for FireOnce {
|
||||
fn event(&self, t: f64, _y: &Vector1<f64>, _p: &()) -> f64 {
|
||||
// Crosses at t = 1, 3, 5 and 7.
|
||||
(t * PI / 2.0).cos()
|
||||
}
|
||||
|
||||
fn effect(&mut self, ode: &mut ODE<'_, f64, 1, ()>) {
|
||||
self.fired += 1;
|
||||
if self.fired == 1 {
|
||||
ode.y = Vector1::new(100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ode = ODE::new(&unit_rate, 0.0, 9.0, Vector1::new(0.0), ());
|
||||
let dp45 = DormandPrince45::new().a_tol(1e-12).r_tol(1e-10);
|
||||
let mut once = FireOnce { fired: 0 };
|
||||
// `dy/dt = 1` has no local error at all, so the step size runs away to
|
||||
// `h_max`; capped at half a period, since a step long enough to contain
|
||||
// two crossings sees no sign change and finds neither.
|
||||
let controller = PIController::new(0.17, 0.04, 10.0, 0.2, 1.0, 0.9, 1e-4);
|
||||
|
||||
let mut problem = Problem::new(ode, dp45, controller).with_callback(&mut once);
|
||||
let solution = problem.solve();
|
||||
let last = solution.states.last().unwrap()[0];
|
||||
|
||||
assert_eq!(once.fired, 4);
|
||||
// Only the first firing moved the state: 1 unit before the jump, 8 after.
|
||||
assert_relative_eq!(last, 108.0, max_relative = 1e-9);
|
||||
}
|
||||
|
||||
/// An event function that is already zero at the start of a step crosses
|
||||
/// there, and fires there, rather than being passed over.
|
||||
#[test]
|
||||
fn test_event_already_zero_fires_at_the_step_start() {
|
||||
// y starts at zero and the event is y, so the event is met at t = 0.
|
||||
let ode = ODE::new(&unit_rate, 0.0, 1.0, Vector1::new(0.0), ());
|
||||
let mut at_zero = FnCallback {
|
||||
event: &|_t: f64, y: &Vector1<f64>, _p: &()| y[0],
|
||||
effect: &|ode: &mut ODE<'_, f64, 1, ()>| ode.y = Vector1::new(100.0),
|
||||
};
|
||||
|
||||
let mut problem = Problem::new(ode, DormandPrince45::new(), PIController::default())
|
||||
.with_callback(&mut at_zero);
|
||||
let solution = problem.solve();
|
||||
|
||||
assert_eq!(solution.times[0], 0.0);
|
||||
assert_eq!(solution.states[0][0], 0.0);
|
||||
assert_eq!(solution.times[1], 0.0);
|
||||
assert_eq!(solution.states[1][0], 100.0);
|
||||
assert_relative_eq!(
|
||||
solution.states.last().unwrap()[0],
|
||||
101.0,
|
||||
max_relative = 1e-9
|
||||
);
|
||||
}
|
||||
|
||||
/// Two events inside one step: the earlier one fires, and the later one is
|
||||
/// found again on the step after it. Which callback was registered first
|
||||
/// does not change the order they fire in.
|
||||
#[test]
|
||||
fn test_earliest_event_in_a_step_fires_first() {
|
||||
fn order_of_firings(three_first: bool) -> Vec<f64> {
|
||||
let fired = Rc::new(Cell::new([0.0f64; 2]));
|
||||
|
||||
let at_three = {
|
||||
let fired = Rc::clone(&fired);
|
||||
move |ode: &mut ODE<'_, f64, 1, ()>| {
|
||||
let mut seen = fired.get();
|
||||
seen[0] = ode.t;
|
||||
fired.set(seen);
|
||||
}
|
||||
};
|
||||
let at_five = {
|
||||
let fired = Rc::clone(&fired);
|
||||
move |ode: &mut ODE<'_, f64, 1, ()>| {
|
||||
let mut seen = fired.get();
|
||||
seen[1] = ode.t;
|
||||
fired.set(seen);
|
||||
}
|
||||
};
|
||||
|
||||
let mut three = FnCallback {
|
||||
event: &|t: f64, _y: &Vector1<f64>, _p: &()| 3.0 - t,
|
||||
effect: &at_three,
|
||||
};
|
||||
let mut five = FnCallback {
|
||||
event: &|t: f64, _y: &Vector1<f64>, _p: &()| 5.0 - t,
|
||||
effect: &at_five,
|
||||
};
|
||||
|
||||
let ode = ODE::new(&unit_rate, 0.0, 10.0, Vector1::new(0.0), ());
|
||||
// One step of 10 would swallow both events whole.
|
||||
let controller = PIController::new(0.17, 0.04, 10.0, 0.2, 100000.0, 0.9, 10.0);
|
||||
let mut problem = if three_first {
|
||||
Problem::new(ode, DormandPrince45::new(), controller)
|
||||
.with_callback(&mut three)
|
||||
.with_callback(&mut five)
|
||||
} else {
|
||||
Problem::new(ode, DormandPrince45::new(), controller)
|
||||
.with_callback(&mut five)
|
||||
.with_callback(&mut three)
|
||||
};
|
||||
problem.solve();
|
||||
fired.get().to_vec()
|
||||
}
|
||||
|
||||
for three_first in [true, false] {
|
||||
let fired = order_of_firings(three_first);
|
||||
assert_relative_eq!(fired[0], 3.0, max_relative = 1e-9);
|
||||
assert_relative_eq!(fired[1], 5.0, max_relative = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
/// An effect that leaves the event function sitting on zero does not
|
||||
/// re-trigger the callback at the same point.
|
||||
#[test]
|
||||
fn test_effect_landing_on_its_own_event_terminates() {
|
||||
// The event is |y| - 1 and the effect negates y, so the event value is
|
||||
// unchanged by the effect: the naive reading is that it has crossed
|
||||
// again and should fire again, at no advance in time.
|
||||
let ode = ODE::new(&unit_rate, 0.0, 3.0, Vector1::new(0.0), ());
|
||||
let dp45 = DormandPrince45::new().a_tol(1e-12).r_tol(1e-10);
|
||||
let mut flip = FnCallback {
|
||||
event: &|_t: f64, y: &Vector1<f64>, _p: &()| y[0].abs() - 1.0,
|
||||
effect: &|ode: &mut ODE<'_, f64, 1, ()>| ode.y = -ode.y,
|
||||
};
|
||||
|
||||
let mut problem =
|
||||
Problem::new(ode, dp45, PIController::default()).with_callback(&mut flip);
|
||||
let solution = problem.solve();
|
||||
|
||||
// y rises to 1 at t = 1 and flips to -1, which the effect leaves on the
|
||||
// event; it rises back to 1 at t = 3 and flips again. Two jumps, neither
|
||||
// of them repeated at the time it happened.
|
||||
let jumps: Vec<f64> = (1..solution.times.len())
|
||||
.filter(|&i| solution.times[i] == solution.times[i - 1])
|
||||
.map(|i| solution.times[i])
|
||||
.collect();
|
||||
assert_eq!(jumps.len(), 2);
|
||||
assert_relative_eq!(jumps[0], 1.0, max_relative = 1e-9);
|
||||
assert_relative_eq!(jumps[1], 3.0, max_relative = 1e-9);
|
||||
assert_relative_eq!(solution.times.last().unwrap(), &3.0, max_relative = 1e-12);
|
||||
assert_relative_eq!(solution.states.last().unwrap()[0], -1.0, max_relative = 1e-8);
|
||||
}
|
||||
|
||||
/// The controller's error history is discarded when an effect changes the
|
||||
/// state, and left alone when it does not.
|
||||
#[test]
|
||||
fn test_controller_is_reset_across_a_jump() {
|
||||
/// Counts the resets `solve` asks for; a `PIController` otherwise.
|
||||
struct Spy {
|
||||
inner: PIController<f64>,
|
||||
resets: Rc<Cell<usize>>,
|
||||
}
|
||||
|
||||
impl Controller<f64> for Spy {
|
||||
fn determine_step(&mut self, h: f64, err: f64) -> TryStep<f64> {
|
||||
self.inner.determine_step(h, err)
|
||||
}
|
||||
|
||||
fn next_step_guess(&self) -> TryStep<f64> {
|
||||
Controller::next_step_guess(&self.inner)
|
||||
}
|
||||
|
||||
fn set_next_step_guess(&mut self, guess: TryStep<f64>) {
|
||||
self.inner.set_next_step_guess(guess)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.resets.set(self.resets.get() + 1);
|
||||
Controller::reset(&mut self.inner);
|
||||
}
|
||||
}
|
||||
|
||||
fn resets_during(effect: &dyn Fn(&mut ODE<'_, f64, 1, ()>)) -> usize {
|
||||
let resets = Rc::new(Cell::new(0));
|
||||
let spy = Spy {
|
||||
inner: PIController::default(),
|
||||
resets: Rc::clone(&resets),
|
||||
};
|
||||
let mut callback = FnCallback {
|
||||
event: &|_t: f64, y: &Vector1<f64>, _p: &()| 5.0 - y[0],
|
||||
effect,
|
||||
};
|
||||
let ode = ODE::new(&unit_rate, 0.0, 10.0, Vector1::new(0.0), ());
|
||||
let mut problem = Problem::new(ode, DormandPrince45::new(), spy)
|
||||
.with_callback(&mut callback);
|
||||
problem.solve();
|
||||
resets.get()
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
resets_during(&|ode: &mut ODE<'_, f64, 1, ()>| ode.y = Vector1::new(100.0)),
|
||||
1
|
||||
);
|
||||
// An effect that changes nothing leaves a smooth solution behind it.
|
||||
assert_eq!(resets_during(&|_ode: &mut ODE<'_, f64, 1, ()>| {}), 0);
|
||||
}
|
||||
|
||||
/// A bouncing ball, which is the standard exercise of repeated jumps: an
|
||||
/// event every time the ball reaches the floor, and an effect that reverses
|
||||
/// and shrinks its velocity.
|
||||
///
|
||||
/// The bounce times are known. A ball dropped from `h0` first lands at
|
||||
/// `sqrt(2 h0 / g)` with speed `v = g t`, and each bounce after that is a
|
||||
/// flight of `2 v / g` at the restitution-scaled speed.
|
||||
#[test]
|
||||
fn test_bouncing_ball() {
|
||||
const G: f64 = 9.81;
|
||||
const RESTITUTION: f64 = 0.8;
|
||||
|
||||
fn free_fall(_t: f64, y: Vector2<f64>, g: &f64) -> Vector2<f64> {
|
||||
Vector2::new(y[1], -g)
|
||||
}
|
||||
|
||||
let ode = ODE::new(&free_fall, 0.0, 2.0, Vector2::new(1.0, 0.0), G);
|
||||
let vern7 = crate::integrator::vern7::Vern7::new().a_tol(1e-12).r_tol(1e-12);
|
||||
let mut bounce = FnCallback {
|
||||
event: &|_t: f64, y: &Vector2<f64>, _p: &f64| y[0],
|
||||
effect: &|ode: &mut ODE<'_, f64, 2, f64>| {
|
||||
ode.y = Vector2::new(0.0, -RESTITUTION * ode.y[1]);
|
||||
},
|
||||
};
|
||||
|
||||
let mut problem =
|
||||
Problem::new(ode, vern7, PIController::default()).with_callback(&mut bounce);
|
||||
let solution = problem.solve();
|
||||
|
||||
// Each bounce is a jump, so it is a repeated time in the solution.
|
||||
let bounces: Vec<(f64, f64)> = (1..solution.times.len())
|
||||
.filter(|&i| solution.times[i] == solution.times[i - 1])
|
||||
.map(|i| (solution.times[i], solution.states[i][1]))
|
||||
.collect();
|
||||
|
||||
// Three of them land before t = 2, and the fourth does not.
|
||||
let mut expected_t = (2.0 / G).sqrt();
|
||||
let mut expected_v = RESTITUTION * G * expected_t;
|
||||
assert_eq!(bounces.len(), 3);
|
||||
for (t, v) in &bounces {
|
||||
assert_relative_eq!(*t, expected_t, max_relative = 1e-9);
|
||||
assert_relative_eq!(*v, expected_v, max_relative = 1e-9);
|
||||
expected_t += 2.0 * expected_v / G;
|
||||
expected_v *= RESTITUTION;
|
||||
}
|
||||
|
||||
// Interpolating inside an arc gives free fall from the bounce that
|
||||
// started it, so the dense output survived the jumps.
|
||||
for &(t, v) in &bounces[..2] {
|
||||
for dt in [1e-3, 0.05, 0.1] {
|
||||
assert_relative_eq!(
|
||||
solution.interpolate(t + dt)[0],
|
||||
v * dt - 0.5 * G * dt * dt,
|
||||
epsilon = 1e-9
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The case the whole feature is for: a rotating rigid body parameterized by
|
||||
/// Modified Rodrigues Parameters, whose parameters are swapped for the
|
||||
/// shadow set before they run into the singularity at a full turn.
|
||||
///
|
||||
/// For a constant angular velocity about one axis the answer is known.
|
||||
/// Starting from zero, the MRP vector is `tan(theta / 4)` along the rotation
|
||||
/// axis, with `theta = |omega| t`, so it reaches magnitude one at half a
|
||||
/// turn. The shadow set `-sigma / |sigma|^2` describes the same attitude and
|
||||
/// obeys the same kinematics, and past half a turn it is the branch that
|
||||
/// stays bounded: `-cot(theta / 4)` along the axis.
|
||||
#[test]
|
||||
fn test_mrp_shadow_set_swap() {
|
||||
/// MRP kinematics, `sigma' = B(sigma) omega / 4`.
|
||||
fn mrp_rate(_t: f64, s: Vector3<f64>, omega: &Vector3<f64>) -> Vector3<f64> {
|
||||
let skew = Matrix3::new(0.0, -s[2], s[1], s[2], 0.0, -s[0], -s[1], s[0], 0.0);
|
||||
let b = Matrix3::identity() * (1.0 - s.dot(&s)) + skew * 2.0 + s * s.transpose() * 2.0;
|
||||
b * omega * 0.25
|
||||
}
|
||||
|
||||
let omega = Vector3::new(0.0, 0.0, 1.0);
|
||||
let t_end = 5.0; // past half a turn, short of a full one
|
||||
let ode = ODE::new(&mrp_rate, 0.0, t_end, Vector3::zeros(), omega);
|
||||
let vern7 = crate::integrator::vern7::Vern7::new().a_tol(1e-12).r_tol(1e-12);
|
||||
|
||||
let mut shadow = FnCallback {
|
||||
event: &|_t: f64, s: &Vector3<f64>, _p: &Vector3<f64>| 1.0 - s.norm(),
|
||||
effect: &|ode: &mut ODE<'_, f64, 3, Vector3<f64>>| {
|
||||
ode.y = -ode.y / ode.y.norm_squared();
|
||||
},
|
||||
};
|
||||
let mut problem =
|
||||
Problem::new(ode, vern7, PIController::default()).with_callback(&mut shadow);
|
||||
let solution = problem.solve();
|
||||
|
||||
// The swap happened at half a turn, and only once. A jump is the one
|
||||
// thing that puts the same time in the solution twice.
|
||||
let jumps: Vec<f64> = (1..solution.times.len())
|
||||
.filter(|&i| solution.times[i] == solution.times[i - 1])
|
||||
.map(|i| solution.times[i])
|
||||
.collect();
|
||||
assert_eq!(jumps.len(), 1);
|
||||
assert_relative_eq!(jumps[0], PI, max_relative = 1e-9);
|
||||
|
||||
// The parameters stayed away from the singularity throughout.
|
||||
assert!(solution.states.iter().all(|s| s.norm() <= 1.0 + 1e-9));
|
||||
|
||||
// And the attitude at the end is the one the body is actually in.
|
||||
let expected = -Vector3::new(0.0, 0.0, 1.0 / (t_end / 4.0).tan());
|
||||
for i in 0..3 {
|
||||
assert_relative_eq!(solution.states.last().unwrap()[i], expected[i], epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_interpolation() {
|
||||
type Params = ();
|
||||
|
||||
Reference in new issue
Block a user