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.orifices import ( AmesimPnor001, AmesimPnvo001SignalOpening, ) from app.simulation.components.amesim.flow.pipes import AmesimPnl00r from app.simulation.components.amesim.flow.pipes import AmesimPnl0001 from app.simulation.components.amesim.flow.pipes import AmesimPnl0002 from app.simulation.components.amesim.junctions.nodes import AmesimPn3Node2 from app.simulation.components.amesim.media.mediums import ( AmesimHeliumPengRobinsonMedium, ) from app.simulation.components.amesim.storage.chambers import AmesimPnch023 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.core.state import VolumeState from app.simulation.solvers.algebraic import AlgebraicSolveError, PressureFlowSolver from app.simulation.systems.generic import GenericFluidSystem from app.simulation.systems.network import SimulationNetwork class PressureFlowSolverInitializationTests(unittest.TestCase): def test_pnor_pnl0001_series_pressure_is_seeded_by_flow_balance(self) -> None: medium = IdealGasMedium() chamber = AmesimPnch023("source", medium, p0=15.3e6) source_plug = AmesimPnpl01("source_closed") orifice = AmesimPnor001("orifice", medium) pipe = AmesimPnl0001("pipe", medium, p0=14.0e6) storage_plug = AmesimPnpl01("storage_closed") network = SimulationNetwork("pnor-pnl0001-series") for component in (chamber, source_plug, orifice, pipe, storage_plug): network.add_component(component) network.connect("source_closed", "port_1", "source", "port_1") network.connect("source", "port_2", "orifice", "port_1") network.connect("orifice", "port_2", "pipe", "port_1") network.connect("pipe", "port_2", "storage_closed", "port_1") chamber.refresh_thermodynamic_ports() pipe.refresh_thermodynamic_ports() solver = PressureFlowSolver(network) result = solver.solve() self.assertTrue(result.success) self.assertGreater(orifice.port_2.p, pipe.port_2.p) self.assertLess(orifice.port_2.p, chamber.port_2.p) self.assertAlmostEqual(orifice.port_2.p, pipe.port_1.p) self.assertAlmostEqual(-orifice.port_2.m_flow, pipe.port_1.m_flow) self.assertFalse(solver.causal_fast_path_eligible) self.assertEqual( solver.causal_execution_diagnostics()["fallbackReason"], "specialSeriesPressureSeed", ) def test_pnvo_pnl0001_series_pressure_is_seeded_by_flow_balance(self) -> None: medium = IdealGasMedium() chamber = AmesimPnch023("source", medium, p0=15.3e6) source_plug = AmesimPnpl01("source_closed") valve = AmesimPnvo001SignalOpening( "valve", medium, opening0=1.0, ) valve.res.signal = 1.0 pipe = AmesimPnl0001("pipe", medium, p0=14.0e6) storage_plug = AmesimPnpl01("storage_closed") network = SimulationNetwork("pnvo-pnl0001-series") for component in (chamber, source_plug, valve, pipe, storage_plug): network.add_component(component) network.connect("source_closed", "port_1", "source", "port_1") network.connect("source", "port_2", "valve", "port_3") network.connect("valve", "port_2", "pipe", "port_1") network.connect("pipe", "port_2", "storage_closed", "port_1") chamber.refresh_thermodynamic_ports() pipe.refresh_thermodynamic_ports() solver = PressureFlowSolver(network) result = solver.solve() self.assertTrue(result.success) self.assertEqual(result.evaluations, 0) self.assertGreater(valve.port_2.p, pipe.port_2.p) self.assertLess(valve.port_2.p, chamber.port_2.p) self.assertAlmostEqual(valve.port_2.p, pipe.port_1.p) self.assertAlmostEqual(-valve.port_2.m_flow, pipe.port_1.m_flow) previous_intermediate_pressure = valve.port_2.p replay_pressure = 12.0e6 replay_temperature = 293.15 replay_mass = ( medium.density(replay_pressure, replay_temperature) * pipe.volume ) pipe.state = VolumeState( m=replay_mass, U=replay_mass * medium.specific_internal_energy(replay_temperature), ) pipe.refresh_thermodynamic_ports() replay = solver.solve() self.assertTrue(replay.success) self.assertEqual(replay.evaluations, 0) self.assertNotAlmostEqual(valve.port_2.p, previous_intermediate_pressure) self.assertAlmostEqual(valve.port_2.p, pipe.port_1.p) self.assertAlmostEqual(-valve.port_2.m_flow, pipe.port_1.m_flow) def test_dead_ended_pnl00r_is_seeded_at_zero_flow_pressure(self) -> None: medium = IdealGasMedium() pipe = AmesimPnl00r("resistance", medium) plug = AmesimPnpl01("closed") pipe.port_1.p = 15.3e6 pipe.port_2.p = 100_000.0 network = SimulationNetwork("dead-ended-resistance") network.add_component(pipe) network.add_component(plug) network.connect("resistance", "port_2", "closed", "port_1") solver = PressureFlowSolver(network) result = solver.solve() self.assertTrue(result.success) self.assertEqual(result.evaluations, 0) self.assertAlmostEqual(pipe.port_2.p, pipe.port_1.p) self.assertEqual(pipe.port_1.m_flow, 0.0) self.assertEqual(pipe.port_2.m_flow, 0.0) self.assertFalse(solver.causal_fast_path_eligible) self.assertEqual( solver.causal_execution_diagnostics()["fallbackReason"], "specialClosedResistancePressureSeed", ) @staticmethod def _near_equal_pressure_network() -> tuple[ SimulationNetwork, AmesimHeliumPengRobinsonMedium, Cylinder, Tank, AmesimPnvo001SignalOpening, ]: medium = AmesimHeliumPengRobinsonMedium() high = Cylinder("high", medium, V=0.057, p0=1.0e5, T0=256.1) low = Tank("low", medium, V=0.015, p0=1.0e5, T0=298.2) valve = AmesimPnvo001SignalOpening( "valve", medium, cq=0.45, area0=7.85e-5, gi=1.0, flowset=1.0, opening0=1.0, ) valve.res.signal = 1.0 network = SimulationNetwork("near-equal-pressure") for component in (high, low, valve): network.add_component(component) network.connect("high", "port_b", "valve", "port_2") network.connect("valve", "port_3", "low", "port_a") return network, medium, high, low, valve @staticmethod def _set_pressure_temperature( component: Cylinder | Tank, medium: AmesimHeliumPengRobinsonMedium, pressure: float, temperature: float, ) -> None: mass = medium.density(pressure, temperature) * component.V component.state = VolumeState( m=mass, U=mass * medium.specific_internal_energy_at_pressure( pressure, temperature, ), ) component.refresh_thermodynamic_ports() def test_stream_enthalpy_refreshes_explicit_orifice_flow(self) -> None: network, medium, high, low, valve = self._near_equal_pressure_network() self._set_pressure_temperature( high, medium, 15_201_996.778497815, 292.3997890954483, ) self._set_pressure_temperature( low, medium, 405_072.8123335606, 393.46713105173205, ) system = GenericFluidSystem(network) system.consistent_initial_state_vector() self.assertLess(valve._connected_h["port_2"], 0.0) self.assertAlmostEqual( valve.port_2.m_flow, valve.mass_flow(valve.port_2.p, valve.port_3.p), places=12, ) self.assertAlmostEqual(valve.port_3.m_flow, -valve.port_2.m_flow, places=12) def test_stream_dependent_pipe_flow_closes_with_junction_mixing_in_one_rhs(self) -> None: medium = IdealGasMedium() hot = Cylinder("hot", medium, V=0.1, p0=500_000.0, T0=400.0) cold = Cylinder("cold", medium, V=0.1, p0=500_000.0, T0=250.0) sink = Tank("sink", medium, V=0.1, p0=100_000.0, T0=300.0) hot_line = AmesimPnl00r("hot_line", medium, diam=0.01, le=0.5) cold_line = AmesimPnl00r("cold_line", medium, diam=0.01, le=0.5) junction = AmesimPn3Node2("junction") pipe = AmesimPnl0002( "pipe", medium, diam=0.01, le=1.0, p0=200_000.0, T0=300.0, ) network = SimulationNetwork("stream-flow-fixed-point") for component in ( hot, cold, sink, hot_line, cold_line, junction, pipe, ): network.add_component(component) network.connect("hot", "port_b", "hot_line", "port_1") network.connect("hot_line", "port_2", "junction", "port_1") network.connect("cold", "port_b", "cold_line", "port_1") network.connect("cold_line", "port_2", "junction", "port_3") network.connect("junction", "port_2", "pipe", "port_1") network.connect("pipe", "port_2", "sink", "port_a") system = GenericFluidSystem(network) state = system.consistent_initial_state_vector() first = system.rhs(0.0, state) second = system.rhs(0.0, state) # PN3NODE2 takes its pressure-flow temperature reference from port 2, # so that closure no longer depends on the flow-weighted energy mix. self.assertGreaterEqual(system.max_thermofluid_iterations, 1) for first_value, second_value in zip(first, second): self.assertAlmostEqual(first_value, second_value, delta=1.0e-8) def test_current_storage_pressure_reseeds_stale_orifice_ports_and_flow(self) -> None: network, medium, high, low, valve = self._near_equal_pressure_network() solver = PressureFlowSolver(network, max_evaluations=10) high_pressure = 10_790_000.0 self._set_pressure_temperature(high, medium, high_pressure, 256.1) self._set_pressure_temperature(low, medium, high_pressure - 300.0, 298.2) initial = solver.solve() self.assertTrue(initial.success) stale_low_pressure = valve.port_3.p stale_flow = valve.port_2.m_flow self._set_pressure_temperature(low, medium, high_pressure - 100.0, 298.2) self.assertNotAlmostEqual(low.port_a.p, stale_low_pressure, places=3) updated = solver.solve() self.assertTrue(updated.success) self.assertEqual(updated.evaluations, 0) self.assertAlmostEqual(valve.port_2.p, high.port_b.p, places=6) self.assertAlmostEqual(valve.port_3.p, low.port_a.p, places=6) self.assertNotAlmostEqual(valve.port_2.m_flow, stale_flow, places=8) self.assertAlmostEqual( valve.port_2.m_flow, valve.mass_flow(valve.port_2.p, valve.port_3.p), places=10, ) self.assertAlmostEqual(high.port_b.m_flow, -valve.port_2.m_flow, places=10) self.assertAlmostEqual(low.port_a.m_flow, -valve.port_3.m_flow, places=10) def test_compiled_plan_does_not_rebuild_equation_metadata_during_solve(self) -> None: boundary = AmesimPnpl01("compiled_closed") boundary.port_1.p = 100_000.0 boundary.port_1.m_flow = 1.0 network = SimulationNetwork("compiled-closed-boundary") network.add_component(boundary) solver = PressureFlowSolver(network) with patch.object( boundary, "pressure_flow_equation_residuals", side_effect=AssertionError("equation metadata rebuilt at runtime"), ): diagnostics = solver.solve() self.assertTrue(diagnostics.success) self.assertEqual(diagnostics.evaluations, 0) self.assertEqual(boundary.port_1.m_flow, 0.0) @staticmethod def _closed_boundary_solver() -> PressureFlowSolver: boundary = AmesimPnpl01("closed") boundary.port_1.p = 100_000.0 boundary.port_1.m_flow = 1.0 network = SimulationNetwork("closed-boundary") network.add_component(boundary) return PressureFlowSolver(network) @staticmethod def _least_squares_result(x, *, status: int): return SimpleNamespace( x=x, success=status > 0, status=status, message="test optimizer result", nfev=1, ) def test_status_zero_is_accepted_only_for_finite_converged_residuals(self) -> None: exact_solver = self._closed_boundary_solver() def exact_status_zero(_fun, x0, **_kwargs): values = x0.copy() flow_index = next( index for index, unknown in enumerate(exact_solver.unknowns) if unknown.variable == "m_flow" ) values[flow_index] = 0.0 return self._least_squares_result(values, status=0) with patch.object( PressureFlowSolver, "_solve_explicit_flow_unknowns", return_value=None, ), patch("scipy.optimize.least_squares", side_effect=exact_status_zero): diagnostics = exact_solver.solve() self.assertTrue(diagnostics.success) self.assertEqual(diagnostics.max_scaled_residual, 0.0) inaccurate_solver = self._closed_boundary_solver() def inaccurate_status_zero(_fun, x0, **_kwargs): values = x0.copy() flow_index = next( index for index, unknown in enumerate(inaccurate_solver.unknowns) if unknown.variable == "m_flow" ) values[flow_index] = 1.0 return self._least_squares_result(values, status=0) with patch.object( PressureFlowSolver, "_solve_explicit_flow_unknowns", return_value=None, ), patch("scipy.optimize.least_squares", side_effect=inaccurate_status_zero): with self.assertRaises(AlgebraicSolveError): inaccurate_solver.solve() invalid_status_solver = self._closed_boundary_solver() def exact_invalid_status(_fun, x0, **_kwargs): values = x0.copy() flow_index = next( index for index, unknown in enumerate(invalid_status_solver.unknowns) if unknown.variable == "m_flow" ) values[flow_index] = 0.0 return self._least_squares_result(values, status=-1) with patch.object( PressureFlowSolver, "_solve_explicit_flow_unknowns", return_value=None, ), patch("scipy.optimize.least_squares", side_effect=exact_invalid_status): with self.assertRaises(AlgebraicSolveError): invalid_status_solver.solve() def test_zero_residual_seed_does_not_bypass_positive_pressure_bound(self) -> None: medium = IdealGasMedium() tank = Tank("tank", medium, V=1.0) plug = AmesimPnpl01("plug") network = SimulationNetwork("invalid-negative-pressure") network.add_component(tank) network.add_component(plug) network.connect("tank", "port_a", "plug", "port_1") tank.state = VolumeState(m=1.0, U=-1000.0) tank.refresh_thermodynamic_ports() self.assertLess(tank.port_a.p, 0.0) solver = PressureFlowSolver(network, max_evaluations=10) with self.assertRaises(AlgebraicSolveError): solver.solve() self.assertIsNotNone(solver.last_diagnostics) self.assertFalse(solver.last_diagnostics.success) def test_positive_sub_pascal_seed_uses_the_exact_pressure_without_optimizer( self, ) -> None: medium = IdealGasMedium() pressure = 0.43301848566882734 temperature = pressure / medium.R_gas tank = Tank("tank", medium, V=1.0) plug = AmesimPnpl01("plug") network = SimulationNetwork("positive-sub-pascal-pressure") network.add_component(tank) network.add_component(plug) network.connect("tank", "port_a", "plug", "port_1") tank.state = VolumeState( m=1.0, U=medium.specific_internal_energy(temperature), ) tank.refresh_thermodynamic_ports() self.assertAlmostEqual( tank.port_a.p, pressure, delta=pressure * 1.0e-11, ) solver = PressureFlowSolver(network, max_evaluations=10) with patch( "scipy.optimize.least_squares", side_effect=AssertionError( "A finite positive pressure seed must stay on the fast path." ), ) as least_squares: diagnostics = solver.solve() self.assertTrue(diagnostics.success) self.assertEqual(diagnostics.evaluations, 0) self.assertAlmostEqual( tank.port_a.p, pressure, delta=pressure * 1.0e-11, ) least_squares.assert_not_called() if __name__ == "__main__": unittest.main()