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
23 changes: 14 additions & 9 deletions src/underworld3/cython/petsc_generic_snes_solvers.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -1324,29 +1324,34 @@ 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"),
-8: ("DIVERGED_LOCAL_MIN", "Local minimum reached"),
-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"):
Expand Down
24 changes: 15 additions & 9 deletions src/underworld3/systems/solve_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,32 @@
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",
-8: "DIVERGED_LOCAL_MIN",
-9: "DIVERGED_DTOL",
-10: "DIVERGED_JACOBIAN_DOMAIN",
-11: "DIVERGED_TR_DELTA",
-13: "DIVERGED_OBJECTIVE_DOMAIN",
-14: "DIVERGED_OBJECTIVE_NANORINF",
}


Expand Down
56 changes: 55 additions & 1 deletion tests/test_1055_solve_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading