from __future__ import annotations from time import perf_counter import unittest from unittest.mock import patch from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 from app.simulation.components.amesim.flow.pipes import AmesimPnl00r from app.simulation.components.experimental.storage.cylinder import Cylinder from app.simulation.core.errors import RecoverableTrialStateError from app.simulation.core.medium import IdealGasMedium from app.simulation.solvers.algebraic import ( AlgebraicSolveDiagnostics, AlgebraicSolveError, ) from app.simulation.solvers.algebraic_blocks import StreamBlockSolveResult from app.simulation.solvers.solver import SolveIVPConfig from app.simulation.solvers.stream import StreamSolveDiagnostics, StreamSolveError from app.simulation.solvers.thermofluid import ( ThermofluidClosureError, ThermofluidTransactionPlan, ) from app.simulation.systems.generic import GenericFluidSystem from app.simulation.systems.network import SimulationNetwork def _pnl00r_system() -> tuple[GenericFluidSystem, AmesimPnl00r]: medium = IdealGasMedium() source = Cylinder( "source", medium, V=0.02, p0=500_000.0, T0=310.0, ) resistance = AmesimPnl00r("resistance", medium) resistance._causal_test_seed = {"accepted": 7.0} plug = AmesimPnpl01("plug") network = SimulationNetwork("thermofluid-rollback") for component in (source, resistance, plug): network.add_component(component) network.connect("source", "port_b", "resistance", "port_1") network.connect("resistance", "port_2", "plug", "port_1") return GenericFluidSystem(network), resistance def _physical_values(system: GenericFluidSystem) -> tuple[float, ...]: return tuple( float(getattr(binding.state, binding.variable)) for binding in system._thermofluid_transaction_plan.port_value_bindings ) class ThermofluidRecoveryTests(unittest.TestCase): def test_nonconvergence_is_recoverable_diagnostic_and_fully_restored(self) -> None: system, resistance = _pnl00r_system() state = system.consistent_initial_state_vector() system.rhs(0.01, state) secondary = system._thermofluid_closure_plan.secondary_block_solvers[0] target = resistance.port_1 expected_ports = _physical_values(system) expected_connected_h = dict(resistance._connected_h) expected_causal_seed = dict(resistance._causal_test_seed) expected_owner_diagnostics = tuple( owner.last_diagnostics for owner in system._thermofluid_transaction_plan.diagnostic_owners ) expected_last_algebraic = system._last_algebraic_diagnostics expected_last_scope = system._last_algebraic_scope expected_last_success = ( system._thermofluid_closure_diagnostics.as_dict()["lastSuccess"] ) fake_diagnostics = AlgebraicSolveDiagnostics( success=True, message="forced divergent thermofluid pass", evaluations=0, pressure_scale=1.0, flow_scale=1.0, max_scaled_residual=0.0, max_raw_residual=0.0, ) call_count = 0 def divergent_solve(*, scale_context=None): del scale_context nonlocal call_count call_count += 1 target.m_flow += 1.0 resistance._connected_h = { "port_1": -float(call_count), "port_2": -2.0 * float(call_count), } resistance._causal_test_seed["polluted"] = float(call_count) return StreamBlockSolveResult( diagnostics=(fake_diagnostics,), scopes=( system._thermofluid_closure_plan.secondary_component_groups[0], ), used_global_fallback=False, ) with patch.object(secondary, "solve", side_effect=divergent_solve): with self.assertRaises(ThermofluidClosureError) as raised: system.rhs(0.125, state) error = raised.exception self.assertIsInstance(error, RecoverableTrialStateError) failure = error.diagnostics self.assertEqual(call_count, 25) self.assertEqual(failure.failed_rhs_time, 0.125) self.assertEqual(failure.iterations, 25) self.assertEqual(failure.failure_count, 1) self.assertEqual(len(failure.delta_tail), 8) self.assertEqual( [item.iteration for item in failure.delta_tail], list(range(18, 26)), ) self.assertEqual(failure.max_delta, 1.0) self.assertEqual(failure.scale, 25.0) self.assertEqual(failure.tolerance, 25.0e-12) self.assertIsNotNone(failure.worst_port) assert failure.worst_port is not None self.assertEqual(failure.worst_port.component, "resistance") self.assertEqual(failure.worst_port.port, "port_1") self.assertEqual(failure.worst_port.signed_delta, 1.0) self.assertEqual(_physical_values(system), expected_ports) self.assertEqual(resistance._connected_h, expected_connected_h) self.assertEqual(resistance._causal_test_seed, expected_causal_seed) self.assertEqual( tuple( owner.last_diagnostics for owner in system._thermofluid_transaction_plan.diagnostic_owners ), expected_owner_diagnostics, ) self.assertIs(system._last_algebraic_diagnostics, expected_last_algebraic) self.assertEqual(system._last_algebraic_scope, expected_last_scope) self.assertTrue(system.pressure_flow_solver._causal_audit_required) self.assertTrue(secondary._causal_audit_required) outcomes = system._thermofluid_closure_diagnostics.as_dict() self.assertEqual(outcomes["failureCount"], 1) self.assertEqual(outcomes["lastSuccess"], expected_last_success) self.assertEqual(outcomes["lastFailure"], failure.as_dict()) # A successful maintenance/postprocessing closure must not masquerade # as the last successful integrator RHS. system._close_current_state(0.5) self.assertEqual( system._thermofluid_closure_diagnostics.as_dict()["lastSuccess"], expected_last_success, ) # The restored seed is safe to replay at the failed time. retried = system.rhs(0.125, state) self.assertEqual(len(retried), len(state)) outcomes = system._thermofluid_closure_diagnostics.as_dict() self.assertEqual(outcomes["failureCount"], 1) self.assertEqual(outcomes["lastFailure"], failure.as_dict()) self.assertEqual(outcomes["lastSuccess"]["rhsTime"], 0.125) def test_simulation_result_exposes_closure_and_transaction_diagnostics(self) -> None: system, _resistance = _pnl00r_system() result = system.simulate( SolveIVPConfig( t_start=0.0, t_stop=1.0e-4, method="RK45", max_step=1.0e-4, ), sample_step=1.0e-4, ) self.assertTrue(result.success) closure = result.diagnostics["stream"]["thermofluidClosure"] self.assertEqual(closure["failureCount"], 0) self.assertIsNone(closure["lastFailure"]) self.assertIsNotNone(closure["lastSuccess"]) self.assertEqual( closure["transaction"]["physicalPortValueSlotCount"], 20, ) self.assertEqual( closure["transaction"]["physicalFlowPortCount"], 4, ) def test_eventless_generic_simulation_retries_a_recoverable_trial(self) -> None: system, _resistance = _pnl00r_system() self.assertFalse(system.mechanical_state_reducer.has_state_events) self.assertEqual(system.signal_resolver.event_times(0.0, 1.0e-4), ()) original_rhs = system.rhs failed_once = False def fail_first_positive_trial(time, state): nonlocal failed_once if time > 0.0 and not failed_once: failed_once = True raise RecoverableTrialStateError("forced recoverable trial") return original_rhs(time, state) with patch.object(system, "rhs", side_effect=fail_first_positive_trial): result = system.simulate( SolveIVPConfig( t_start=0.0, t_stop=1.0e-4, method="RK45", max_step=1.0e-4, ), sample_step=1.0e-4, ) self.assertTrue(failed_once) self.assertTrue(result.success) totals = result.diagnostics["integration"]["totals"] self.assertEqual(totals["recoverableRetryCount"], 1) self.assertEqual(totals["stateTransitionCount"], 0) segment = result.diagnostics["integration"]["segments"][0] self.assertEqual(len(segment["recoverableRetries"]), 1) self.assertEqual( segment["recoverableRetries"][0]["reason"], "forced recoverable trial", ) def test_other_closure_failures_restore_but_remain_nonrecoverable(self) -> None: fake_algebraic_diagnostics = AlgebraicSolveDiagnostics( success=False, message="forced algebraic failure", evaluations=1, pressure_scale=1.0, flow_scale=1.0, max_scaled_residual=2.0, max_raw_residual=2.0, ) for failure_kind in ("stream", "secondaryAlgebraic"): with self.subTest(failure_kind=failure_kind): system, resistance = _pnl00r_system() state = system.consistent_initial_state_vector() system.rhs(0.01, state) expected_ports = _physical_values(system) expected_cache = dict(resistance._connected_h) expected_success = ( system._thermofluid_closure_diagnostics.as_dict()[ "lastSuccess" ] ) def pollute() -> None: resistance.port_1.p = -9.0 resistance.port_1.m_flow = 99.0 resistance.port_1.h_outflow = -999.0 resistance._connected_h = { "port_1": -1.0, "port_2": -2.0, } if failure_kind == "stream": def failed_stream(*, dynamic_ports_are_current=False): del dynamic_ports_are_current pollute() diagnostics = StreamSolveDiagnostics( converged=False, iterations=100, max_delta=1.0, ) raise StreamSolveError("forced stream failure", diagnostics) context = patch.object( system.stream_resolver, "solve", side_effect=failed_stream, ) expected_error = StreamSolveError else: secondary = ( system._thermofluid_closure_plan.secondary_block_solvers[0] ) def failed_secondary(*, scale_context=None): del scale_context pollute() raise AlgebraicSolveError( "forced secondary failure", fake_algebraic_diagnostics, scope_kind="physicalIsland", ) context = patch.object( secondary, "solve", side_effect=failed_secondary, ) expected_error = AlgebraicSolveError with context: with self.assertRaises(expected_error) as raised: system.rhs(0.25, state) self.assertNotIsInstance( raised.exception, RecoverableTrialStateError, ) self.assertEqual(_physical_values(system), expected_ports) self.assertEqual(resistance._connected_h, expected_cache) outcomes = system._thermofluid_closure_diagnostics.as_dict() self.assertEqual(outcomes["failureCount"], 0) self.assertIsNone(outcomes["lastFailure"]) self.assertEqual(outcomes["lastSuccess"], expected_success) def test_transaction_microbenchmark_stays_scoped_to_numeric_slots(self) -> None: medium = IdealGasMedium() network = SimulationNetwork("transaction-microbenchmark") for index in range(120): network.add_component(AmesimPnl00r(f"resistance_{index}", medium)) plan = ThermofluidTransactionPlan.compile(network) diagnostics = plan.diagnostics() self.assertEqual(diagnostics["physicalPortValueSlotCount"], 1_200) self.assertEqual(diagnostics["streamAndCausalCacheSlotCount"], 120) for _ in range(5): plan.capture().restore() repetitions = 200 started = perf_counter() for _ in range(repetitions): plan.capture().restore() seconds_per_transaction = (perf_counter() - started) / repetitions # This deliberately generous guard detects accidental full component/ # network deepcopy while remaining stable on slow CI workers. self.assertLess(seconds_per_transaction, 0.01) if __name__ == "__main__": unittest.main()