Skip to content
Open
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
38 changes: 33 additions & 5 deletions src/proteus/interior_energetics/aragog.py
Original file line number Diff line number Diff line change
Expand Up @@ -1966,6 +1966,33 @@ def run_solver(self, hf_row, interior_o, dirs, write_data: bool = True):

return sim_time, output

def _active_solver_name(self) -> str:
"""Name the integrator that is actually running, not the one configured.

``solver_method`` can ask for CVODE and still run scipy: the wrapper
is compiled against SUNDIALS and falls back silently on a build or
ABI mismatch, so trusting the config name mislabels every failure
the fallback produces. Mirrors aragog's own Radau/BDF choice
(entropy_solver.py) so a scipy fallback names the integrator that
ran instead of a generic 'scipy'.

Returns
-------
str
'CVODE' when the configured and available integrator is CVODE,
'BDF' when ``solver_method='bdf'``, 'Radau' otherwise (including
a CVODE fallback, which aragog also resolves to Radau).
"""
try:
from aragog.solver.entropy_solver import _CVODE_AVAILABLE
except ImportError:
_CVODE_AVAILABLE = False

method = str(self._config.interior_energetics.aragog.solver_method or '')
if method == 'cvode' and _CVODE_AVAILABLE:
return 'CVODE'
return 'BDF' if method == 'bdf' else 'Radau'

def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput:
"""Run aragog_solver.solve() with a dt-halving retry ladder.

Expand Down Expand Up @@ -2064,7 +2091,7 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput:
float(hf_row.get('Time', 0.0)),
)

# Status check: did CVODE accept the step?
# Status check: did the solver accept the step?
if out.status == 0:
# Sanity check: reject suspiciously large T_core jumps
# that indicate the solver "succeeded" with garbage.
Expand Down Expand Up @@ -2105,16 +2132,17 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput:
return out

if attempt >= max_attempts:
# status==0 here means CVODE accepted every step but each
# result was rejected for an over-threshold T_core jump, so
# report that reason rather than the misleading status=0.
# status==0 here means the solver accepted every step but
# each result was rejected for an over-threshold T_core
# jump, so report that reason rather than the misleading
# status=0.
if out.status == 0:
reason = (
'status=0 but the T_core jump exceeded the '
f'{sanity_dT_core:.0f} K sanity threshold on every attempt'
)
else:
reason = f'CVODE status={out.status}'
reason = f'{self._active_solver_name()} status={out.status}'
log.error(
'Aragog solver failed after %d attempts (%s). '
'Raising RuntimeError so wrapper can apply skip-step fallback.',
Expand Down
107 changes: 107 additions & 0 deletions tests/interior_energetics/test_aragog.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,3 +760,110 @@ def test_setup_or_update_solver_tracks_stale_structure_steps():
interior_o.structure_stale = True
AragogRunner.setup_or_update_solver(config, hf_row, interior_o, 1.0, dirs)
assert interior_o._stale_struct_steps == 1


@pytest.mark.unit
def test_solve_with_retry_ladder_exhaustion_names_the_solver_that_actually_ran(
monkeypatch,
):
"""A retry-ladder exhaustion names the integrator that actually ran.

