From 78013b25c9cf2164b558c2bcc429682943d09e45 Mon Sep 17 00:00:00 2001 From: huojiarong Date: Fri, 17 Jul 2026 09:05:51 +0000 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5test=5Fmql=20PN3=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=AE=A1=E8=B7=AF=E9=97=AD=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PythonModels/systems/test_mql.py | 120 +++++++++ PythonModels/systems/test_mql_closure.py | 303 ++++++++++++++++++++++- tests/test_test_mql_pnl0001_segment.py | 89 +++++++ 3 files changed, 511 insertions(+), 1 deletion(-) diff --git a/PythonModels/systems/test_mql.py b/PythonModels/systems/test_mql.py index fcb0328..ab46b8b 100644 --- a/PythonModels/systems/test_mql.py +++ b/PythonModels/systems/test_mql.py @@ -3940,6 +3940,22 @@ class TestMqlSystem: def typed_node3_count(self) -> int: return len(self.node3_assembly) + def _pnl0003_line_for_node_port(self, node_alias: str, port_name: str): + for spec in self.pnl0003_assembly.specs: + if spec.source_component == node_alias and spec.source_port == port_name: + return self.pnl0003_assembly.lines[spec.alias] + if spec.target_component == node_alias and spec.target_port == port_name: + return self.pnl0003_assembly.lines[spec.alias] + raise KeyError(f"No PNL0003 line attached to {node_alias}.{port_name}") + + def _pnl00r_line_for_node_port(self, node_alias: str, port_name: str): + for spec in self.pnl00r_assembly.specs: + if spec.source_component == node_alias and spec.source_port == port_name: + return self.pnl00r_assembly.lines[spec.alias] + if spec.target_component == node_alias and spec.target_port == port_name: + return self.pnl00r_assembly.lines[spec.alias] + raise KeyError(f"No PNL00R line attached to {node_alias}.{port_name}") + def pneumatic_state_vector(self) -> list[float]: return self.network.initial_state_vector() @@ -4276,6 +4292,110 @@ class TestMqlSystem: t_eval=t_eval, ) + def pn3_node_chamber_segment_closure_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + pnl0003_port_2_mass_flow_kg_s: float = 0.0, + pnl0003_port_2_temperature_k: float = 293.15, + ): + """Close a PNL0001 chamber segment with the real outlet PN3 node. + + The PN3 primary pressure/temperature is taken from the attached PNL0003 + port-1 compliance. The PNL0003 port-2 side and the far side of PNL00R + remain prescribed boundaries until PNVO/P4 semantics are inserted. + """ + from PythonModels.components.amesim_pneumatic import ( + AmesimPneumaticOrifice, + AmesimPneumaticVolume, + ) + from PythonModels.systems.test_mql_closure import ( + TestMqlPneumaticBoundaryCondition, + TestMqlPneumaticFlowBoundaryCondition, + TestMqlPn3NodeChamberSegmentClosure, + TestMqlPn3NodeChamberSegmentComponents, + ) + + inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] + outlet_line = self.pnl0001_assembly.lines[spec.outlet_line_alias] + node_line = self._pnl0003_line_for_node_port(spec.outlet_node_alias, "port_2") + node_resistance = self._pnl00r_line_for_node_port( + spec.outlet_node_alias, "port_1" + ) + node = self.node3_assembly[spec.outlet_node_alias] + volume = self.network.components[spec.volume_alias] + inlet_orifice = self.network.components[spec.inlet_orifice_alias] + outlet_orifice = self.network.components[spec.outlet_orifice_alias] + if not isinstance(volume, AmesimPneumaticVolume): + raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") + if not isinstance(inlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + if not isinstance(outlet_orifice, AmesimPneumaticOrifice): + raise TypeError( + f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" + ) + return TestMqlPn3NodeChamberSegmentClosure( + components=TestMqlPn3NodeChamberSegmentComponents( + inlet_line=inlet_line, + outlet_line=outlet_line, + node_line=node_line, + node_resistance=node_resistance, + node=node, + volume=volume, + inlet_orifice=inlet_orifice, + outlet_orifice=outlet_orifice, + spec=spec, + ), + inlet_node=TestMqlPneumaticBoundaryCondition( + pressure_pa=inlet_node_pressure_pa, + temperature_k=inlet_node_temperature_k, + ), + resistance_boundary=TestMqlPneumaticBoundaryCondition( + pressure_pa=resistance_boundary_pressure_pa, + temperature_k=resistance_boundary_temperature_k, + ), + pnl0003_port_2_flow=TestMqlPneumaticFlowBoundaryCondition( + mass_flow_kg_s=pnl0003_port_2_mass_flow_kg_s, + temperature_k=pnl0003_port_2_temperature_k, + ), + ) + + def simulate_pn3_node_chamber_segment_from_spec( + self, + spec, + *, + inlet_node_pressure_pa: float, + resistance_boundary_pressure_pa: float, + inlet_node_temperature_k: float = 293.15, + resistance_boundary_temperature_k: float = 293.15, + pnl0003_port_2_mass_flow_kg_s: float = 0.0, + pnl0003_port_2_temperature_k: float = 293.15, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ): + closure = self.pn3_node_chamber_segment_closure_from_spec( + spec, + inlet_node_pressure_pa=inlet_node_pressure_pa, + resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, + inlet_node_temperature_k=inlet_node_temperature_k, + resistance_boundary_temperature_k=resistance_boundary_temperature_k, + pnl0003_port_2_mass_flow_kg_s=pnl0003_port_2_mass_flow_kg_s, + pnl0003_port_2_temperature_k=pnl0003_port_2_temperature_k, + ) + run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) + return integrate_ode( + rhs=lambda t, state: closure.rhs(state), + initial_state=closure.initial_state_vector(), + config=run_config, + t_eval=t_eval, + ) + def pneumatic_branch_closure_from_spec(self, spec): return self.pneumatic_branch_closure( name=spec.name, diff --git a/PythonModels/systems/test_mql_closure.py b/PythonModels/systems/test_mql_closure.py index bbda93e..6eef2b8 100644 --- a/PythonModels/systems/test_mql_closure.py +++ b/PythonModels/systems/test_mql_closure.py @@ -8,10 +8,18 @@ from PythonModels.components.amesim_pneumatic import ( AmesimPneumaticOrifice, AmesimPneumaticVolume, ) -from PythonModels.components.amesim_pneumatic_line import AmesimPnl0001Pipe +from PythonModels.components.amesim_pneumatic_line import ( + AmesimPnl0001Pipe, + AmesimPnl0003Pipe, + AmesimPnl00rPipe, +) from PythonModels.core.medium import ThermodynamicProperties from PythonModels.core.ports import PortState from PythonModels.core.state import VolumeState +from PythonModels.systems.test_mql_nodes import ( + TestMqlPneumaticNode3, + TestMqlPneumaticNode3Balance, +) @dataclass(frozen=True) @@ -82,6 +90,17 @@ class TestMqlPneumaticBoundaryCondition: ) +@dataclass(frozen=True) +class TestMqlPneumaticFlowBoundaryCondition: + mass_flow_kg_s: float + temperature_k: float = 293.15 + + def connected_enthalpy(self, gas: AmesimPneumaticGas) -> float: + if self.temperature_k <= 0.0: + raise ValueError("flow boundary temperature must be positive") + return gas.specific_enthalpy(self.temperature_k) + + @dataclass(frozen=True) class TestMqlPneumaticChamberSegmentComponents: volume: AmesimPneumaticVolume @@ -142,6 +161,40 @@ class TestMqlPnl0001PairChamberSegmentSnapshot: outlet_line_to_chamber_flow: float +@dataclass(frozen=True) +class TestMqlPn3NodeChamberSegmentComponents: + inlet_line: AmesimPnl0001Pipe + outlet_line: AmesimPnl0001Pipe + node_line: AmesimPnl0003Pipe + node_resistance: AmesimPnl00rPipe + node: TestMqlPneumaticNode3 + volume: AmesimPneumaticVolume + inlet_orifice: AmesimPneumaticOrifice + outlet_orifice: AmesimPneumaticOrifice + spec: TestMqlPneumaticChamberSegmentSpec + + +@dataclass(frozen=True) +class TestMqlPn3NodeChamberSegmentSnapshot: + inlet_line: ThermodynamicProperties + chamber: ThermodynamicProperties + outlet_line: ThermodynamicProperties + node_line_port_1: ThermodynamicProperties + node_line_port_2: ThermodynamicProperties + inlet_node: ThermodynamicProperties + resistance_boundary: ThermodynamicProperties + node_balance: TestMqlPneumaticNode3Balance + inlet_node_to_line_flow: float + inlet_line_to_chamber_flow: float + outlet_node_to_line_flow: float + outlet_line_to_chamber_flow: float + node_to_resistance_flow: float + node_to_pnl0003_flow: float + pnl0003_center_flow: float + pnl0003_port_2_flow: float + pnl0003_port_2_connected_h: float + + @dataclass(frozen=True) class TestMqlPneumaticBranchComponents: name: str @@ -774,3 +827,251 @@ class TestMqlPnl0001PairChamberSegmentClosure: *chamber_derivative.as_vector(), *outlet_derivative.as_vector(), ] + + +class TestMqlPn3NodeChamberSegmentClosure: + """Boundary-reduced chamber segment closed by one real PN3 node. + + ``pneumatic_88`` supplies the PN3 primary pressure/temperature from its + port-1 compliance. The node has no storage, so the sum of the port-1 + PNL00R flow and port-3 PNL0001 flow is written as the opposite port-1 + flow into the PNL0003 state. + """ + + def __init__( + self, + *, + components: TestMqlPn3NodeChamberSegmentComponents, + inlet_node: TestMqlPneumaticBoundaryCondition, + resistance_boundary: TestMqlPneumaticBoundaryCondition, + pnl0003_port_2_flow: TestMqlPneumaticFlowBoundaryCondition, + ) -> None: + self.components = components + self.inlet_node = inlet_node + self.resistance_boundary = resistance_boundary + self.pnl0003_port_2_flow = pnl0003_port_2_flow + + def initial_state_vector(self) -> list[float]: + return [ + *self.components.inlet_line.get_state_vector(), + *self.components.volume.get_state_vector(), + *self.components.outlet_line.get_state_vector(), + *self.components.node_line.get_state_vector(), + ] + + def apply_state_vector(self, values: list[float]) -> None: + if len(values) != 10: + raise ValueError("PN3 node/chamber segment state vector requires ten values") + self.components.inlet_line.set_state_vector(values[:2]) + self.components.volume.set_state_vector(values[2:4]) + self.components.outlet_line.set_state_vector(values[4:6]) + self.components.node_line.set_state_vector(values[6:]) + + def snapshot( + self, + state_vector: list[float] | None = None, + ) -> TestMqlPn3NodeChamberSegmentSnapshot: + if state_vector is not None: + self.apply_state_vector(state_vector) + inlet_line = self.components.inlet_line.properties() + chamber = self.components.volume.properties() + outlet_line = self.components.outlet_line.properties() + node_line_port_1 = self.components.node_line.properties_1() + node_line_port_2 = self.components.node_line.properties_2() + inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) + resistance_boundary = self.resistance_boundary.properties( + self.components.node_resistance.gas + ) + inlet_node_to_line_flow = self.components.inlet_line.resistance_mass_flow( + port_1_pressure_pa=inlet_node.p, + port_1_temperature_k=inlet_node.T, + ) + outlet_node_to_line_flow = self.components.outlet_line.resistance_mass_flow( + port_1_pressure_pa=node_line_port_1.p, + port_1_temperature_k=node_line_port_1.T, + ) + inlet_line_to_chamber_flow = self._line_to_chamber_flow( + line=inlet_line, + chamber=chamber, + orifice=self.components.inlet_orifice, + ) + outlet_line_to_chamber_flow = self._line_to_chamber_flow( + line=outlet_line, + chamber=chamber, + orifice=self.components.outlet_orifice, + ) + node_to_resistance_flow = self.components.node_resistance.mass_flow( + port_1_pressure_pa=node_line_port_1.p, + port_1_temperature_k=node_line_port_1.T, + port_2_pressure_pa=resistance_boundary.p, + port_2_temperature_k=resistance_boundary.T, + ) + node_balance = self.components.node.balance( + port_2_temperature_k=node_line_port_1.T, + port_2_pressure_pa=node_line_port_1.p, + port_1_enthalpy_flow_w=self._enthalpy_flow_from_node( + flow_kg_s=node_to_resistance_flow, + node_h=node_line_port_1.h, + connected_h=resistance_boundary.h, + ), + port_1_mass_flow_g_s=node_to_resistance_flow * 1.0e3, + port_3_enthalpy_flow_w=self._enthalpy_flow_from_node( + flow_kg_s=outlet_node_to_line_flow, + node_h=node_line_port_1.h, + connected_h=outlet_line.h, + ), + port_3_mass_flow_g_s=outlet_node_to_line_flow * 1.0e3, + ) + node_to_pnl0003_flow = -node_balance.port_2_mass_flow_g_s * 1.0e-3 + pnl0003_center_flow = self.components.node_line.resistance_mass_flow() + pnl0003_port_2_connected_h = self.pnl0003_port_2_flow.connected_enthalpy( + self.components.node_line.gas + ) + snapshot = TestMqlPn3NodeChamberSegmentSnapshot( + inlet_line=inlet_line, + chamber=chamber, + outlet_line=outlet_line, + node_line_port_1=node_line_port_1, + node_line_port_2=node_line_port_2, + inlet_node=inlet_node, + resistance_boundary=resistance_boundary, + node_balance=node_balance, + inlet_node_to_line_flow=inlet_node_to_line_flow, + inlet_line_to_chamber_flow=inlet_line_to_chamber_flow, + outlet_node_to_line_flow=outlet_node_to_line_flow, + outlet_line_to_chamber_flow=outlet_line_to_chamber_flow, + node_to_resistance_flow=node_to_resistance_flow, + node_to_pnl0003_flow=node_to_pnl0003_flow, + pnl0003_center_flow=pnl0003_center_flow, + pnl0003_port_2_flow=self.pnl0003_port_2_flow.mass_flow_kg_s, + pnl0003_port_2_connected_h=pnl0003_port_2_connected_h, + ) + self._write_port_states(snapshot) + return snapshot + + @staticmethod + def _line_to_chamber_flow( + *, + line: ThermodynamicProperties, + chamber: ThermodynamicProperties, + orifice: AmesimPneumaticOrifice, + ) -> float: + return TestMqlPnl0001PairChamberSegmentClosure._line_to_chamber_flow( + line=line, + chamber=chamber, + orifice=orifice, + ) + + @staticmethod + def _port( + component: AmesimPneumaticVolume | AmesimPneumaticOrifice, + port_name: str, + ) -> PortState: + return TestMqlPneumaticChamberSegmentClosure._port(component, port_name) + + @staticmethod + def _enthalpy_flow_from_node( + *, + flow_kg_s: float, + node_h: float, + connected_h: float, + ) -> float: + return flow_kg_s * (node_h if flow_kg_s >= 0.0 else connected_h) + + def _write_port_states( + self, + snapshot: TestMqlPn3NodeChamberSegmentSnapshot, + ) -> None: + spec = self.components.spec + chamber = self.components.volume + chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) + chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) + + TestMqlPnl0001PairChamberSegmentClosure._write_line_side( + self, + line=self.components.inlet_line, + line_properties=snapshot.inlet_line, + node_properties=snapshot.inlet_node, + node_to_line_flow=snapshot.inlet_node_to_line_flow, + line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, + orifice=self.components.inlet_orifice, + orifice_boundary_port=spec.inlet_orifice_boundary_port, + orifice_volume_port=spec.inlet_orifice_volume_port, + chamber_port=chamber_inlet_port, + chamber_properties=snapshot.chamber, + ) + TestMqlPnl0001PairChamberSegmentClosure._write_line_side( + self, + line=self.components.outlet_line, + line_properties=snapshot.outlet_line, + node_properties=snapshot.node_line_port_1, + node_to_line_flow=snapshot.outlet_node_to_line_flow, + line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, + orifice=self.components.outlet_orifice, + orifice_boundary_port=spec.outlet_orifice_boundary_port, + orifice_volume_port=spec.outlet_orifice_volume_port, + chamber_port=chamber_outlet_port, + chamber_properties=snapshot.chamber, + ) + node_line = self.components.node_line + node_line.port_1.p = snapshot.node_line_port_1.p + node_line.port_1.m_flow = snapshot.node_to_pnl0003_flow + node_line.port_1.h_outflow = snapshot.node_line_port_1.h + node_line.port_2.p = snapshot.node_line_port_2.p + node_line.port_2.m_flow = snapshot.pnl0003_port_2_flow + node_line.port_2.h_outflow = snapshot.node_line_port_2.h + + resistance = self.components.node_resistance + resistance.port_1.p = snapshot.node_line_port_1.p + resistance.port_1.m_flow = snapshot.node_to_resistance_flow + resistance.port_1.h_outflow = snapshot.node_line_port_1.h + resistance.port_2.p = snapshot.resistance_boundary.p + resistance.port_2.m_flow = -snapshot.node_to_resistance_flow + resistance.port_2.h_outflow = snapshot.resistance_boundary.h + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + inlet_derivative = self.components.inlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.inlet_node_to_line_flow, + connected_h_1=snapshot.inlet_node.h, + port_2_m_flow=-snapshot.inlet_line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + outlet_derivative = self.components.outlet_line.derivatives_from_connections( + port_1_m_flow=snapshot.outlet_node_to_line_flow, + connected_h_1=snapshot.node_line_port_1.h, + port_2_m_flow=-snapshot.outlet_line_to_chamber_flow, + connected_h_2=snapshot.chamber.h, + ) + chamber = self.components.volume + spec = self.components.spec + chamber_derivative = chamber.derivatives_from_two_connections( + port_a_m_flow=self._port(chamber, "port_1").m_flow, + connected_h_a=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_1" + else snapshot.outlet_line.h + ), + port_b_m_flow=self._port(chamber, "port_2").m_flow, + connected_h_b=( + snapshot.inlet_line.h + if spec.volume_inlet_port == "port_2" + else snapshot.outlet_line.h + ), + internal_h=snapshot.chamber.h, + ) + node_line_derivative_1, node_line_derivative_2 = ( + self.components.node_line.derivatives_from_connections( + port_1_m_flow=snapshot.node_to_pnl0003_flow, + connected_h_1=snapshot.node_line_port_1.h, + port_2_m_flow=snapshot.pnl0003_port_2_flow, + connected_h_2=snapshot.pnl0003_port_2_connected_h, + ) + ) + return [ + *inlet_derivative.as_vector(), + *chamber_derivative.as_vector(), + *outlet_derivative.as_vector(), + *node_line_derivative_1.as_vector(), + *node_line_derivative_2.as_vector(), + ] diff --git a/tests/test_test_mql_pnl0001_segment.py b/tests/test_test_mql_pnl0001_segment.py index f91f7f9..81538ef 100644 --- a/tests/test_test_mql_pnl0001_segment.py +++ b/tests/test_test_mql_pnl0001_segment.py @@ -149,5 +149,94 @@ class TestMqlPnl0001PairChamberSegmentTests(unittest.TestCase): self.assertEqual([len(row) for row in solution.y], [3, 3, 3, 3, 3, 3]) +class TestMqlPn3NodeChamberSegmentTests(unittest.TestCase): + def setUp(self) -> None: + self.system = TestMqlSystem() + self.spec = self.system.discover_pneumatic_branch_topology().chamber_segment_specs[0] + + def test_closure_replaces_outlet_boundary_with_real_pn3_node(self) -> None: + closure = self.system.pn3_node_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + ) + + snapshot = closure.snapshot() + rhs = closure.rhs(closure.initial_state_vector()) + + self.assertEqual(closure.components.inlet_line.name, "pneumatic_96") + self.assertEqual(closure.components.outlet_line.name, "pneumatic_97") + self.assertEqual(closure.components.node_line.name, "pneumatic_88") + self.assertEqual(closure.components.node_resistance.name, "pneumatic_100") + self.assertEqual(closure.components.node.alias, "pn_node3_9") + self.assertEqual(len(closure.initial_state_vector()), 10) + self.assertEqual(len(rhs), 10) + self.assertAlmostEqual( + snapshot.node_balance.pressure_pa, + snapshot.node_line_port_1.p, + ) + self.assertAlmostEqual( + snapshot.node_balance.temperature_k, + snapshot.node_line_port_1.T, + ) + + def test_pn3_node_flow_cancels_between_adjacent_lines(self) -> None: + closure = self.system.pn3_node_chamber_segment_closure_from_spec( + self.spec, + inlet_node_pressure_pa=15.3e6, + resistance_boundary_pressure_pa=15.0e6, + ) + state = closure.initial_state_vector() + state[4] *= 0.99 + state[5] *= 0.99 + + snapshot = closure.snapshot(state) + rhs = closure.rhs(state) + + self.assertGreater(snapshot.outlet_node_to_line_flow, 0.0) + self.assertGreater(snapshot.node_to_resistance_flow, 0.0) + self.assertAlmostEqual( + snapshot.node_balance.port_2_mass_flow_g_s, + (snapshot.outlet_node_to_line_flow + snapshot.node_to_resistance_flow) + * 1.0e3, + ) + self.assertAlmostEqual( + snapshot.node_to_pnl0003_flow, + -(snapshot.outlet_node_to_line_flow + snapshot.node_to_resistance_flow), + ) + self.assertAlmostEqual( + closure.components.node_line.port_1.m_flow, + snapshot.node_to_pnl0003_flow, + ) + mass_derivative_sum = rhs[0] + rhs[2] + rhs[4] + rhs[6] + rhs[8] + self.assertAlmostEqual( + mass_derivative_sum, + ( + snapshot.inlet_node_to_line_flow + + snapshot.pnl0003_port_2_flow + - snapshot.node_to_resistance_flow + ), + delta=1.0e-12, + ) + + def test_simulates_ten_state_pn3_node_chamber_segment(self) -> None: + solution = self.system.simulate_pn3_node_chamber_segment_from_spec( + self.spec, + inlet_node_pressure_pa=15.31e6, + resistance_boundary_pressure_pa=15.29e6, + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0e-8, + max_step=1.0e-9, + ), + t_eval=[0.0, 5.0e-9, 1.0e-8], + ) + + self.assertTrue(solution.success) + self.assertEqual(len(solution.t), 3) + self.assertEqual(len(solution.y), 10) + self.assertEqual([len(row) for row in solution.y], [3] * 10) + + if __name__ == "__main__": unittest.main()