Fix ODESolver stopping one step short and composite Simpson tail-drop - #15
Conversation
ODESolver::solve looped `1..steps`, running one iteration too few, so it returned y(x_end - h) instead of y(x_end). The missing step is an O(h) error that dominates the result and reduces every method, including the default RK4, from its nominal order down to first order. solve now runs the full step count and uses a uniform step that lands exactly on x_end, so a step size that does not divide the interval no longer stops short. The composite Simpson's 1/3 and 3/8 rules in NewtonCotes iterate the panels with windows(3).step_by(2) / windows(4).step_by(3), which drop the trailing subinterval(s) whenever the subdivision count is not a multiple of 2 or 3. This silently truncated the integral, including for the default of 1000 subdivisions with Simpson's 3/8. The count is now rounded up to the next valid multiple for the selected rule. Adds order-of-accuracy and endpoint regressions for the solver and exactness regressions for both Simpson rules across even/odd and non-multiple-of-three subdivision counts, and tightens the existing tolerances that were loose enough to mask both bugs.
AzeezDa
left a comment
There was a problem hiding this comment.
Hi @gaoflow,
Thank you very much for catching these errors; I completely missed them!
The changes look good, but I only have a few comments on the documentation and readability. Feel free to fix them yourself, but if you want I can fix them as well.
Otherwise, when my comments are amended, the pull request is approved to be merged 😃!
Thanks again!
PS: Feel free to mention/link to this pull request in the tests you added as you seem to hint at there being an error that was fixed.
| /// Uses cubic interpolation | ||
| SimpsonsThreeEighths, | ||
| } | ||
|
|
There was a problem hiding this comment.
Instead of using the raw panel width immediately in e.g. line 69, I suggest moving the match statement of line 90-94 here as a const function as below, then using that function in the rest of the file (see comments for at line 69 and 90-94).
(Feel free to change the function name and document it.)
| const fn panel_width(formula: &Formula) -> usize { | |
| match formula { | |
| Formula::Trapezium => 1, | |
| Formula::SimpsonsOneThird => 2, | |
| Formula::SimpsonsThreeEighths => 3, | |
| } | |
| } |
Another alternative is to keep the match statement as it is in your original pull request, but instead of writing 1, 2, 3 (in lines 69, 90-94), constants (e.g. const TRAPEZIUM_PANEL_WIDTH: usize = 1;) are instead added to the file and used later on. I have no preference with either of the alternatives.
| f, | ||
| subdivisions: DEFAULT_SUBDIVISIONS, | ||
| formula: Self::simpsons_one_third, | ||
| panel: 2, |
There was a problem hiding this comment.
If the previous comment is implemented, then this line would become:
| panel: 2, | |
| panel: panel_width(&Formula::SimpsonsOneThird) |
| self.panel = match formula { | ||
| Formula::Trapezium => 1, | ||
| Formula::SimpsonsOneThird => 2, | ||
| Formula::SimpsonsThreeEighths => 3, | ||
| }; |
There was a problem hiding this comment.
If the first comment is implemented this line would simply be:
| self.panel = match formula { | |
| Formula::Trapezium => 1, | |
| Formula::SimpsonsOneThird => 2, | |
| Formula::SimpsonsThreeEighths => 3, | |
| }; | |
| self.panel = panel_width(&formula); |
| /// Specify the number of subintervals the integration interval is split in. | ||
| /// | ||
| /// The default is 1000. |
There was a problem hiding this comment.
Since the subdivisions internally will be adjusted according to the formula, I suggest writing this explicitly here to increase transparency for the users.
| /// Specify the number of subintervals the integration interval is split in. | |
| /// | |
| /// When the integrator is run, the number of subintervals will be adjusted to the next nearest | |
| /// multiple that the given formula is expecting. In particular, since the default formula is | |
| /// Simpson's 1/3 Method, the count in that case is adjusted to the next nearest even integer. | |
| /// | |
| /// The default is 1000. |
| x = x + self.h; | ||
| // Take a uniform step that lands exactly on `x_end`, even when the requested | ||
| // step size does not divide the interval evenly. | ||
| let h = interval / T::from(steps).unwrap(); |
There was a problem hiding this comment.
From what I can see, this unwrap should be not be problematic because it would've been caught in the previous unwrap_or and the if-statement under it. Nonetheless, if there is a case where it would fail, I suggest adding the below. (What do you think?)
| let h = interval / T::from(steps).unwrap(); | |
| let h = if let Some(steps_casted) = T::from(steps) { | |
| interval / steps_casted | |
| } else { | |
| return Err(SolverError::TypeConversionError); | |
| }; |
- extract the formula->panel-width match into a const fn and use it in new() and with_formula() - document that with_subdivisions rounds up to the formula's panel multiple - return SolverError::TypeConversionError instead of unwrapping the step cast - reference AzeezDa#15 from the regression tests
|
Thanks — all five are in (
|
|
Thank you! |
Since (#15) fixed an error with the uniformity of the steps taken by the ODESolver's step-process, the step size specified with ODESolver::with_step_size may be adjusted when the solver is run. This change clarifies this in the docstring of the mentioned function.
Two related correctness bugs, both cases where an iteration loop stops short of covering the full integration domain.
ODESolver::solve returns y(x_end - h)
solveloops over1..steps, so it runs one iteration too few and returnsy(x_end - h)instead ofy(x_end). The dropped step is an O(h) error that dominates the result and pulls every method — including the default RK4 — down to first-order accuracy.Halving
hthen only halves the error (first order) where RK4 should cut it by ~16x.solvenow runs the full step count and uses a uniform step that lands exactly onx_end, so a step size that does not divide the interval no longer stops a whole step short either.Composite Simpson rules drop the trailing subinterval(s)
simpsons_one_third(windows(3).step_by(2)) andsimpsons_three_eighths(windows(4).step_by(3)) skip the last subinterval(s) when the subdivision count is not a multiple of 2 or 3. Since the default is 1000, Simpson's 3/8 is wrong out of the box:The count is now rounded up to the next valid multiple for the selected rule. (
AdaptiveNewtonCotesevaluates each rule over a single panel, so it is unaffected.)Notes
solveintegrates tox_endusinground((x_end - x0) / h)uniform steps; for a step size that already divides the interval — all documented usage and anything fromwith_steps— this is unchanged. Simpson may use up to two extra subintervals to reach a valid count.cargo test,cargo fmt -- --check, andcargo clippyall pass.