Files
tensor/integrate/oderow.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

265 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 Rosenbrock-Wanner workhorse: the four-stage L-stable scheme ROS4
// of Hairer and Wanner, fourth order accurate with an embedded
// third-order solution driving the step control. Where BDF solves each
// step by Newton, a W method puts the Jacobian into the formula
// itself: every stage is one linear solve against the frozen matrix
// (1/(γh))I − J, so a step costs one numerical Jacobian, one LU
// factorisation and four back-substitutions, and no Newton iteration
// ever stalls. The scheme is L-stable: its stability function vanishes
// at infinity, so a step far beyond the transient's time constant
// damps the stiff mode instead of amplifying it.
//
// The stage form is the divided one the standard implementations use.
// With A = (1/(γh))I − J factored once per step, stage one solves
// A·k₁ = f(t, y) and stage i solves A·kᵢ = f(t + cᵢh, yᵢ) +
// (Σⱼ<ᵢ cᵢⱼkⱼ)/h with the stage value yᵢ = y + Σⱼ<ᵢ aᵢⱼkⱼ; the step
// advances by y + Σ bᵢkᵢ and the embedded estimate is Σ êᵢkᵢ. Stage
// four shares its node and its stage value with stage three, so its f
// evaluation carries over and a step costs three f calls. The digits
// are the published ones, cross-checked against the standard
// implementations of the scheme.
//
// Two honest limits of the tableau. The Jacobian enters at (t, y) and
// the tableau's time-derivative weights are left out, because the
// package's f contract carries no partial derivative in t: the fourth
// order therefore holds for autonomous systems (every problem in this
// package's stiff tests is one), while a genuinely time-dependent f
// loses the second-order local terms those weights carry and can
// degrade to first order globally; measured on y' = −y + t with
// uniform steps the global ratios come out near 2, not near 16. And
// the scheme is L-stable but not stiffly accurate: the fourth stage's
// weights differ from the solution weights, the stiff damping coming
// from the stability limit rather than from stage-end agreement.
var (
// rowGamma is the abscissa of the stage solves and the source of
// the L-stability: the stability function's denominator is
// (1 − γz)⁴ and its numerator vanishes at infinity.
rowGamma = 0.57282
// rowNodes are the stage times as multiples of h. The second node
// is the published tableau's c2 = 0.114564 (Hairer and Wanner's
// ROS4); its doubled form 2·gamma appears in some reprints, and the
// digit never enters the arithmetic of an autonomous problem, where
// stage times cancel, so either form integrates identically here.
rowNodes = [4]float64{0, 0.114564, 0.65521686381559, 0.65521686381559}
// rowA holds the stage-value weights a[i][j], the coefficient of
// k_j inside stage i's value.
rowA = [4][3]float64{
{},
{2},
{1.867943637803922, 0.2344449711399156},
{1.867943637803922, 0.2344449711399156, 0},
}
// rowC holds the Jacobian-coupling weights c[i][j], the coefficient
// of k_j inside stage i's right side, divided by h.
rowC = [4][3]float64{
{},
{-7.13761503641231},
{2.580708087951457, 0.6515950076447975},
{-2.137148994382534, -0.3214669691237626, -0.6949742501781779},
}
// rowB advances the solution; rowE carries the embedded third-order
// estimate (the difference between the fourth-order and the
// embedded third-order weights).
rowB = [4]float64{2.255570073418735, 0.2870493262186792, 0.435317943184018, 1.093502252409163}
rowE = [4]float64{-0.2815431932141155, -0.0727619912493892, -0.1082196201495311, -1.093502252409163}
)
// IntegrateROS4 integrates y' = f(t, y) from t0 to t1 with the
// four-stage L-stable Rosenbrock-Wanner scheme ROS4 and returns y(t1).
// The fourth order holds for autonomous systems; a genuinely
// time-dependent f loses the second-order local terms the tableau's
// time-derivative weights would carry (the package's f contract has no
// partial derivative in t), and can degrade to first order globally:
// measured on y' = −y + t with uniform steps the ratios come out near
// 2, not near 16. The adaptive controller still holds its tolerance
// there, at the cost of more steps. The step size follows the embedded
// error under the classic accept-or-shrink control, with the numerical
// Jacobian taken once per step through the same central-difference
// helper the Newton path uses; there is no user Jacobian parameter.
// 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 stage that leaves the
// finite range even as the step shrinks is an error, never a silently
// truncated trajectory.
func IntegrateROS4(f func(t float64, y *core.Array) (*core.Array, error),
t0, t1 float64, y0 *core.Array, opts ODEOptions) (*core.Array, error) {
const name = "IntegrateROS4"
y, err := odeCheck(name, y0, &opts)
if err != nil {
return nil, err
}
h, err := bdf2InitialStep(name, f, t0, t1, y, &opts)
if err != nil {
return nil, err
}
budget := odeBudget{max: opts.MaxSteps}
w := &odeWork{}
t := t0
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)
yEnd, errNorm, nerr := ros4Step(name, f, w, t, y, h, opts.AbsTol, opts.RelTol)
if nerr != nil {
if errors.Is(nerr, errNewtonStalled) {
// The stage matrix closed onto singularity: halve the
// step and retry the same interval, within the budget.
h *= 0.5
continue
}
return nil, nerr
}
if math.IsNaN(errNorm) || math.IsInf(errNorm, 0) {
// A stage left the finite range: shrink hard and retry.
h *= 0.25
continue
}
factor := min(5, max(0.2, 0.9*math.Pow(1/errNorm, 1.0/4)))
if errNorm <= 1 {
copy(y, yEnd)
prevT := t
t += h
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)
}
} else {
// Rejected: retry the same interval with the smaller step.
h *= max(0.2, factor)
}
}
return arrayFromVector(y), nil
}
// ros4Step attempts one ROS4 step of the given size from (t, y) and
// returns the candidate end state, the embedded error norm against the
// mixed absolute and relative tolerance, and a nil error when the
// stages all solved. Every buffer belongs to the workspace, so a step
// allocates nothing of its own: the caller's y is left untouched, and
// the returned slice stays valid until the next step.
func ros4Step(name string, f func(t float64, y *core.Array) (*core.Array, error),
w *odeWork, t float64, y []float64, h, absTol, relTol float64) ([]float64, float64, error) {
n := len(y)
w.use(n)
// One scratch buffer per role: the stage value, the stage right
// side, f's result and the candidate end state are rebuilt at the
// top of their use and read only by it.
ks, stage, rhs, fy, yEnd := w.ks, w.stage, w.rhs, w.fy, w.yEnd
// One numerical Jacobian per step, the house central-difference
// helper the Newton path shares, and one factorisation of I − hγJ,
// which is (1/(γh))I − J scaled by γh: the scale folds into the
// stage right sides instead of the matrix.
jac, jerr := odeJacobian(name, f, t, y, w)
if jerr != nil {
return nil, 0, jerr
}
lu := w.mat
// The step-scaled matrix weight and the stage weight are one
// product each, the same (−h·γ) and (h·γ) groupings the element
// loops evaluated.
hgr := -h * rowGamma
hg := h * rowGamma
for i := range n {
row := lu[i]
for j := range n {
row[j] = hgr * jac[i*n+j]
}
row[i]++
}
w.perm, _ = base.Factor(lu)
if err := base.CheckSingular(name, lu); err != nil {
return nil, 0, base.Errf("%s: %w, singular stage matrix at t=%g", name, errNewtonStalled, t)
}
// Stage one.
out, ferr := odeCall(name, f, t, y, n, &w.views)
if ferr != nil {
return nil, 0, ferr
}
readVector(fy, out)
for i := range n {
rhs[i] = hg * fy[i]
}
odePermuteColumn(rhs, w.perm, w.visited)
base.SolveColumn(lu, rhs)
copy(ks[0], rhs)
// Stages two through four. Stage four repeats stage three's node
// and stage value, so its derivative carries over.
for s := 1; s < 4; s++ {
if s < 3 {
copy(stage, y)
for j := range s {
aj := rowA[s][j]
if aj == 0 {
continue
}
kjs := ks[j]
for i := range n {
stage[i] += aj * kjs[i]
}
}
out, ferr = odeCall(name, f, t+rowNodes[s]*h, stage, n, &w.views)
if ferr != nil {
return nil, 0, ferr
}
readVector(fy, out)
}
for i := range n {
rhs[i] = hg * fy[i]
}
for j := range s {
cj := rowC[s][j]
if cj == 0 {
continue
}
gcj := rowGamma * cj
kjs := ks[j]
for i := range n {
rhs[i] += gcj * kjs[i]
}
}
odePermuteColumn(rhs, w.perm, w.visited)
base.SolveColumn(lu, rhs)
copy(ks[s], rhs)
}
// The embedded pair: the b weights advance, the gap to the
// embedded third-order solution estimates the local error.
errNorm := 0.0
for i := range n {
e, advance := 0.0, 0.0
for s := range 4 {
e += rowE[s] * ks[s][i]
advance += rowB[s] * ks[s][i]
}
yEnd[i] = y[i] + advance
scale := absTol + relTol*math.Max(math.Abs(y[i]), math.Abs(yEnd[i]))
ratio := e / scale
errNorm += ratio * ratio
}
return yEnd, math.Sqrt(errNorm/float64(n)) + 1e-10, nil
}