Files
tensor/integrate/odebdfvar_test.go
T

299 lines
12 KiB
Go
Raw 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 (
"math"
"testing"
)
// TestIntegrateBDFVarStiff is the demonstration pin: on y' =
// −10^5(y − cos t) the variable-order driver lands on the exact y(1) =
// (k²·cos 1 + k·sin 1)/(k² + 1) inside half the step budget BDF2
// needed, having raised to order five on the smooth tail.
func TestIntegrateBDFVarStiff(t *testing.T) {
const k = 1e5
var stats BDFVarStats
end, err := IntegrateBDFVar(stiffCosine(k), 0, 1, mustFloats(t, []float64{0}),
BDFVarOptions{MaxSteps: 2000, Stats: &stats})
if err != nil {
t.Fatalf("IntegrateBDFVar: %v", err)
}
want := (k*k*math.Cos(1) + k*math.Sin(1)) / (k*k + 1)
if math.Abs(end.FloatAt(0)-want) > 1e-6 {
t.Fatalf("y(1) = %.14g, want %.14g", end.FloatAt(0), want)
}
t.Logf("stiff run: %d steps, %d rejected, max order %d", stats.Steps, stats.Rejected, stats.MaxOrder)
if stats.MaxOrder != 5 {
t.Fatalf("max order reached = %d, want 5 on the smooth tail", stats.MaxOrder)
}
if stats.Steps > 1000 {
t.Fatalf("the run took %d steps, want well inside the 2000-step budget BDF2 needed", stats.Steps)
}
}
// TestIntegrateBDFVarOrderAdapts instruments the order counter: the
// first accepted steps run at order one (nothing else has history), so
// a run that ends with order five must have climbed the ladder, and on
// the same stiff problem it must spend far fewer steps than an
// order-one-locked run, which is what step and order adaptation buy.
func TestIntegrateBDFVarOrderAdapts(t *testing.T) {
const k = 1e5
var adaptive, locked BDFVarStats
if _, err := IntegrateBDFVar(stiffCosine(k), 0, 1, mustFloats(t, []float64{0}),
BDFVarOptions{MaxSteps: 50000, Stats: &adaptive}); err != nil {
t.Fatalf("IntegrateBDFVar adaptive: %v", err)
}
if _, err := integrateBDFVar("TestIntegrateBDFVarOrderAdapts", stiffCosine(k), 0, 1,
mustFloats(t, []float64{0}), BDFVarOptions{MaxSteps: 50000, Stats: &locked}, 1, true); err != nil {
t.Fatalf("IntegrateBDFVar order-one locked: %v", err)
}
t.Logf("adaptive run: %d steps, locked run: %d steps", adaptive.Steps, locked.Steps)
if adaptive.Steps < 8 || locked.Steps < 8 {
t.Fatalf("implausible step counts: adaptive %d, locked %d", adaptive.Steps, locked.Steps)
}
if adaptive.MaxOrder != 5 {
t.Fatalf("adaptive run reached order %d, want 5", adaptive.MaxOrder)
}
if locked.MaxOrder != 1 {
t.Fatalf("locked run reached order %d, want 1 throughout", locked.MaxOrder)
}
if adaptive.Steps*3 > locked.Steps {
t.Fatalf("the adaptive run took %d steps against the locked run's %d: order adaptation did not engage",
adaptive.Steps, locked.Steps)
}
}
// TestIntegrateBDFVarAccuracy checks the adaptive driver on a smooth
// problem against the analytic decay, over a full oscillator period
// with a two-dimensional state, and backwards in time.
func TestIntegrateBDFVarAccuracy(t *testing.T) {
end, err := IntegrateBDFVar(decay, 0, 1, mustFloats(t, []float64{1}),
BDFVarOptions{RelTol: 1e-8, AbsTol: 1e-12})
if err != nil {
t.Fatalf("IntegrateBDFVar: %v", err)
}
if math.Abs(end.FloatAt(0)-math.Exp(-1)) > 1e-5 {
t.Fatalf("y(1) = %.14g, want %.14g ± 1e-5", end.FloatAt(0), math.Exp(-1))
}
oscillator := func(t float64, y *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
}
full, err := IntegrateBDFVar(oscillator, 0, 2*math.Pi, mustFloats(t, []float64{1, 0}),
BDFVarOptions{RelTol: 1e-8, AbsTol: 1e-12})
if err != nil {
t.Fatalf("IntegrateBDFVar oscillator: %v", err)
}
if math.Abs(full.FloatAt(0)-1) > 1e-4 || math.Abs(full.FloatAt(1)) > 1e-4 {
t.Fatalf("full period = (%.10g, %.10g), want (1, 0)",
full.FloatAt(0), full.FloatAt(1))
}
back, err := IntegrateBDFVar(decay, 1, 0, mustFloats(t, []float64{math.Exp(-1)}),
BDFVarOptions{RelTol: 1e-8, AbsTol: 1e-12})
if err != nil {
t.Fatalf("IntegrateBDFVar backward: %v", err)
}
if math.Abs(back.FloatAt(0)-1) > 1e-5 {
t.Fatalf("backward y(0) = %.14g, want 1 ± 1e-5", back.FloatAt(0))
}
}
// TestBDFVarCoefficientsMatchBDF2 pins the coefficient recurrence: at
// order two the divided-difference form must reproduce the shipped
// bdf2Coefficients, on equal steps and on skewed ones, in α, β and the
// predictor seed alike.
func TestBDFVarCoefficientsMatchBDF2(t *testing.T) {
patterns := []struct{ tNext, t, tNm1, tNm2 float64 }{
{3, 2, 1, 0},
{1.3, 0.75, 0.4, -0.1},
{5, 1, 0.5, -2},
}
vals := []float64{2.5, -3, 7} // y at tNm2, tNm1, t
for _, p := range patterns {
hist := &bdfVarHistory{}
for i, tt := range []float64{p.tNm2, p.tNm1, p.t} {
hist.push(tt, []float64{vals[i]})
}
beta := make([]float64, 1)
seed := make([]float64, 1)
alpha := bdfVarCoefficients(2, p.tNext, hist, beta, seed,
make([]float64, bdfVarKeep), make([]float64, bdfVarKeep), make([]float64, bdfVarKeep))
beta2 := make([]float64, 1)
seed2 := make([]float64, 1)
alpha2 := bdf2Coefficients(p.t, p.tNext, p.tNm1, p.tNm2, []float64{vals[2]},
[]float64{vals[1]}, []float64{vals[0]}, beta2, seed2)
tol := func(v float64) float64 { return 1e-12 * math.Max(1, math.Abs(v)) }
if math.Abs(alpha-alpha2) > tol(alpha2) {
t.Fatalf("pattern %v: alpha = %.16g, bdf2 gives %.16g", p, alpha, alpha2)
}
if math.Abs(beta[0]-beta2[0]) > tol(beta2[0]) {
t.Fatalf("pattern %v: beta = %.16g, bdf2 gives %.16g", p, beta[0], beta2[0])
}
if math.Abs(seed[0]-seed2[0]) > tol(seed2[0]) {
t.Fatalf("pattern %v: seed = %.16g, bdf2 gives %.16g", p, seed[0], seed2[0])
}
}
}
// TestBDFVarMilneConstantMatchesBDF2 pins the variable-step Milne
// constant against the shipped bdf2Milne at order two.
func TestBDFVarMilneConstantMatchesBDF2(t *testing.T) {
patterns := []struct{ tNext, t, tNm1, tNm2 float64 }{
{3, 2, 1, 0},
{1.3, 0.75, 0.4, -0.1},
{5, 1, 0.5, -2},
}
for _, p := range patterns {
hist := &bdfVarHistory{}
for _, tt := range []float64{p.tNm2, p.tNm1, p.t} {
hist.push(tt, []float64{0})
}
alpha := bdfVarCoefficients(2, p.tNext, hist, make([]float64, 1), make([]float64, 1),
make([]float64, bdfVarKeep), make([]float64, bdfVarKeep), make([]float64, bdfVarKeep))
_, tOldest := hist.back(2)
got := 1 / (1 + alpha*(p.tNext-tOldest))
want := bdf2Milne(p.t, p.tNext, p.tNm1, p.tNm2)
if math.Abs(got-want) > 1e-14*math.Max(1, math.Abs(want)) {
t.Fatalf("pattern %v: milne constant %.16g, bdf2Milne gives %.16g", p, got, want)
}
}
}
// TestIntegrateBDFVarFixedOrderLinear pins the exactness of the fixed
// orders on y' = k·t^(k−1), whose solution y = t^k only order k
// reproduces exactly: the k-step formula carries the k-th derivative
// the problem is built from, and any lower order drops it, so each
// locked run must land on 1 at the end AND report that it ran at the
// locked order, which together rule out a hook that silently
// integrates at order 1.
func TestIntegrateBDFVarFixedOrderLinear(t *testing.T) {
for order := 1; order <= 5; order++ {
k := float64(order)
f := func(t float64, y *core.Array) (*core.Array, error) {
out, err := core.Zeros(core.Float, 1)
if err != nil {
return nil, err
}
out.SetFloatAt(0, k*math.Pow(t, k-1))
return out, nil
}
stats := &BDFVarStats{}
end, err := integrateBDFVar("TestIntegrateBDFVarFixedOrderLinear", f, 0, 1,
mustFloats(t, []float64{0}), BDFVarOptions{MaxSteps: 10000, Stats: stats}, order, true)
if err != nil {
t.Fatalf("locked order %d: %v", order, err)
}
if math.Abs(end[0]-1) > 5e-5 {
t.Fatalf("locked order %d: y(1) = %.16g, want 1", order, end[0])
}
if stats.MaxOrder != order {
t.Fatalf("locked order %d ran at max order %d", order, stats.MaxOrder)
}
}
}
// TestBDFVarExactPolynomialPerOrder drives the coefficient recurrence
// directly: a single order-k step from exact history on the degree-k
// polynomial p(t) = t^k must return p at the new time to rounding, on
// skewed steps, because the variable-step formula is exact for degree
// k when the past is exact.
func TestBDFVarExactPolynomialPerOrder(t *testing.T) {
const tNext = 1.3
// Back-value times on skewed step gaps, newest first.
patterns := [][6]float64{
{1, 0.7, 0.35, 0.1, -0.2, -1},
{1, 0.9, 0.75, 0.5, 0.2, -0.1},
}
for order := 1; order <= 5; order++ {
for _, g := range patterns {
times := g[:order+1]
hist := &bdfVarHistory{}
for _, tt := range times {
hist.push(tt, []float64{math.Pow(tt, float64(order))})
}
beta := make([]float64, 1)
seed := make([]float64, 1)
w := &odeWork{}
alpha := bdfVarCoefficients(order, tNext, hist, beta, seed,
make([]float64, bdfVarKeep), make([]float64, bdfVarKeep), make([]float64, bdfVarKeep))
z := make([]float64, 1)
err := odeNewton("TestBDFVarExactPolynomialPerOrder",
func(t float64, y *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{float64(order) * math.Pow(t, float64(order-1))}, 1)
}, w, tNext, alpha, 1, beta, seed, z, 1e-13, 1e-13)
if err != nil {
t.Fatalf("order %d gaps %v: odeNewton: %v", order, times, err)
}
want := math.Pow(tNext, float64(order))
if math.Abs(z[0]-want) > 1e-11*math.Max(1, math.Abs(want)) {
t.Fatalf("order %d gaps %v: z = %.16g, want %.16g to rounding", order, times, z[0], want)
}
}
}
}
// TestIntegrateBDFVarErrors pins the error contract: a degenerate span
// returns the initial state unchanged, a wrong-shaped f, a rank-2
// state, an empty state and an exhausted step budget are errors, and a
// nonsensical order cap is refused.
func TestIntegrateBDFVarErrors(t *testing.T) {
y0 := mustFloats(t, []float64{1})
same, err := integrateBDFVar("TestIntegrateBDFVarErrors", decay, 1, 1, y0, BDFVarOptions{}, 5, false)
if err != nil {
t.Fatalf("zero span: %v", err)
}
if math.Abs(same[0]-1) > 0 {
t.Fatalf("zero span moved the state to %v", same[0])
}
wrongShape := func(t float64, y *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{1, 1}, 2)
}
if _, err := IntegrateBDFVar(wrongShape, 0, 1, y0, BDFVarOptions{}); err == nil {
t.Fatal("expected an error when f returns the wrong shape")
}
matrixState := mustFloats(t, []float64{1, 1}, 1, 2)
if _, err := IntegrateBDFVar(decay, 0, 1, matrixState, BDFVarOptions{}); err == nil {
t.Fatal("expected an error for a rank-2 state")
}
if _, err := IntegrateBDFVar(decay, 0, 1, mustFloats(t, nil), BDFVarOptions{}); err == nil {
t.Fatal("expected an error for an empty state")
}
if _, err := IntegrateBDFVar(decay, 0, 1, y0, BDFVarOptions{MaxSteps: 2}); err == nil {
t.Fatal("expected an error for an exhausted step budget")
}
if _, err := integrateBDFVar("TestIntegrateBDFVarErrors", decay, 0, 1, y0, BDFVarOptions{}, 6, false); err == nil {
t.Fatal("expected an error for an order cap above five")
}
if _, err := integrateBDFVar("TestIntegrateBDFVarErrors", decay, 0, 1, y0, BDFVarOptions{}, 0, false); err == nil {
t.Fatal("expected an error for an order cap below one")
}
boom := func(t float64, y *core.Array) (*core.Array, error) {
if t > 0.5 {
return nil, base.Errf("detector tripped")
}
return core.MulF(y, -1), nil
}
if _, err := IntegrateBDFVar(boom, 0, 1, y0, BDFVarOptions{}); err == nil {
t.Fatal("expected the operator error to propagate")
}
// An f that survives the two probe evaluations and fails on the
// starter's own evaluation is refused at once.
calls := 0
counted := func(t float64, y *core.Array) (*core.Array, error) {
calls++
if calls > 2 {
return nil, base.Errf("detector tripped")
}
return core.FromFloats([]float64{0}, 1)
}
if _, err := IntegrateBDFVar(counted, 0, 1, mustFloats(t, []float64{1}), BDFVarOptions{}); err == nil {
t.Fatal("expected the starter's f failure to surface")
}
}