Files
SystemSimulationApp/tests/test_thermofluid_recovery.py
T
lujingze b435daecf2 完善通用求解器回归与前端交互
- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
2026-08-18 06:42:07 +00:00

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()