完善通用求解器回归与前端交互
- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
This commit is contained in:
1 parent
143e8dd309
commit
b435daecf2
65 files changed
+172271
-701
No files matched your search
@@ -0,0 +1,339 @@
|
||||
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()
|
||||
Reference in new issue
Block a user