Files
tensor/integrate/odedae_test.go
T

268 lines
11 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"
"strings"
"testing"
)
// daeCircuit returns the source, mass matrix and state of a linear
// index-1 circuit: a one-volt source feeds a unit resistor into node
// v1 (unit capacitor to ground), an inductor of one henry carries i
// on to node v2, and node v2 dumps through a unit resistor with no
// capacitor, so its KCL row 0 = i − v2 is the algebraic constraint
// and i the algebraic variable. With C = L = R = 1 the differential
// pair is x' = Ax + (1, 0) with A = [[−1, −1], [1, −1]], whose
// solution is elementary.
func daeCircuit(t float64, y *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{
1 - y.FloatAt(0) - y.FloatAt(2),
y.FloatAt(2) - y.FloatAt(1),
y.FloatAt(0) - y.FloatAt(1),
}, 3)
}
func daeCircuitEnd(t *testing.T, steps int, y0 []float64, t0, t1 float64) []float64 {
t.Helper()
m := mustFloats(t, []float64{1, 0, 0, 0, 0, 0, 0, 0, 1}, 3, 3)
end, err := IntegrateDAE(daeCircuit, m, t0, t1, mustFloats(t, y0), steps, DAEOptions{})
if err != nil {
t.Fatalf("IntegrateDAE: %v", err)
}
return []float64{end.FloatAt(0), end.FloatAt(1), end.FloatAt(2)}
}
// daeCircuitExact evaluates the exact v1, v2, i at time t: the
// equilibrium (0.5, 0.5) plus the elementary homogeneous part.
func daeCircuitExact(t float64) []float64 {
c := math.Exp(-t) / 2
return []float64{
0.5 + c*(math.Cos(t)+math.Sin(t)),
0.5 + c*(math.Sin(t)-math.Cos(t)),
0.5 + c*(math.Sin(t)-math.Cos(t)),
}
}
// TestIntegrateDAECircuit is the linear index-1 pin: the differential
// nodes track the elementary solution, and the algebraic variable i
// satisfies the KCL constraint i = v2 to rounding at the end state,
// because every step enforces the constraint row exactly.
func TestIntegrateDAECircuit(t *testing.T) {
end := daeCircuitEnd(t, 200, []float64{1, 0, 0}, 0, 1)
want := daeCircuitExact(1)
for k, band := range []float64{0.01, 0.01, 0.01} {
if math.Abs(end[k]-want[k]) > band {
t.Fatalf("circuit[%d] = %.14g, want %.14g ± %g", k, end[k], want[k], band)
}
}
if math.Abs(end[1]-end[2]) > 1e-10 {
t.Fatalf("the constraint i = v2 drifted to %g at the end state", end[1]-end[2])
}
}
// TestIntegrateDAEScalarConstraint pins the algebraic variable on the
// exact constraint to rounding: with w' unconstrained by M's zero row
// and 0 = w − cos t, the solved w must equal cos at every step, so
// certainly at the end.
func TestIntegrateDAEScalarConstraint(t *testing.T) {
f := func(t float64, y *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{-y.FloatAt(0), y.FloatAt(1) - math.Cos(t)}, 2)
}
m := mustFloats(t, []float64{1, 0, 0, 0}, 2, 2)
end, err := IntegrateDAE(f, m, 0, 1, mustFloats(t, []float64{1, 1}), 25, DAEOptions{})
if err != nil {
t.Fatalf("IntegrateDAE: %v", err)
}
if math.Abs(end.FloatAt(1)-math.Cos(1)) > 1e-12 {
t.Fatalf("algebraic w(1) = %.16g, want cos(1) = %.16g to rounding",
end.FloatAt(1), math.Cos(1))
}
if math.Abs(end.FloatAt(0)-math.Exp(-1)) > 0.05 {
t.Fatalf("differential u(1) = %.14g, want %.14g ± 0.05", end.FloatAt(0), math.Exp(-1))
}
}
// TestIntegrateDAEBackward integrates the circuit backwards from the
// exact end state; the signed-step formulation must return the start.
func TestIntegrateDAEBackward(t *testing.T) {
want := daeCircuitExact(1)
end := daeCircuitEnd(t, 200, want, 1, 0)
start := daeCircuitExact(0)
for k := range 3 {
if math.Abs(end[k]-start[k]) > 0.01 {
t.Fatalf("backward circuit[%d] = %.14g, want %.14g ± 0.01", k, end[k], start[k])
}
}
}
// TestIntegrateDAEConsistencyRefused pins the initial-residual check:
// a start violating the KCL row by one full unit is refused, with the
// row named.
func TestIntegrateDAEConsistencyRefused(t *testing.T) {
m := mustFloats(t, []float64{1, 0, 0, 0, 0, 0, 0, 0, 1}, 3, 3)
_, err := IntegrateDAE(daeCircuit, m, 0, 1, mustFloats(t, []float64{1, 1, 0}), 200, DAEOptions{})
if err == nil {
t.Fatal("expected an error for an inconsistent start")
}
if !strings.Contains(err.Error(), "row 1") {
t.Fatalf("want the algebraic row named, got %v", err)
}
}
// TestIntegrateDAEPendulumRefused pins the honest index-3 refusal: the
// Cartesian pendulum with multipliers has a mass matrix that admits
// index 1 by rank alone, but its algebraic rows (the constraints) do
// not depend on the algebraic variables (the multipliers) at all, so
// the certified block is singular and the solver refuses, naming the
// detection.
func TestIntegrateDAEPendulumRefused(t *testing.T) {
// y = (x, ypos, u, v, lambda, mu): position, velocity, multipliers.
// m = 1, g = 0, length 1: the unit circle, started at (1, 0) with
// unit tangential speed and the multiplier that holds it there.
f := func(t float64, y *core.Array) (*core.Array, error) {
x, ypos, u, v, lambda := y.FloatAt(0), y.FloatAt(1), y.FloatAt(2), y.FloatAt(3), y.FloatAt(4)
return core.FromFloats([]float64{
u, v,
-2 * x * lambda,
-2 * ypos * lambda,
x*x + ypos*ypos - 1,
x*u + ypos*v,
}, 6)
}
m := mustFloats(t, []float64{
1, 0, 0, 0, 0, 0,
0, 1, 0, 0, 0, 0,
0, 0, 1, 0, 0, 0,
0, 0, 0, 1, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
}, 6, 6)
y0 := mustFloats(t, []float64{1, 0, 0, 1, 0.5, 0})
_, err := IntegrateDAE(f, m, 0, 0.1, y0, 10, DAEOptions{})
if err == nil {
t.Fatal("expected the index-3 pendulum to be refused")
}
if !strings.Contains(err.Error(), "index 1") || !strings.Contains(err.Error(), "singular") {
t.Fatalf("want the index detection stated, got %v", err)
}
}
// TestIntegrateDAEIndexTwoStall pins the other honest refusal: an
// ordinary stiff ODE whose per-step Newton matrix is exactly singular
// at the chosen step (y0' = 100·y0 with h·100 = 1) fails loudly
// through the Newton solve, not through silent drift; the index-1
// certificate itself passes because the algebraic row y1 − y0 does
// depend on the algebraic variable, so the refusal here comes from
// the differential row's pathology and must be named as such.
func TestIntegrateDAEIndexTwoStall(t *testing.T) {
// y0' = 100·y0 with h·100 = 1 makes the Newton matrix singular.
f := func(t float64, y *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{100 * y.FloatAt(0), y.FloatAt(1) - y.FloatAt(0)}, 2)
}
m := mustFloats(t, []float64{1, 0, 0, 0}, 2, 2)
_, err := IntegrateDAE(f, m, 0, 1, mustFloats(t, []float64{1, 1}), 100, DAEOptions{})
if err == nil {
t.Fatal("expected the singular per-step solve to be refused")
}
if !strings.Contains(err.Error(), "Newton") {
t.Fatalf("want a Newton failure, got %v", err)
}
}
// TestIntegrateDAEErrors pins the structural error contract: a zero
// step count, a rank-1 mass matrix of the wrong shape, a nonsingular
// matrix, a rank deficiency without whole zero rows, mismatched zero
// row and column counts, an empty state, a non-finite matrix entry and
// a failing f are all errors; a degenerate span returns the start.
func TestIntegrateDAEErrors(t *testing.T) {
good := mustFloats(t, []float64{1, 0, 0, 0}, 2, 2)
simple := func(t float64, y *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{-y.FloatAt(0), y.FloatAt(1)}, 2)
}
simple3 := func(t float64, y *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{-y.FloatAt(0), y.FloatAt(1), -y.FloatAt(2)}, 3)
}
if _, err := IntegrateDAE(simple, good, 0, 1, mustFloats(t, []float64{1, 1}), 0, DAEOptions{}); err == nil {
t.Fatal("expected an error for zero steps")
}
badShape := mustFloats(t, []float64{1, 0}, 1, 2)
if _, err := IntegrateDAE(simple, badShape, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
t.Fatal("expected an error for a non-square mass matrix")
}
identity := mustFloats(t, []float64{1, 0, 0, 1}, 2, 2)
if _, err := IntegrateDAE(simple, identity, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
t.Fatal("expected an error for a nonsingular mass matrix")
}
noZeroRows := mustFloats(t, []float64{1, 1, 1, 1}, 2, 2)
if _, err := IntegrateDAE(simple, noZeroRows, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
t.Fatal("expected an error for rank deficiency without zero rows")
}
rowColMismatch := mustFloats(t, []float64{1, 1, 0, 0}, 2, 2)
if _, err := IntegrateDAE(simple, rowColMismatch, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
t.Fatal("expected an error for mismatched zero row and column counts")
}
if _, err := IntegrateDAE(simple, good, 0, 1, mustFloats(t, nil), 10, DAEOptions{}); err == nil {
t.Fatal("expected an error for an empty state")
}
nonFinite := mustFloats(t, []float64{1, 0, 0, math.Inf(1)}, 2, 2)
if _, err := IntegrateDAE(simple, nonFinite, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
t.Fatal("expected an error for a non-finite mass matrix entry")
}
complexM, _ := core.FromComplexes([]complex128{1, 0, 0, 1}, 2, 2)
if _, err := IntegrateDAE(simple, complexM, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
t.Fatal("expected an error for a complex mass matrix")
}
// One zero row but a second dependent row: the rank deficiency
// exceeds the zero rows and the contract is refused.
hiddenDeficiency := mustFloats(t, []float64{1, 1, 0, 1, 1, 0, 0, 0, 0}, 3, 3)
_, err := IntegrateDAE(simple3, hiddenDeficiency, 0, 1, mustFloats(t, []float64{1, 1, 1}), 10, DAEOptions{})
if err == nil || !strings.Contains(err.Error(), "rank deficiency") {
t.Fatalf("expected the hidden rank deficiency to be refused, got %v", err)
}
// A degenerate span answers the validated start unchanged. The
// start must satisfy the algebraic row of this system, y1 = 0.
same, err := IntegrateDAE(simple, good, 1, 1, mustFloats(t, []float64{1, 0}), 10, DAEOptions{})
if err != nil {
t.Fatalf("zero span: %v", err)
}
if same.FloatAt(0) != 1 || same.FloatAt(1) != 0 {
t.Fatalf("zero span moved the state to (%v, %v)", same.FloatAt(0), same.FloatAt(1))
}
boom := func(t float64, y *core.Array) (*core.Array, error) {
if t > 0.5 {
return nil, base.Errf("detector tripped")
}
return core.FromFloats([]float64{-y.FloatAt(0), y.FloatAt(1)}, 2)
}
if _, err := IntegrateDAE(boom, good, 0, 1, mustFloats(t, []float64{1, 0}), 100, DAEOptions{}); err == nil {
t.Fatal("expected the operator error to propagate")
}
// An f failing on the initial evaluation, the initial Jacobian and
// inside the first Newton iteration is refused at once.
always := func(t float64, y *core.Array) (*core.Array, error) {
return nil, base.Errf("detector tripped")
}
if _, err := IntegrateDAE(always, good, 0, 1, mustFloats(t, []float64{1, 0}), 10, DAEOptions{}); err == nil {
t.Fatal("expected an error for an f that always fails")
}
// An f that only tolerates the exact seed fails when the Newton
// iteration perturbs the state for its numerical Jacobian.
touchy := func(t float64, y *core.Array) (*core.Array, error) {
if y.FloatAt(0) != 1 {
return nil, base.Errf("detector tripped")
}
return core.FromFloats([]float64{1 - y.FloatAt(0), y.FloatAt(1) - y.FloatAt(0)}, 2)
}
if _, err := IntegrateDAE(touchy, good, 0, 1, mustFloats(t, []float64{1, 0}), 10, DAEOptions{}); err == nil {
t.Fatal("expected the Jacobian perturbation to trip the f error")
}
}