Files

332 lines
11 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 "math"
// Differential-algebraic equations in the semi-implicit mass-matrix
// form M·y' = f(t, y) with a singular M whose rank deficiency sits in
// whole zero rows (and, by the same counts, whole zero columns). The
// differential rows carry the implicit Euler scheme, the algebraic
// rows are constraints the implicit relation enforces at every step,
// and the coupled nonlinear system of each step is solved by Newton
// over the numerical Jacobian and the library's dense LU, the
// machinery the ODE stiff solvers already carry.
//
// The honest contract. The solver is first order and takes equal
// steps, the DAE twin of IntegrateBackwardEuler. Consistent initial
// values are the caller's contract: the solver verifies the initial
// residual on the algebraic rows and refuses when it sits beyond the
// tolerance, naming the row, but it does not project a general start
// onto the constraint manifold (a consistent initialiser is a
// documented follow-up). A second-order formula is not offered yet
// either: BDF2 keeps index 1 only when the algebraic start satisfies
// the constraint to second order, which again needs the missing
// projection step. And the index is certified at t0: the solver factors
// the Jacobian of the algebraic rows against the algebraic variables
// and refuses when it is singular, which is the textbook refutation of
// the index-1 contract and is exactly what catches the Cartesian
// pendulum with multipliers, an index-3 system whose per-step solves
// would otherwise converge while the trajectory drifted.
// DAEOptions tunes IntegrateDAE. RelTol ≤ 0 means 1e-6, AbsTol ≤ 0
// means 1e-9, the ODEOptions defaults; they size the Newton tolerance
// of the per-step solves.
type DAEOptions struct {
RelTol float64
AbsTol float64
}
// IntegrateDAE integrates the mass-matrix differential-algebraic
// system M·y' = f(t, y) from t0 to t1 over the given number of equal
// implicit Euler steps and returns y(t1). The mass matrix must be
// square and singular, with its rank deficiency carried by whole zero
// rows matched by whole zero columns; the zero rows are the algebraic
// constraints and the zero columns the algebraic variables. Backward
// integration works: a t1 < t0 simply integrates in the negative
// direction. A wrong-shaped state or matrix, a nonsingular matrix, a
// rank deficiency outside whole zero rows, an inconsistent initial
// residual on an algebraic row, a singular algebraic block at t0 (the
// index-1 refutation), or a Newton iteration that cannot converge is
// an error, never a silently truncated trajectory.
func IntegrateDAE(f func(t float64, y *core.Array) (*core.Array, error),
m *core.Array, t0, t1 float64, y0 *core.Array, steps int, opts DAEOptions) (*core.Array, error) {
const name = "IntegrateDAE"
if steps <= 0 {
return nil, base.Errf("%s: steps must be ≥ 1, got %d", name, steps)
}
y, err := odeCheck(name, y0, nil)
if err != nil {
return nil, err
}
n := len(y)
mat, err := daeMassMatrix(name, m, n)
if err != nil {
return nil, err
}
relTol, absTol := opts.RelTol, opts.AbsTol
if relTol <= 0 {
relTol = 1e-6
}
if absTol <= 0 {
absTol = 1e-9
}
rows, cols, err := daeAlgebraicSets(name, mat)
if err != nil {
return nil, err
}
// The initial residual on the algebraic rows is the constraint the
// steps will enforce; a start that violates it has no trajectory.
fy0, err := odeEval(name, f, t0, y, n, nil)
if err != nil {
return nil, err
}
for _, row := range rows {
residual := fy0[row]
limit := 100 * (absTol + relTol*math.Abs(residual))
if math.Abs(residual) > limit {
return nil, base.Errf("%s: the initial residual on algebraic row %d is %g, beyond the consistency tolerance %g: consistent initial values are the caller's contract",
name, row, residual, limit)
}
}
// The index-1 certificate: the algebraic rows must determine the
// algebraic variables, which is the nonsingularity of their
// Jacobian block. A singular block means the mass-matrix rank
// alone admitted index 1 but the system behaves above it.
w := &odeWork{}
jac, err := odeJacobian(name, f, t0, y, w)
if err != nil {
return nil, err
}
block := make([][]float64, len(rows))
for i, r := range rows {
block[i] = make([]float64, len(cols))
for j, c := range cols {
block[i][j] = jac[r*n+c]
}
}
base.Factor(block)
if err := base.CheckSingular(name, block); err != nil {
return nil, base.Errf("%s: %w: the Jacobian of the algebraic rows against the algebraic variables is singular at t=%g, the system behaves above index 1",
name, err, t0)
}
h := (t1 - t0) / float64(steps)
myn := make([]float64, n)
// One Newton result buffer serves every step: daeNewton overwrites
// it fully before the step copies it into the state, so no step
// allocates its own.
zbuf := make([]float64, n)
t := t0
if odeArrived(t, t1) {
return arrayFromVector(y), nil
}
// The step times come from the exact grid t0 + i·h, never from an
// accumulated t += h: the addition's rounding walks over a long
// run, while each grid point carries a single rounding that stays
// put.
for i := range steps {
tNext := t0 + float64(i+1)*h
daeMatVec(myn, mat, y)
if nerr := daeNewton(name, f, w, mat, tNext, h, myn, y, zbuf, absTol, relTol); nerr != nil {
return nil, nerr
}
copy(y, zbuf)
}
return arrayFromVector(y), nil
}
// daeMassMatrix reads the mass matrix into a dense float64 row-major
// form and validates its shape against the state length.
func daeMassMatrix(name string, m *core.Array, n int) ([][]float64, error) {
if m.Dtype() == core.Complex {
return nil, base.Errf("%s: complex mass matrices are not supported", name)
}
shape := m.Shape()
if m.NDim() != 2 || shape[0] != n || shape[1] != n {
return nil, base.Errf("%s: the mass matrix must be square of the state's length %d, got shape %s",
name, n, base.ShapeText(shape))
}
mat := make([][]float64, n)
for i := range n {
mat[i] = make([]float64, n)
for j := range n {
v := m.FloatAt(i*n + j)
if math.IsNaN(v) || math.IsInf(v, 0) {
return nil, base.Errf("%s: the mass matrix carries the non-finite entry %g at row %d, column %d", name, v, i, j)
}
mat[i][j] = v
}
}
return mat, nil
}
// daeAlgebraicSets returns the algebraic row and column indices: the
// zero rows and the zero columns of the mass matrix, verified to be
// equally numerous and to carry the whole rank deficiency.
func daeAlgebraicSets(name string, mat [][]float64) ([]int, []int, error) {
n := len(mat)
worst := 0.0
for i := range n {
for j := range n {
worst = math.Max(worst, math.Abs(mat[i][j]))
}
}
tiny := worst * 1e-14
var rows, cols []int
for i := range n {
zero := true
for j := range n {
if math.Abs(mat[i][j]) > tiny {
zero = false
break
}
}
if zero {
rows = append(rows, i)
}
}
for j := range n {
zero := true
for i := range n {
if math.Abs(mat[i][j]) > tiny {
zero = false
break
}
}
if zero {
cols = append(cols, j)
}
}
if len(rows) == 0 {
return nil, nil, base.Errf("%s: the mass matrix has no zero rows, want a singular mass matrix", name)
}
if len(rows) != len(cols) {
return nil, nil, base.Errf("%s: the mass matrix carries %d zero rows and %d zero columns, want equal counts for the semi-explicit index-1 form",
name, len(rows), len(cols))
}
if rank := daeRank(mat, worst*1e-12); rank != n-len(rows) {
return nil, nil, base.Errf("%s: the mass matrix carries rank deficiency outside whole zero rows (rank %d with %d zero rows), which the index-1 contract refutes",
name, rank, len(rows))
}
return rows, cols, nil
}
// daeRank counts the pivots Gaussian elimination with partial pivoting
// leaves above the tolerance.
func daeRank(mat [][]float64, tol float64) int {
n := len(mat)
a := make([][]float64, n)
for i := range n {
a[i] = cloneDenseSlice(mat[i])
}
rank, row := 0, 0
for col := 0; col < n && row < n; col++ {
piv := row
for i := row + 1; i < n; i++ {
if math.Abs(a[i][col]) > math.Abs(a[piv][col]) {
piv = i
}
}
if math.Abs(a[piv][col]) <= tol {
continue
}
a[piv], a[row] = a[row], a[piv]
for i := row + 1; i < n; i++ {
f := a[i][col] / a[row][col]
for j := col; j < n; j++ {
a[i][j] -= f * a[row][j]
}
}
rank++
row++
}
return rank
}
// daeMatVec writes the product m·x into dst.
func daeMatVec(dst []float64, m [][]float64, x []float64) {
for i := range dst {
s := 0.0
for j, v := range m[i] {
s += v * x[j]
}
dst[i] = s
}
}
// daeNewton solves the implicit Euler step equation M·z − M·y_n =
// h·f(tNext, z) for z by Newton over a numerical Jacobian and the
// library's LU solver, the mass-matrix twin of odeNewton. The
// converged state lands in dst, which must not alias seed: a driver
// that hands one buffer down every step allocates it once per run.
// The Jacobian is frozen from the seed and rebuilt twice when
// convergence drags; convergence is measured on the residual against
// the error scale the caller integrates to, an order of magnitude
// below it, but never below the floating-point floor of the
// residual's own terms. An iteration that outlives twenty rounds, or
// a singular Newton matrix, surfaces as errNewtonStalled; an f that
// fails is the fatal error it is.
func daeNewton(name string, f func(t float64, y *core.Array) (*core.Array, error),
w *odeWork, m [][]float64, tNext, h float64, myn, seed, dst []float64,
absTol, relTol float64) error {
n := len(seed)
w.use(n)
z := dst
copy(z, seed)
mz := w.mz
for iteration := range 20 {
daeMatVec(mz, m, z)
out, err := odeCall(name, f, tNext, z, n, &w.views)
if err != nil {
return err
}
readVector(w.fzs, out)
worst, terms := 0.0, 0.0
for i := range n {
w.g[i] = mz[i] - h*w.fzs[i] - myn[i]
worst = math.Max(worst, math.Abs(w.g[i]))
terms = math.Max(terms, math.Abs(mz[i])+math.Abs(h*w.fzs[i])+math.Abs(myn[i]))
}
limit := math.Max(0.1*(absTol+relTol*normInfOfStep(z)), 8*base.EpsF*terms)
if worst <= limit {
return nil
}
if iteration == 0 || iteration == 4 || iteration == 10 {
jac, jerr := odeJacobian(name, f, tNext, z, w)
if jerr != nil {
return jerr
}
// Newton matrix M − h·J, a fresh LU for the frozen
// Jacobian; the iterations that follow only substitute.
// Every row is rebuilt entry by entry before the
// factorisation reads it.
for i := range n {
row := w.mat[i]
for j := range n {
row[j] = m[i][j] - h*jac[i*n+j]
}
}
w.perm, _ = base.Factor(w.mat)
if err := base.CheckSingular(name, w.mat); err != nil {
return base.Errf("%s: %w, singular Newton matrix at t=%g",
name, errNewtonStalled, tNext)
}
}
for i := range n {
w.col[i] = -w.g[i]
}
odePermuteColumn(w.col, w.perm, w.visited)
base.SolveColumn(w.mat, w.col)
for i := range n {
z[i] += w.col[i]
}
}
return base.Errf("%s: %w at t=%g", name, errNewtonStalled, tNext)
}