Files

415 lines
15 KiB
Go
Raw Permalink Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package integrate
import (
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
import (
"errors"
"math"
)
// The variable-order stiff workhorse above IntegrateBDF2: the backward
// differentiation formula of order one through five, with the step size
// and the order both adapted every step in the VODE manner. Each step
// interpolates a polynomial of degree k through the k most recent
// states and the unknown end value and requires its derivative at the
// new time to equal f, the same implicit relation BDF2 solves; the
// Newton iteration, the LU machinery, the Hairer-Nørsett-Wanner initial
// step probe and the step controller are the ones IntegrateBDF2
// already carries.
//
// The coefficients are the variable-step, divided-difference form: the
// Newton form of the interpolating polynomial through (tNext, z) and
// the stored back values, written per component from a small divided-
// difference table over the stored times. The form was chosen over the
// fixed-coefficient one because the package keeps a solution history
// rather than a Nordsieck array, because the divided differences feed
// the order selection (the a-priori error estimate per candidate order
// falls out of the same table) and because the relation leaves the
// Newton contract α·z − h·f(tNext, z) = β of odeNewton untouched. At
// order two with equal steps the assembled α and β agree with
// bdf2Coefficients to rounding, so the shipped BDF2 behaviour is the
// special case the driver degrades to.
//
// The local error estimate is the Milne-type one: the gap between the
// corrector and the degree-k predictor extrapolated from the k+1
// newest states, scaled by the constant that turns the gap into the
// corrector's own error. The variable-step constant generalises the
// 2/11 of bdf2Milne: with α the derivative weight of the new point and
// S the span from tNext to the oldest predictor node, the estimate is
// (z − seed)/(1 + α·S), which for equal steps of order two reproduces
// 2/11 exactly. The order itself is chosen before the solve, from the
// divided differences of the stored states: the (k+1)-th divided
// difference approximates y^(k+1)/(k+1)!, and the candidate whose
// implied optimal step is largest wins, with a margin so the order
// does not flicker between neighbours.
//
// The first step is backward Euler, sized by the shared probe; the
// order ramps up as the history accumulates, one level per step.
// BDFVarStats reports what a variable-order run did: the accepted and
// rejected steps and the highest order the driver reached.
type BDFVarStats struct {
Steps int
Rejected int
MaxOrder int
}
// BDFVarOptions tunes IntegrateBDFVar. RelTol ≤ 0 means 1e-6, AbsTol ≤ 0
// means 1e-9, MaxSteps ≤ 0 means 100000, the ODEOptions defaults. Stats,
// when not nil, receives the run's counters.
type BDFVarOptions struct {
RelTol float64
AbsTol float64
MaxSteps int
Stats *BDFVarStats
}
// bdfVarOrderMax is the highest order the driver raises to. bdfVarKeep
// is the number of states held back: order k needs k back values for
// its corrector, k+1 for its predictor and k+2 for the a-priori order
// comparison, so seven states serve order five in every role.
const (
bdfVarOrderMax = 5
bdfVarKeep = bdfVarOrderMax + 2
)
// IntegrateBDFVar integrates y' = f(t, y) from t0 to t1 with the
// variable-step, variable-order BDF scheme of orders one through five
// and returns y(t1). Backward integration works: a t1 < t0 simply
// integrates in the negative direction. An exhausted step budget, a
// collapsed step size, an f that returns a wrongly shaped state, or a
// Newton iteration that cannot converge even as the step shrinks is an
// error, never a silently truncated trajectory.
func IntegrateBDFVar(f func(t float64, y *core.Array) (*core.Array, error),
t0, t1 float64, y0 *core.Array, opts BDFVarOptions) (*core.Array, error) {
end, err := integrateBDFVar("IntegrateBDFVar", f, t0, t1, y0, opts, bdfVarOrderMax, false)
if err != nil {
return nil, err
}
return arrayFromVector(end), nil
}
// integrateBDFVar drives the variable-order loop. maxOrder caps the
// order adaptation and lockOrder pins the order at maxOrder once the
// history ramp reaches it, which is the fixed-order hook the tests
// drive; the public entry always asks for adaptive order five.
func integrateBDFVar(name string, f func(t float64, y *core.Array) (*core.Array, error),
t0, t1 float64, y0 *core.Array, opts BDFVarOptions, maxOrder int, lockOrder bool) ([]float64, error) {
if maxOrder < 1 || maxOrder > bdfVarOrderMax {
return nil, base.Errf("%s: maxOrder must be between 1 and %d, got %d", name, bdfVarOrderMax, maxOrder)
}
y, err := odeCheck(name, y0, nil)
if err != nil {
return nil, err
}
relTol, absTol, maxSteps := opts.RelTol, opts.AbsTol, opts.MaxSteps
if relTol <= 0 {
relTol = 1e-6
}
if absTol <= 0 {
absTol = 1e-9
}
if maxSteps <= 0 {
maxSteps = 100000
}
n := len(y)
h, err := bdf2InitialStep(name, f, t0, t1, y, &ODEOptions{RelTol: relTol, AbsTol: absTol})
if err != nil {
return nil, err
}
var stats BDFVarStats
hist := &bdfVarHistory{}
hist.push(t0, y)
// The implicit relation's right side, the predictor seed and the
// divided-difference workspace live in reused buffers: all are fully
// rewritten at the top of every step. The Newton result lands in a
// per-solve scratch buffer that never touches the history window:
// the order selection and the coefficient assembly reread the whole
// window on a retry, so a rejected attempt must leave every stored
// state intact. Only an accepted step copies the state into the
// ring slot its push then occupies.
beta := make([]float64, n)
seed := make([]float64, n)
zbuf := make([]float64, n)
dd := make([]float64, bdfVarKeep)
nodes := make([]float64, bdfVarKeep)
spans := make([]float64, bdfVarKeep)
ddTab := make([][]float64, bdfVarKeep)
for level := range ddTab {
ddTab[level] = make([]float64, n)
}
budget := odeBudget{max: maxSteps}
w := &odeWork{}
t := t0
carried := 1
for !odeArrived(t, t1) {
if err := budget.spend(name, t, t1); err != nil {
return nil, err
}
// Never step past t1; t1−t carries the integration direction.
h = odeClampStep(h, t, t1)
tNext := t + h
hN := tNext - t
var alpha, weight float64
var order int
estimated := false
if hist.n == 1 {
// The very first step has no history and runs backward
// Euler, seeded with the semi-implicit prediction: the
// house starter IntegrateBDF2 begins with.
alpha, weight, order = 1, hN, 1
copy(beta, y)
fy, ferr := odeEval(name, f, tNext, y, n, &w.views)
if ferr != nil {
return nil, ferr
}
for i := range n {
seed[i] = y[i] + hN*fy[i]
}
} else {
order = min(carried, maxOrder, hist.n-1)
switch {
case lockOrder && hist.n > maxOrder:
// The fixed-order contract: once the history ramp can
// feed the requested order, every step runs at it.
order = maxOrder
case !lockOrder && hist.n >= 3:
bdfVarDividedDifferences(hist, n, ddTab, dd, nodes)
order = bdfVarPickOrder(order, maxOrder, hist.n, tNext, h, hist, y, absTol, relTol, ddTab, spans)
}
weight = 1
alpha = bdfVarCoefficients(order, tNext, hist, beta, seed, dd, nodes, spans)
estimated = true
}
if nerr := odeNewton(name, f, w, tNext, alpha, weight, beta, seed, zbuf, absTol, relTol); nerr != nil {
if errors.Is(nerr, errNewtonStalled) {
// The implicit solve struggled: halve the step and
// retry the same interval, within the step budget.
h *= 0.5
continue
}
return nil, nerr
}
factor := 1.0
if estimated {
// Milne-type local error estimate against the mixed
// absolute and relative tolerance.
_, tOldest := hist.back(order)
c := 1 / (1 + alpha*(tNext-tOldest))
errNorm := 0.0
for i := range n {
scale := absTol + relTol*math.Max(math.Abs(y[i]), math.Abs(zbuf[i]))
ratio := c * (zbuf[i] - seed[i]) / scale
errNorm += ratio * ratio
}
errNorm = math.Sqrt(errNorm/float64(n)) + 1e-10
if errNorm <= 1 {
factor = min(2, max(0.2, 0.9*math.Pow(1/errNorm, 1/float64(order+1))))
} else {
// Rejected: retry the same interval with a smaller step.
stats.Rejected++
h *= max(0.1, min(1, 0.9*math.Pow(1/errNorm, 1/float64(order+1))))
continue
}
}
// Accepted: the corrector is copied into the ring slot the push
// fills and becomes the working state, so back(0) is always
// (t, y) and the buffers flow without copying.
slot := hist.y[hist.next]
if slot == nil {
slot = make([]float64, n)
}
copy(slot, zbuf)
hist.push(tNext, slot)
y = slot
carried = order
if order > stats.MaxOrder {
stats.MaxOrder = order
}
stats.Steps++
prevT := t
t = tNext
h *= factor
// Collapse is "t did not move": a span below the absolute time
// scale is integrable, and an accepted step that arrives at the
// end exactly is not a failure either.
if t == prevT {
return nil, base.Errf("%s: the step size shrank below the resolution of t at t=%g", name, prevT)
}
}
if opts.Stats != nil {
*opts.Stats = stats
}
return y, nil
}
// bdfVarHistory holds the last bdfVarKeep accepted states with their
// times in a fixed ring. back(0) is the newest state, back(1) the one
// before it, and so on; slots are recycled only once they are too old
// to serve any order, so the buffers flow without copying.
type bdfVarHistory struct {
y [bdfVarKeep][]float64
t [bdfVarKeep]float64
next int
n int
}
// push records an accepted state and its time as the new newest entry.
func (h *bdfVarHistory) push(t float64, y []float64) {
h.y[h.next], h.t[h.next] = y, t
h.next = (h.next + 1) % bdfVarKeep
if h.n < bdfVarKeep {
h.n++
}
}
// back returns the state i steps behind the newest one.
func (h *bdfVarHistory) back(i int) ([]float64, float64) {
j := (h.next - 1 - i + bdfVarKeep) % bdfVarKeep
return h.y[j], h.t[j]
}
// bdfVarCoefficients assembles the variable-step BDF relation of the
// given order for a step to tNext from the newest history state. It
// writes α's companions β and the Newton seed into the caller's
// buffers and returns α: the implicit equation is α·z − f(tNext, z) =
// β, the weight already scaled out. Both buffers are fully overwritten.
// The seed is the degree-order polynomial through the order+1 newest
// states evaluated at tNext, the predictor the error estimate reads.
// All differences are signed, so backward integration needs no
// separate path.
//
// The construction is the divided-difference (Newton) form: with nodes
// x_0 = tNext and x_q = the q-th back time, the interpolating
// polynomial's derivative at tNext is Σ_j c_j·Π_j where c_j are the
// divided differences of the data (z at x_0, the back values after)
// and Π_j the Newton basis products. Splitting c_j into its z part,
// 1/Π_j, and its history part gives α = Σ 1/(tNext − x_m), the Lagrange
// derivative weight of the new point, and β from the history-only
// table, all from one per-component recursion.
func bdfVarCoefficients(order int, tNext float64, hist *bdfVarHistory,
beta, seed, dd, nodes, spans []float64) float64 {
nodes[0] = tNext
// The ring's nodes and value slices are the same for every
// component: gather both once, on the stack, instead of walking the
// ring inside the per-element loop.
var backVals [bdfVarKeep][]float64
for q := range order + 1 {
backVals[q], nodes[q+1] = hist.back(q)
}
// spans[m] is Π_m, the product of tNext − x_q over q < m: the
// Newton basis value the level-m coefficients multiply.
spans[0] = 1
for m := 1; m <= order; m++ {
spans[m] = spans[m-1] * (tNext - nodes[m])
}
alpha := 0.0
for m := 1; m <= order; m++ {
alpha += 1 / (tNext - nodes[m])
}
for i := range beta {
// dd[q] starts as the value at node q: zero at tNext, the back
// values after. One level of the recursion per Newton term;
// level order leaves dd[0] holding the order-th divided
// difference over the new point and dd[1] the one over the
// stored values, which is the predictor's top coefficient.
dd[0] = 0
for q := range order + 1 {
dd[q+1] = backVals[q][i]
}
seed[i] = dd[1]
betaSum := 0.0
for level := 1; level <= order; level++ {
for q := range order + 2 - level {
dd[q] = (dd[q+1] - dd[q]) / (nodes[q+level] - nodes[q])
}
betaSum += dd[0] * spans[level-1]
seed[i] += dd[1] * spans[level]
}
beta[i] = -betaSum
}
return alpha
}
// bdfVarDividedDifferences fills tab with the divided differences of
// the stored back values alone: tab[level][i] is the level-th divided
// difference of (y_n, y_{n-1}, …) over their times for component i.
// The (order+1)-th entry approximates y^(order+1)/(order+1)! and is
// what the a-priori order comparison reads.
func bdfVarDividedDifferences(hist *bdfVarHistory, n int, tab [][]float64, dd, times []float64) {
// The ring's times and value slices do not depend on the component:
// gather both once, on the stack, instead of walking the ring
// inside the per-element loops.
var backVals [bdfVarKeep][]float64
for q := range hist.n {
backVals[q], times[q] = hist.back(q)
}
for i := range n {
for q := range hist.n {
dd[q] = backVals[q][i]
}
for level := 1; level < hist.n; level++ {
for q := range hist.n - level {
dd[q] = (dd[q+1] - dd[q]) / (times[q+level] - times[q])
}
tab[level][i] = dd[0]
}
}
}
// bdfVarPickOrder returns the order for the coming step. Every order
// the history supports gets an a-priori optimal step: the local error
// the divided differences predict, raised to the power that would
// bring it to the tolerance. The scan runs from order 1 upward and a
// candidate must beat the running best by a clear margin, so the
// effective pick is the lowest order within 15 percent of the largest
// predicted step: short histories and cheap coefficients win near
// ties, and the order settles instead of flickering between equals.
// The carried order survives the scan only before a second state is
// held; after that some candidate always displaces it.
func bdfVarPickOrder(carried, maxOrder, held int, tNext, h float64, hist *bdfVarHistory, y []float64,
absTol, relTol float64, tab [][]float64, spans []float64) int {
best, bestH := carried, 0.0
for j := 1; j <= min(maxOrder, held-1); j++ {
hj := math.Abs(h)
if j <= held-2 {
e := bdfVarPriorNorm(j, tNext, hist, y, absTol, relTol, tab, spans)
hj = math.Abs(h) * math.Pow(1/e, 1/float64(j+1))
}
if hj > bestH*1.15 {
best, bestH = j, hj
}
}
return best
}
// bdfVarPriorNorm estimates the RMS error norm a step of size h at the
// given order would produce: the (order+1)-th divided difference of
// the stored states approximates y^(order+1)/(order+1)!, and the
// order's local error scales that by the Newton basis product over α,
// the same estimate the Milne constant formalises a posteriori.
func bdfVarPriorNorm(order int, tNext float64, hist *bdfVarHistory, y []float64,
absTol, relTol float64, tab [][]float64, spans []float64) float64 {
spans[0] = 1
alpha := 0.0
for q := range order {
_, tq := hist.back(q)
spans[q+1] = spans[q] * math.Abs(tNext-tq)
alpha += 1 / math.Abs(tNext-tq)
}
w := spans[order] / alpha
norm := 0.0
for i := range y {
scale := absTol + relTol*math.Abs(y[i])
ratio := math.Abs(tab[order+1][i]) * w / scale
norm += ratio * ratio
}
return math.Sqrt(norm/float64(len(y))) + 1e-10
}