feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user