Files
tensor/integrate/odedae_test.go
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

268 lines
11 KiB
Go
Raw Permalink 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 (
"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")
}
}