340 lines
14 KiB
Python
340 lines
14 KiB
Python
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()
|