Handle singular Newton matrices in the Anderson solver by milanofthe · Pull Request #241 · pathsim/pathsim · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 113 additions & 10 deletions src/pathsim/optim/anderson.py
76 changes: 75 additions & 1 deletion tests/pathsim/optim/test_anderson.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
from pathsim.optim.anderson import (
Anderson,
NewtonAnderson,
solve_root
solve_root,
_factorize,
_singular_step
)


Expand Down Expand Up @@ -238,6 +240,78 @@ def test_warmstart_converges_immediately(self):
_, _, it = solve_root(NewtonAnderson(), func, x, tolerance=1e-12)
self.assertEqual(it, 0)

class TestSingularNewtonMatrix(unittest.TestCase):
"""
Test the handling of a singular Newton matrix.

The Newton matrix 'jac - I' is structurally singular whenever a state's
derivative does not depend on that state: a 'PID's integrator has
'dx/dt = u', so its Jacobian row is zero. 'scipy.linalg.lu_factor' does not
raise on that, it warns and leaves a zero pivot, after which 'lu_solve'
returns inf/nan. Those propagate into the block state and only surface many
iterations later as an SVD failure inside the anderson least squares.
"""

def test_factorize_rejects_singular(self):
"""a singular matrix yields no factorization"""

self.assertIsNone(_factorize(np.array([[-50.0, 0.0], [0.0, 0.0]])))
self.assertIsNone(_factorize(np.zeros((2, 2))))
self.assertIsNone(_factorize(np.zeros((0, 0))))


def test_factorize_accepts_regular(self):
"""a regular matrix factorizes and solves correctly"""

from scipy.linalg import lu_solve

A = np.array([[-50.0, 0.0], [0.0, -2.0]])
lu = _factorize(A)

self.assertIsNotNone(lu)
self.assertTrue(np.allclose(lu_solve(lu, np.array([1.0, 4.0])), [-0.02, -2.0]))


def test_singular_step_is_finite(self):
"""the correction stays finite where 'lu_solve' would give inf/nan"""

A = np.array([[-50.0, 0.0], [0.0, 0.0]])
dx = _singular_step(A, np.array([1.0, 0.382]))

self.assertTrue(np.all(np.isfinite(dx)))

#newton on the range, no correction on the null space
self.assertAlmostEqual(dx[0], 1.0 / -50.0, places=12)
self.assertAlmostEqual(dx[1], 0.0, places=12)


def test_newton_step_survives_singular_jacobian(self):
"""the vector path does not produce inf/nan"""

opt = NewtonAnderson()
x = np.array([0.0, 0.0])
g = np.array([1.0, 0.382])
jac = np.eye(2) + np.array([[-50.0, 0.0], [0.0, 0.0]])

y, res = opt._newton(x, g, jac)

self.assertTrue(np.all(np.isfinite(y)), f"got {y}")
self.assertTrue(np.isfinite(res))


def test_newton_step_survives_zero_scalar_jacobian(self):
"""the scalar path does not divide by zero

'jac - 1 == 0' is the scalar face of the same singularity, and an
'Integrator' ('dx/dt = u') hits it exactly.
"""

opt = NewtonAnderson()
y, res = opt._newton(np.array([0.0]), np.array([1.0]), np.array([[1.0]]))

self.assertTrue(np.all(np.isfinite(y)), f"got {y}")
self.assertTrue(np.isfinite(res))


# RUN TESTS LOCALLY ====================================================================

Expand Down
94 changes: 94 additions & 0 deletions tests/pathsim/test_simulation.py
Loading