``solver_method`` can ask for CVODE and still run scipy: the wrapper is
compiled against SUNDIALS and falls back silently on a build or ABI
mismatch, so trusting the config name mislabels every scipy-fallback
failure as a CVODE one. Covers CVODE available, CVODE unavailable
(silent fallback to Radau), an explicit 'radau', and an explicit 'bdf',
so a mutant that drops the solver_method check or collapses Radau/BDF
into one label fails at least one branch. Each case also asserts
``solve()`` ran once per attempt, so a mutant that breaks the retry loop
itself (wrong attempt count, early exit) fails alongside the label.
"""
from proteus.interior_energetics.aragog import AragogRunner

module_path = 'aragog.solver.entropy_solver'

def _build_runner(status, solver_method='cvode'):
runner = AragogRunner.__new__(AragogRunner)
runner._config = MagicMock()
runner._config.interior_energetics.aragog.solver_method = solver_method
runner._config.planet.mass_tot = 1.0

out = MagicMock()
out.status = status
out.T_core = 0.0

solver = MagicMock()
solver.parameters.solver.start_time = 0.0
solver.parameters.solver.end_time = 1.0
solver.get_current_dSdr_cmb.return_value = None
solver._dSdr_cmb_init = None
solver.get_state.return_value = out
runner.aragog_solver = solver

interior_o = MagicMock()
interior_o._last_entropy = None

hf_row = {'Time': 2.15e5, 'T_cmb': 0.0}
return runner, interior_o, hf_row

max_attempts = 6

monkeypatch.setattr(f'{module_path}._CVODE_AVAILABLE', True)
cvode_runner, cvode_interior_o, cvode_hf_row = _build_runner(status=-1)
with pytest.raises(RuntimeError, match='CVODE status=-1') as cvode_info:
cvode_runner._solve_with_retry(cvode_hf_row, cvode_interior_o)
assert 'Radau status=' not in str(cvode_info.value)
assert 'BDF status=' not in str(cvode_info.value)
assert cvode_runner.aragog_solver.solve.call_count == max_attempts

monkeypatch.setattr(f'{module_path}._CVODE_AVAILABLE', False)
fallback_runner, fallback_interior_o, fallback_hf_row = _build_runner(status=-1)
with pytest.raises(RuntimeError, match='Radau status=-1') as fallback_info:
fallback_runner._solve_with_retry(fallback_hf_row, fallback_interior_o)
assert 'CVODE status=' not in str(fallback_info.value)
assert 'BDF status=' not in str(fallback_info.value)
assert fallback_runner.aragog_solver.solve.call_count == max_attempts

monkeypatch.setattr(f'{module_path}._CVODE_AVAILABLE', True)
radau_runner, radau_interior_o, radau_hf_row = _build_runner(
status=-1, solver_method='radau'
)
with pytest.raises(RuntimeError, match='Radau status=-1') as radau_info:
radau_runner._solve_with_retry(radau_hf_row, radau_interior_o)
assert 'CVODE status=' not in str(radau_info.value)
assert 'BDF status=' not in str(radau_info.value)
assert radau_runner.aragog_solver.solve.call_count == max_attempts

monkeypatch.setattr(f'{module_path}._CVODE_AVAILABLE', True)
bdf_runner, bdf_interior_o, bdf_hf_row = _build_runner(status=-1, solver_method='bdf')
with pytest.raises(RuntimeError, match='BDF status=-1') as bdf_info:
bdf_runner._solve_with_retry(bdf_hf_row, bdf_interior_o)
assert 'CVODE status=' not in str(bdf_info.value)
assert 'Radau status=' not in str(bdf_info.value)
assert bdf_runner.aragog_solver.solve.call_count == max_attempts


@pytest.mark.unit
def test_active_solver_name_falls_back_when_cvode_flag_is_unimportable(monkeypatch):
"""A missing/renamed aragog CVODE flag degrades to a label, not a crash.

``_active_solver_name()`` reaches into aragog's private
``_CVODE_AVAILABLE`` flag. aragog is a separate, actively developed
package that owes that private name no stability guarantee; if it is
ever renamed or removed, the retry ladder's exhaustion path must still
raise the intended ``RuntimeError`` (which the wrapper catches to apply
a skip-step fallback) rather than an uncaught ``ImportError``.
"""
from proteus.interior_energetics.aragog import AragogRunner

monkeypatch.delattr('aragog.solver.entropy_solver._CVODE_AVAILABLE')

runner = AragogRunner.__new__(AragogRunner)
runner._config = MagicMock()
runner._config.interior_energetics.aragog.solver_method = 'cvode'
assert runner._active_solver_name() == 'Radau'

# The missing flag must not leak into or corrupt the 'bdf' branch,
# which never consults _CVODE_AVAILABLE in the first place.
runner._config.interior_energetics.aragog.solver_method = 'bdf'
assert runner._active_solver_name() == 'BDF'
Loading