Skip to content

Commit 8017ba6

Browse files
committed
add tests for singularity
1 parent 5be1d5e commit 8017ba6

File tree

2 files changed

+83
-12
lines changed

2 files changed

+83
-12
lines changed

burnman/optimize/nonlinear_solvers.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import numpy.typing as npt
44
from typing import Any
55
from scipy.linalg import lu_factor, lu_solve
6-
from collections import namedtuple
76
from types import SimpleNamespace
87

98

@@ -200,6 +199,7 @@ def __init__(
200199
self.cond_lu_thresh = cond_lu_thresh
201200
self.cond_lstsq_thresh = cond_lstsq_thresh
202201
self.eps = 2.0 * np.finfo(float).eps
202+
self.max_condition_number = 1.0 / np.finfo(float).eps
203203

204204
assert np.all(
205205
self._constraints(self.guess) < self.eps
@@ -598,8 +598,10 @@ def _termination_info(
598598
n_it: int,
599599
max_iterations: int,
600600
violated_constraints: list[int],
601+
condition_number: float,
601602
) -> tuple[int, str, bool]:
602-
if converged:
603+
singular_jacobian = condition_number > self.max_condition_number
604+
if converged and not singular_jacobian:
603605
return (
604606
True,
605607
0,
@@ -611,7 +613,7 @@ def _termination_info(
611613
1,
612614
f"The function is too non-linear for lower lambda bound ({lmda_bounds[0]})",
613615
)
614-
if persistent_bound_violation:
616+
if persistent_bound_violation and not singular_jacobian:
615617
return (
616618
False,
617619
2,
@@ -622,6 +624,30 @@ def _termination_info(
622624
)
623625
if n_it == max_iterations:
624626
return False, 3, f"The solver reached max_iterations ({max_iterations})"
627+
if singular_jacobian and not converged:
628+
return (
629+
False,
630+
4,
631+
(
632+
f"The system was effectively singular (cond={condition_number:.2e}) "
633+
f"and tried to leave the feasible region at iteration {n_it} by "
634+
"crossing the constraints with the following indices: "
635+
f"{[i for i, lmda in violated_constraints]}.\n"
636+
"You may want to try a different initial guess "
637+
"or check the Jacobian implementation. "
638+
"It may be that the problem is ill-posed."
639+
),
640+
)
641+
if singular_jacobian and converged:
642+
return (
643+
True,
644+
5,
645+
(
646+
f"The solver successfully found a root after {n_it} iterations,"
647+
f"but the system is effectively singular (cond={condition_number:.2e}). "
648+
"You should check the problem formulation before trusting this result."
649+
),
650+
)
625651
raise Exception("Unknown termination of solver")
626652

627653
def solve(self) -> Solution:
@@ -645,10 +671,12 @@ def solve(self) -> Solution:
645671
* 1 -- Failure: lambda reached its minimum bound
646672
* 2 -- Failure: descent direction violates constraints
647673
* 3 -- Failure: maximum iterations reached
674+
* 4 -- Failure: singular system and violated constraints
675+
* 5 -- Successful convergence but singular system
648676
649677
* **text** (str) -- Human-readable termination description.
650678
* **n_it** (int) -- Number of Newton iterations performed.
651-
* **iterates** (namedtuple, optional) -- Present if
679+
* **iterates** (Iterates instance, optional) -- Present if
652680
``store_iterates=True``. Contains iteration history:
653681
654682
* **x** (list[np.ndarray]) -- Solution for each iteration.
@@ -751,14 +779,16 @@ def solve(self) -> Solution:
751779
sol.iterates.append(sol.x, sol.F, lmda)
752780

753781
# Final adjustment for constraints
754-
if converged and not persistent_bound_violation:
755-
sol.x = x_j + dxbar_j
756-
if not np.all(self._constraints(sol.x) <= 0.0):
757-
sol.x -= dxbar_j
758-
759-
sol.F = self.F(sol.x)
760-
sol.F_norm = np.linalg.norm(sol.F, ord=2)
761-
sol.J = self.J(sol.x)
782+
if condition_number < self.max_condition_number:
783+
if converged and not persistent_bound_violation:
784+
sol.x = x_j + dxbar_j
785+
if not np.all(self._constraints(sol.x) <= 0.0):
786+
sol.x -= dxbar_j
787+
788+
sol.F = self.F(sol.x)
789+
sol.F_norm = np.linalg.norm(sol.F, ord=2)
790+
sol.J = self.J(sol.x)
791+
condition_number = np.linalg.cond(sol.J)
762792

763793
sol.success, sol.code, sol.text = self._termination_info(
764794
converged,
@@ -768,6 +798,7 @@ def solve(self) -> Solution:
768798
sol.n_it,
769799
self.max_iterations,
770800
locals().get("violated_constraints", []),
801+
condition_number,
771802
)
772803
return sol
773804

tests/test_equilibration.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,46 @@ def test_ol_wad_eqm_compositional_constraint(self):
244244
[1620.532183457096, 0.45, 0.6791743],
245245
)
246246

247+
def test_convergence_with_singular_system(self):
248+
249+
composition = {"Mg": 1.0, "Fe": 1.0, "Si": 1.0, "O": 4.0}
250+
a = burnman.Composite(
251+
[SLB_2011.mg_fe_olivine(), SLB_2011.mg_fe_olivine()], [0.5, 0.5]
252+
)
253+
254+
a.phases[0].set_composition([0.1, 0.9])
255+
a.phases[1].set_composition([0.9, 0.1])
256+
equality_constraints = [("P", 1.0e5), ("T", 1000.0)]
257+
sol, _ = equilibrate(composition, a, equality_constraints)
258+
self.assertTrue(sol.success) # Expect successful convergence
259+
self.assertTrue(sol.code == 5) # Expect singular system at solution
260+
261+
def test_ill_posed_problem(self):
262+
263+
composition = {"Mg": 1.8, "Fe": 0.2, "Si": 1.0, "O": 4.0}
264+
a = burnman.Composite(
265+
[SLB_2011.mg_fe_olivine(), SLB_2011.mg_fe_olivine()], [0.5, 0.5]
266+
)
267+
268+
a.phases[0].set_composition([0.4, 0.6])
269+
a.phases[1].set_composition([0.5, 0.5])
270+
equality_constraints = [("P", 1.0e5), ("T", 300.0)]
271+
sol, _ = equilibrate(composition, a, equality_constraints)
272+
self.assertTrue(sol.code == 2) # Expect problem to leave the feasible region
273+
274+
def test_ill_conditioned_jacobian(self):
275+
276+
composition = {"Mg": 1.8, "Fe": 0.2, "Si": 1.0, "O": 4.0}
277+
a = burnman.Composite(
278+
[SLB_2011.mg_fe_olivine(), SLB_2011.mg_fe_olivine()], [0.5, 0.5]
279+
)
280+
281+
a.phases[0].set_composition([0.4, 0.6])
282+
a.phases[1].set_composition([0.4, 0.6])
283+
equality_constraints = [("P", 1.0e5), ("T", 300.0)]
284+
sol, _ = equilibrate(composition, a, equality_constraints)
285+
self.assertTrue(sol.code == 4) # Expect singular system
286+
247287

248288
if __name__ == "__main__":
249289
unittest.main()

0 commit comments

Comments
 (0)