优化仿真求解性能并修复流量闭合问题(初版)
This commit is contained in:
1 parent
57b459bc72
commit
5332a788f3
55 files changed
+8973
-549
No files matched your search
@@ -0,0 +1,486 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
|
||||
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
|
||||
from app.simulation.components.amesim.mechanical.translational import (
|
||||
AmesimLstp00a,
|
||||
)
|
||||
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||
from app.simulation.components.experimental.storage.tank import Tank
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.solvers.algebraic import (
|
||||
AlgebraicSolveDiagnostics,
|
||||
PressureFlowSolver,
|
||||
)
|
||||
from app.simulation.solvers.algebraic_blocks import (
|
||||
StreamPressureBlockSolver,
|
||||
_BlockSolveAttempt,
|
||||
)
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
def _pnl0002_solver(
|
||||
*,
|
||||
include_unselected_island: bool = False,
|
||||
) -> tuple[PressureFlowSolver, AmesimPnl0002]:
|
||||
medium = IdealGasMedium()
|
||||
left = Cylinder("left", medium, V=0.1, p0=500_000.0)
|
||||
right = Tank("right", medium, V=0.1, p0=100_000.0)
|
||||
pipe = AmesimPnl0002(
|
||||
"pipe",
|
||||
medium,
|
||||
p0=300_000.0,
|
||||
T0=300.0,
|
||||
)
|
||||
network = SimulationNetwork("pnl0002-equation-blocks")
|
||||
for component in (left, right, pipe):
|
||||
network.add_component(component)
|
||||
network.connect("left", "port_b", "pipe", "port_1")
|
||||
network.connect("pipe", "port_2", "right", "port_a")
|
||||
|
||||
if include_unselected_island:
|
||||
isolated = Cylinder("isolated", medium, V=0.2, p0=700_000.0)
|
||||
plug = AmesimPnpl01("isolated_plug")
|
||||
network.add_component(isolated)
|
||||
network.add_component(plug)
|
||||
network.connect("isolated", "port_b", "isolated_plug", "port_1")
|
||||
|
||||
for component in network.dynamic_components():
|
||||
component.refresh_thermodynamic_ports()
|
||||
solver = PressureFlowSolver(network)
|
||||
solver.solve()
|
||||
return solver, pipe
|
||||
|
||||
|
||||
class StreamPressureBlockSolverTests(unittest.TestCase):
|
||||
def test_pnl0002_uses_two_blocks_with_one_shared_component(self) -> None:
|
||||
solver, pipe = _pnl0002_solver()
|
||||
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||
|
||||
self.assertTrue(block_solver.available, block_solver.fallback_reason)
|
||||
self.assertEqual(len(block_solver.blocks), 2)
|
||||
self.assertTrue(
|
||||
all("pipe" in block.scope_components for block in block_solver.blocks)
|
||||
)
|
||||
pipe_component_evaluations = [
|
||||
evaluation
|
||||
for block in block_solver.blocks
|
||||
for evaluation in block.component_evaluations
|
||||
if getattr(evaluation.evaluate, "__self__", None) is pipe
|
||||
]
|
||||
self.assertEqual(len(pipe_component_evaluations), 2)
|
||||
self.assertTrue(
|
||||
{unknown.id for unknown in block_solver.blocks[0].unknowns}.isdisjoint(
|
||||
unknown.id for unknown in block_solver.blocks[1].unknowns
|
||||
)
|
||||
)
|
||||
|
||||
result = block_solver.solve(scale_context=solver.scale_context())
|
||||
|
||||
self.assertFalse(result.used_global_fallback)
|
||||
self.assertEqual(len(result.diagnostics), 1)
|
||||
self.assertAlmostEqual(
|
||||
pipe.port_1.m_flow,
|
||||
pipe.port_mass_flow(
|
||||
pipe.port_1.p,
|
||||
pipe.properties().p,
|
||||
pipe.properties().T,
|
||||
port_name="port_1",
|
||||
),
|
||||
places=12,
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
pipe.port_2.m_flow,
|
||||
pipe.port_mass_flow(
|
||||
pipe.port_2.p,
|
||||
pipe.properties().p,
|
||||
pipe.properties().T,
|
||||
port_name="port_2",
|
||||
),
|
||||
places=12,
|
||||
)
|
||||
|
||||
def test_selected_block_seeding_preserves_every_unselected_unknown(self) -> None:
|
||||
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
|
||||
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||
unselected = tuple(
|
||||
unknown
|
||||
for unknown in solver.unknowns
|
||||
if unknown.component in {"isolated", "isolated_plug"}
|
||||
)
|
||||
for index, unknown in enumerate(unselected, start=1):
|
||||
unknown.write(10_000.0 * index)
|
||||
expected = tuple(unknown.read() for unknown in unselected)
|
||||
|
||||
result = block_solver.solve(scale_context=solver.scale_context())
|
||||
|
||||
self.assertFalse(result.used_global_fallback)
|
||||
self.assertEqual(
|
||||
tuple(unknown.read() for unknown in unselected),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_causal_secondary_pressure_mutation_fuses_to_verified_path(self) -> None:
|
||||
solver, _pipe = _pnl0002_solver()
|
||||
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||
first = block_solver.solve(scale_context=solver.scale_context())
|
||||
self.assertFalse(first.used_global_fallback)
|
||||
expected_pressures = tuple(
|
||||
unknown.read()
|
||||
for unknown in block_solver._selected_unknowns
|
||||
if unknown.variable == "p"
|
||||
)
|
||||
original_seed = block_solver._seed_selected_blocks
|
||||
mutated_pressure = next(
|
||||
unknown
|
||||
for unknown in block_solver._selected_unknowns
|
||||
if unknown.variable == "p"
|
||||
)
|
||||
|
||||
def seed_then_mutate(entry_values):
|
||||
seeded = original_seed(entry_values)
|
||||
mutated_pressure.write(mutated_pressure.read() + 10_000.0)
|
||||
return seeded
|
||||
|
||||
with patch.object(
|
||||
block_solver,
|
||||
"_seed_selected_blocks",
|
||||
side_effect=seed_then_mutate,
|
||||
):
|
||||
result = block_solver.solve(scale_context=solver.scale_context())
|
||||
|
||||
self.assertFalse(result.used_global_fallback)
|
||||
self.assertTrue(result.diagnostics[0].residual_verified_this_solve)
|
||||
actual_pressures = tuple(
|
||||
unknown.read()
|
||||
for unknown in block_solver._selected_unknowns
|
||||
if unknown.variable == "p"
|
||||
)
|
||||
for expected, actual in zip(expected_pressures, actual_pressures):
|
||||
self.assertAlmostEqual(
|
||||
actual,
|
||||
expected,
|
||||
delta=1.0e-12 * max(abs(expected), 1.0),
|
||||
)
|
||||
execution = block_solver.causal_execution_diagnostics()
|
||||
self.assertFalse(execution["enabled"])
|
||||
self.assertEqual(
|
||||
execution["disabledReason"],
|
||||
"causalSecondaryRuntimeGateFailed",
|
||||
)
|
||||
self.assertEqual(execution["legacyFallbackCount"], 1)
|
||||
|
||||
def test_sparse_block_reports_actual_residual_evaluations(self) -> None:
|
||||
solver, pipe = _pnl0002_solver()
|
||||
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||
pipe.port_1.m_flow += 0.01
|
||||
|
||||
with patch.object(
|
||||
block_solver,
|
||||
"_seed_selected_blocks",
|
||||
return_value=None,
|
||||
):
|
||||
result = block_solver.solve(scale_context=solver.scale_context())
|
||||
|
||||
sparse = result.diagnostics[0]
|
||||
self.assertEqual(sparse.jacobian_mode, "blockSparse")
|
||||
self.assertGreater(sparse.evaluations, 0)
|
||||
self.assertGreater(sparse.residual_evaluations, sparse.evaluations)
|
||||
self.assertFalse(sparse.dense_fallback_used)
|
||||
|
||||
def test_failed_sparse_block_restores_its_original_unknowns(self) -> None:
|
||||
solver, pipe = _pnl0002_solver()
|
||||
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||
block = block_solver.blocks[0]
|
||||
pipe.port_1.m_flow += 0.01
|
||||
expected = tuple(unknown.read() for unknown in block.unknowns)
|
||||
|
||||
def failed_sparse(_fun, x0, **_kwargs):
|
||||
return SimpleNamespace(
|
||||
x=x0 + 123.0,
|
||||
success=False,
|
||||
status=-1,
|
||||
message="forced sparse block failure",
|
||||
nfev=1,
|
||||
)
|
||||
|
||||
with patch("scipy.optimize.least_squares", side_effect=failed_sparse):
|
||||
attempt = block_solver._solve_block(
|
||||
block,
|
||||
solver.scale_context(),
|
||||
)
|
||||
|
||||
self.assertIsNone(attempt.diagnostics)
|
||||
self.assertEqual(attempt.optimizer_evaluations, 1)
|
||||
self.assertEqual(
|
||||
tuple(unknown.read() for unknown in block.unknowns),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_block_failure_restores_full_snapshot_before_global_fallback(self) -> None:
|
||||
solver, pipe = _pnl0002_solver(include_unselected_island=True)
|
||||
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||
pipe.port_1.m_flow += 0.01
|
||||
solver.network.components["isolated_plug"].port_1.p = 12_345.0
|
||||
expected = tuple(unknown.read() for unknown in solver.unknowns)
|
||||
original_global_solve = solver.solve
|
||||
|
||||
def checked_global_solve(*args, **kwargs):
|
||||
self.assertEqual(
|
||||
tuple(unknown.read() for unknown in solver.unknowns),
|
||||
expected,
|
||||
)
|
||||
return original_global_solve(*args, **kwargs)
|
||||
|
||||
def failed_block(block, _scale_context, **_kwargs):
|
||||
for unknown in block.unknowns:
|
||||
unknown.write(unknown.read() + 321.0)
|
||||
return _BlockSolveAttempt(
|
||||
diagnostics=None,
|
||||
optimizer_evaluations=2,
|
||||
residual_evaluations=7,
|
||||
failure_reason="blockResidualNotConverged",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
block_solver,
|
||||
"_seed_selected_blocks",
|
||||
return_value=None,
|
||||
), patch.object(
|
||||
block_solver,
|
||||
"_solve_block",
|
||||
side_effect=failed_block,
|
||||
), patch.object(
|
||||
solver,
|
||||
"solve",
|
||||
side_effect=checked_global_solve,
|
||||
) as global_solve:
|
||||
result = block_solver.solve(scale_context=solver.scale_context())
|
||||
|
||||
global_solve.assert_called_once()
|
||||
self.assertTrue(result.used_global_fallback)
|
||||
self.assertEqual(len(result.diagnostics), 1)
|
||||
fallback_diagnostics = result.diagnostics[0]
|
||||
self.assertIn(
|
||||
fallback_diagnostics.jacobian_mode,
|
||||
{
|
||||
"seeded",
|
||||
"sparse",
|
||||
"dense",
|
||||
"sparseThenDense",
|
||||
"blockSparse",
|
||||
},
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
fallback_diagnostics.residual_evaluations,
|
||||
fallback_diagnostics.evaluations,
|
||||
)
|
||||
self.assertGreaterEqual(fallback_diagnostics.evaluations, 2)
|
||||
self.assertGreaterEqual(fallback_diagnostics.residual_evaluations, 7)
|
||||
self.assertTrue(fallback_diagnostics.block_fallback_used)
|
||||
self.assertIn(
|
||||
"blockResidualNotConverged",
|
||||
fallback_diagnostics.block_fallback_reason or "",
|
||||
)
|
||||
|
||||
def test_seed_exception_restores_snapshot_before_global_fallback(self) -> None:
|
||||
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
|
||||
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||
expected = tuple(unknown.read() for unknown in solver.unknowns)
|
||||
original_global_solve = solver.solve
|
||||
|
||||
def broken_seed(_entry_values) -> None:
|
||||
solver.unknowns[0].write(solver.unknowns[0].read() + 123_456.0)
|
||||
raise ValueError("forced seed failure")
|
||||
|
||||
def checked_global_solve(*args, **kwargs):
|
||||
self.assertEqual(
|
||||
tuple(unknown.read() for unknown in solver.unknowns),
|
||||
expected,
|
||||
)
|
||||
return original_global_solve(*args, **kwargs)
|
||||
|
||||
with patch.object(
|
||||
block_solver,
|
||||
"_seed_selected_blocks",
|
||||
side_effect=broken_seed,
|
||||
), patch.object(
|
||||
solver,
|
||||
"solve",
|
||||
side_effect=checked_global_solve,
|
||||
) as global_solve:
|
||||
result = block_solver.solve(scale_context=solver.scale_context())
|
||||
|
||||
global_solve.assert_called_once()
|
||||
self.assertTrue(result.used_global_fallback)
|
||||
|
||||
def test_failed_global_fallback_does_not_leak_candidate_state(self) -> None:
|
||||
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
|
||||
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||
expected = tuple(unknown.read() for unknown in solver.unknowns)
|
||||
|
||||
def failed_block(block, _scale_context, **_kwargs):
|
||||
for unknown in block.unknowns:
|
||||
unknown.write(unknown.read() + 123.0)
|
||||
return _BlockSolveAttempt(
|
||||
diagnostics=None,
|
||||
optimizer_evaluations=1,
|
||||
residual_evaluations=4,
|
||||
failure_reason="blockResidualNotConverged",
|
||||
)
|
||||
|
||||
def failed_global_solve(*_args, **_kwargs):
|
||||
for unknown in solver.unknowns:
|
||||
unknown.write(unknown.read() - 456.0)
|
||||
raise RuntimeError("forced global fallback failure")
|
||||
|
||||
with patch.object(
|
||||
block_solver,
|
||||
"_seed_selected_blocks",
|
||||
return_value=None,
|
||||
), patch.object(
|
||||
block_solver,
|
||||
"_seeded_diagnostics",
|
||||
return_value=None,
|
||||
), patch.object(
|
||||
block_solver,
|
||||
"_solve_block",
|
||||
side_effect=failed_block,
|
||||
), patch.object(
|
||||
solver,
|
||||
"solve",
|
||||
side_effect=failed_global_solve,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"forced global fallback failure",
|
||||
):
|
||||
block_solver.solve(scale_context=solver.scale_context())
|
||||
|
||||
self.assertEqual(
|
||||
tuple(unknown.read() for unknown in solver.unknowns),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_local_to_global_fallback_aggregates_attempt_chain_once(self) -> None:
|
||||
solver, _pipe = _pnl0002_solver()
|
||||
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||
accepted_global = AlgebraicSolveDiagnostics(
|
||||
success=True,
|
||||
message="accepted global result",
|
||||
evaluations=3,
|
||||
pressure_scale=400_000.0,
|
||||
flow_scale=0.01,
|
||||
max_scaled_residual=0.25,
|
||||
max_raw_residual=5.0,
|
||||
residual_evaluations=11,
|
||||
jacobian_mode="dense",
|
||||
)
|
||||
failed_local = _BlockSolveAttempt(
|
||||
diagnostics=None,
|
||||
optimizer_evaluations=2,
|
||||
residual_evaluations=7,
|
||||
failure_reason="blockResidualNotConverged",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
block_solver,
|
||||
"_seeded_diagnostics",
|
||||
return_value=None,
|
||||
), patch.object(
|
||||
block_solver,
|
||||
"_solve_block",
|
||||
return_value=failed_local,
|
||||
), patch.object(
|
||||
solver,
|
||||
"solve",
|
||||
return_value=accepted_global,
|
||||
) as global_solve:
|
||||
result = block_solver.solve(scale_context=solver.scale_context())
|
||||
|
||||
global_solve.assert_called_once()
|
||||
self.assertTrue(result.used_global_fallback)
|
||||
self.assertEqual(len(result.diagnostics), 1)
|
||||
aggregate = result.diagnostics[0]
|
||||
self.assertEqual(aggregate.evaluations, 5)
|
||||
self.assertEqual(aggregate.residual_evaluations, 18)
|
||||
self.assertEqual(aggregate.jacobian_mode, "dense")
|
||||
self.assertEqual(aggregate.max_scaled_residual, 0.25)
|
||||
self.assertTrue(aggregate.block_fallback_used)
|
||||
self.assertEqual(
|
||||
aggregate.block_fallback_reason,
|
||||
"blockResidualNotConverged",
|
||||
)
|
||||
|
||||
def test_failed_global_fallback_restores_lstp_causal_cache_for_base_errors(
|
||||
self,
|
||||
) -> None:
|
||||
class ForcedFatalError(BaseException):
|
||||
pass
|
||||
|
||||
for error_type in (MemoryError, ForcedFatalError):
|
||||
with self.subTest(error_type=error_type.__name__):
|
||||
solver, _pipe = _pnl0002_solver()
|
||||
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||
block_solver.fallback_reason = "forcedUntrustedStructure"
|
||||
contact = AmesimLstp00a(
|
||||
"contact",
|
||||
IdealGasMedium(),
|
||||
gap0=0.0,
|
||||
kcont=1.0e6,
|
||||
rcont=0.0,
|
||||
Pdis=1.0e-6,
|
||||
discContactOption=1.0,
|
||||
)
|
||||
contact.port_1.x = 1.25
|
||||
contact.port_2.x = 1.0
|
||||
contact.port_1.v = 0.5
|
||||
contact.port_2.v = -0.25
|
||||
contact.set_causal_contact(penetration=0.25, force=12.0)
|
||||
solver._causal_contact_components = (contact,)
|
||||
expected_unknowns = tuple(
|
||||
unknown.read() for unknown in solver.unknowns
|
||||
)
|
||||
causal_names = tuple(
|
||||
name
|
||||
for name in vars(contact)
|
||||
if name.startswith("_causal_")
|
||||
)
|
||||
expected_causal = tuple(
|
||||
getattr(contact, name) for name in causal_names
|
||||
)
|
||||
expected_last = solver.last_diagnostics
|
||||
|
||||
def failed_global_solve(*_args, **_kwargs):
|
||||
solver.unknowns[0].write(
|
||||
solver.unknowns[0].read() + 123_456.0
|
||||
)
|
||||
contact.clear_causal_contact()
|
||||
solver.last_diagnostics = None
|
||||
raise error_type("forced global fallback failure")
|
||||
|
||||
with patch.object(
|
||||
solver,
|
||||
"solve",
|
||||
side_effect=failed_global_solve,
|
||||
):
|
||||
with self.assertRaises(error_type):
|
||||
block_solver.solve(scale_context=solver.scale_context())
|
||||
|
||||
self.assertEqual(
|
||||
tuple(unknown.read() for unknown in solver.unknowns),
|
||||
expected_unknowns,
|
||||
)
|
||||
self.assertEqual(
|
||||
tuple(getattr(contact, name) for name in causal_names),
|
||||
expected_causal,
|
||||
)
|
||||
self.assertIs(solver.last_diagnostics, expected_last)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user