diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 0fda2ebd..e71f5f4e 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -1324,22 +1324,25 @@ class SolverBaseClass(uw_object): return None - # SNES convergence reasons (PETSc documentation): code -> (NAME, explanation). - # Single source for both get_convergence_diagnostics (formats - # "NAME - explanation") and _warn_on_divergence (uses NAME only). + # SNES convergence reasons: code -> (NAME, explanation). The NAMES are the same + # table as solve_report.REASON_STRINGS, kept here with an explanation string for + # get_convergence_diagnostics (formats "NAME - explanation") and _warn_on_divergence + # (uses NAME only). Both copies are pinned to petsc4py's enum by test_1055 — the + # positive codes here were shifted by one until 2026-07 (there is no code 1, and a + # step-norm stop was reported as CONVERGED_ITS). _convergence_reasons = { # Positive reasons = converged - 1: ("CONVERGED_FNORM_ABS", "||F|| < atol"), - 2: ("CONVERGED_FNORM_RELATIVE", "||F|| < rtol*||F_initial||"), - 3: ("CONVERGED_SNORM_RELATIVE", "||x|| < stol"), - 4: ("CONVERGED_ITS", "Maximum iterations reached"), + 2: ("CONVERGED_FNORM_ABS", "||F|| < atol"), + 3: ("CONVERGED_FNORM_RELATIVE", "||F|| < rtol*||F_initial||"), + 4: ("CONVERGED_SNORM_RELATIVE", "||x|| < stol"), + 5: ("CONVERGED_ITS", "Maximum iterations reached"), # Zero = still iterating (shouldn't see after solve) - 0: ("ITERATING", "Still iterating (unexpected after solve)"), + 0: ("CONVERGED_ITERATING", "Still iterating (unexpected after solve)"), # Negative reasons = diverged -1: ("DIVERGED_FUNCTION_DOMAIN", "Function domain error"), -2: ("DIVERGED_FUNCTION_COUNT", "Too many function evaluations"), -3: ("DIVERGED_LINEAR_SOLVE", "Linear solver failed"), - -4: ("DIVERGED_FNORM_NAN", "||F|| is Not-a-Number"), + -4: ("DIVERGED_FUNCTION_NANORINF", "||F|| is Not-a-Number or infinite"), -5: ("DIVERGED_MAX_IT", "Maximum iterations exceeded"), -6: ("DIVERGED_LINE_SEARCH", "Line search failed"), -7: ("DIVERGED_INNER", "Inner solve failed"), @@ -1347,6 +1350,8 @@ class SolverBaseClass(uw_object): -9: ("DIVERGED_DTOL", "||F|| increased by divtol"), -10: ("DIVERGED_JACOBIAN_DOMAIN", "Jacobian calculation failed"), -11: ("DIVERGED_TR_DELTA", "Trust region delta too small"), + -13: ("DIVERGED_OBJECTIVE_DOMAIN", "Objective function domain error"), + -14: ("DIVERGED_OBJECTIVE_NANORINF", "Objective is Not-a-Number or infinite"), } def _warn_on_divergence(self, phase="solve"): diff --git a/src/underworld3/systems/solve_report.py b/src/underworld3/systems/solve_report.py index 8cdd6358..bc5c5b3f 100644 --- a/src/underworld3/systems/solve_report.py +++ b/src/underworld3/systems/solve_report.py @@ -14,19 +14,23 @@ from dataclasses import dataclass from typing import Optional, Tuple -# PETSc SNESConvergedReason codes -> short names. Mirrors the compact map in -# SolverBaseClass._convergence_reasons; duplicated here so this module imports without the -# Cython solver extension present. +# PETSc SNESConvergedReason codes -> short names. Duplicated here (rather than read from +# petsc4py) so this module imports without the Cython solver extension present -- which +# means it can drift, and it HAD: every positive code was shifted by one, so a solve that +# stopped on the STEP norm (the weakest criterion, and what a stalled plastic solve +# reports) was labelled CONVERGED_ITS, and a genuine residual convergence was labelled +# CONVERGED_SNORM_RELATIVE. There is no code 1. test_1055 now checks this table against +# petsc4py's enum, which is the only thing that can keep a hand-copy honest. REASON_STRINGS = { - 1: "CONVERGED_FNORM_ABS", - 2: "CONVERGED_FNORM_RELATIVE", - 3: "CONVERGED_SNORM_RELATIVE", - 4: "CONVERGED_ITS", - 0: "ITERATING", + 0: "CONVERGED_ITERATING", + 2: "CONVERGED_FNORM_ABS", + 3: "CONVERGED_FNORM_RELATIVE", + 4: "CONVERGED_SNORM_RELATIVE", + 5: "CONVERGED_ITS", -1: "DIVERGED_FUNCTION_DOMAIN", -2: "DIVERGED_FUNCTION_COUNT", -3: "DIVERGED_LINEAR_SOLVE", - -4: "DIVERGED_FNORM_NAN", + -4: "DIVERGED_FUNCTION_NANORINF", -5: "DIVERGED_MAX_IT", -6: "DIVERGED_LINE_SEARCH", -7: "DIVERGED_INNER", @@ -34,6 +38,8 @@ -9: "DIVERGED_DTOL", -10: "DIVERGED_JACOBIAN_DOMAIN", -11: "DIVERGED_TR_DELTA", + -13: "DIVERGED_OBJECTIVE_DOMAIN", + -14: "DIVERGED_OBJECTIVE_NANORINF", } diff --git a/tests/test_1055_solve_report.py b/tests/test_1055_solve_report.py index be3de21d..90d68327 100644 --- a/tests/test_1055_solve_report.py +++ b/tests/test_1055_solve_report.py @@ -145,7 +145,10 @@ def test_resume_is_lossless_and_same_termination(): @pytest.mark.level_1 @pytest.mark.tier_a def test_solve_report_helpers(): - assert reason_string(2) == "CONVERGED_FNORM_RELATIVE" + # 2 is CONVERGED_FNORM_ABS in PETSc. This line previously asserted + # CONVERGED_FNORM_RELATIVE — it pinned the table to itself rather than to the enum, + # which is how the off-by-one survived. See test_snes_reason_table_matches_petsc. + assert reason_string(2) == "CONVERGED_FNORM_ABS" assert reason_string(-5) == "DIVERGED_MAX_IT" assert reason_string(999).startswith("UNKNOWN") assert contraction([50.0, 1e-3, 1e-6, 1e-9]) is not None @@ -261,3 +264,54 @@ def test_ksp_reason_table_matches_petsc(): assert label == f"KSP_{enum_names[code]}", (code, label, enum_names[code]) assert ksp_reason_string(-3) == "KSP_DIVERGED_MAX_IT" assert ksp_reason_string(999).startswith("KSP_UNKNOWN") + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_snes_reason_table_matches_petsc(): + """The SNES table is hand-written for the same reason as the KSP one, and unlike the + KSP one it was never pinned to the enum — so it drifted. Every positive code was + shifted by one: a solve that stopped on the STEP norm (the weakest criterion, and + what a stalled viscoplastic solve reports) was labelled CONVERGED_ITS, while a + genuine residual convergence was labelled CONVERGED_SNORM_RELATIVE. Reading a + difficulty report is how continuation drivers decide whether a station is reachable, + so the labels have to be the real ones.""" + from petsc4py import PETSc + from underworld3.systems.solve_report import REASON_STRINGS, reason_string + + # A code can carry more than one petsc4py spelling (0 is both CONVERGED_ITERATING + # and ITERATING), so collect every alias: picking one via setdefault would pin the + # test to the order vars() happens to yield, which is not a petsc4py guarantee. + enum_names = {} + for name, value in vars(PETSc.SNES.ConvergedReason).items(): + if isinstance(value, int) and not name.startswith("_"): + enum_names.setdefault(value, set()).add(name) + + for code, label in REASON_STRINGS.items(): + assert code in enum_names, f"{code} ({label}) is not a PETSc SNES reason at all" + assert label in enum_names[code], (code, label, sorted(enum_names[code])) + + # Every reason PETSc can return must be nameable — an UNKNOWN_n in a report is a + # gap in the table, and the ones that went missing were real diverged states. + for code in enum_names: + assert code in REASON_STRINGS, ( + f"PETSc reason {code} ({sorted(enum_names[code])}) unmapped") + + assert reason_string(4) == "CONVERGED_SNORM_RELATIVE" + assert reason_string(5) == "CONVERGED_ITS" + assert reason_string(999).startswith("UNKNOWN") + + # The solver carries a SECOND copy of this table (code -> (NAME, explanation)) so + # its diagnostics can add a one-line gloss. It had the identical off-by-one, and + # fixing only one copy would leave the two disagreeing — so pin both to the enum + # and to each other. + from underworld3.systems import Stokes + + solver_table = Stokes._convergence_reasons + for code, (label, _explanation) in solver_table.items(): + assert code in enum_names, f"{code} ({label}) is not a PETSc SNES reason at all" + assert label in enum_names[code], (code, label, sorted(enum_names[code])) + for code in enum_names: + assert code in solver_table, ( + f"PETSc reason {code} ({sorted(enum_names[code])}) unmapped in the solver") + assert {c: n for c, (n, _) in solver_table.items()} == REASON_STRINGS