diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index cbd00702a..44d47f541 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -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. @@ -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. @@ -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.', diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 01870698e..52a062a7c 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -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'