接入test_mql真实固定气室拓扑

This commit is contained in:
huojiarong committed 2026-07-17 02:49:06 +00:00
1 parent c0f28520b4
commit 2d33a8f4bc
8 files changed
+747 -5

No files matched your search

@@ -93,6 +93,7 @@ class AmesimPneumaticVolume(DynamicComponent):
U0 = m0 * gas.specific_internal_energy(T0) U0 = m0 * gas.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0) self.state = VolumeState(m=m0, U=U0)
self.port_a = PortState() self.port_a = PortState()
self.port_b = PortState()
@classmethod @classmethod
def from_liters( def from_liters(
@@ -130,11 +131,37 @@ class AmesimPneumaticVolume(DynamicComponent):
h = self.gas.specific_enthalpy(T) h = self.gas.specific_enthalpy(T)
self.port_a.p = p self.port_a.p = p
self.port_a.h_outflow = h self.port_a.h_outflow = h
self.port_b.p = p
self.port_b.h_outflow = h
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h) return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState: def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
return VolumeState(m=m_flow, U=m_flow * inlet_h) return VolumeState(m=m_flow, U=m_flow * inlet_h)
def derivatives_from_two_connections(
self,
*,
port_a_m_flow: float,
connected_h_a: float,
port_b_m_flow: float,
connected_h_b: float,
internal_h: float,
) -> VolumeState:
inlet_h_a = self.connection_inlet_enthalpy(
port_m_flow=port_a_m_flow,
connected_h=connected_h_a,
internal_h=internal_h,
)
inlet_h_b = self.connection_inlet_enthalpy(
port_m_flow=port_b_m_flow,
connected_h=connected_h_b,
internal_h=internal_h,
)
return VolumeState(
m=port_a_m_flow + port_b_m_flow,
U=port_a_m_flow * inlet_h_a + port_b_m_flow * inlet_h_b,
)
class AmesimVariablePneumaticVolume(AmesimPneumaticVolume): class AmesimVariablePneumaticVolume(AmesimPneumaticVolume):
"""PNCH012-style volume with a dead volume plus an external moving volume.""" """PNCH012-style volume with a dead volume plus an external moving volume."""
+118 -5
View File
@@ -3920,8 +3920,23 @@ class TestMqlSystem:
TestMqlPneumaticTopologyCandidate, TestMqlPneumaticTopologyCandidate,
TestMqlPneumaticTopologyDiscovery, TestMqlPneumaticTopologyDiscovery,
) )
from PythonModels.systems.test_mql_pneumatic_topology import (
discover_fixed_chamber_segments,
)
from PythonModels.systems.test_mql_topology import load_test_mql_cir_topology
topology = load_test_mql_cir_topology(self.archive_path)
chamber_segment_specs = discover_fixed_chamber_segments(
topology,
COMPONENT_SPECS,
CONNECTION_SPECS,
)
typed_aliases = set(self._pneumatic_components_by_alias()) typed_aliases = set(self._pneumatic_components_by_alias())
component_submodels = {
str(component["alias"]): str(component["submodel"])
for component in COMPONENT_SPECS
}
blocked_candidates = [] blocked_candidates = []
for spec in CONNECTION_SPECS: for spec in CONNECTION_SPECS:
source_component = str(spec["source_component"]) source_component = str(spec["source_component"])
@@ -3932,12 +3947,28 @@ class TestMqlSystem:
continue continue
line_submodel = str(spec["submodel"]) line_submodel = str(spec["submodel"])
if source_is_typed and target_is_typed: endpoint_submodels = {
reason = "direct typed pneumatic connection is not a complete volume-orifice-volume branch" component_submodels.get(source_component, ""),
elif line_submodel == "DIRECT": component_submodels.get(target_component, ""),
reason = "requires AMESim boundary component interpretation before closure construction" }
if "PNRP17" in endpoint_submodels:
category = "piston-boundary"
reason = "requires pneumatic piston and mechanical coupling interpretation"
elif "STEP0" in endpoint_submodels:
category = "control-boundary"
reason = "requires modulated-orifice control signal evaluation"
elif endpoint_submodels & {"PN3NODE2", "P4NODE2"}:
category = "node-line-boundary"
reason = "requires AMESim node/line equations before full-network closure"
elif source_is_typed and target_is_typed:
category = "typed-direct"
reason = "direct typed connection is not a complete simulated subnetwork"
elif line_submodel != "DIRECT":
category = "line-boundary"
reason = "requires AMESim line equations before full-network closure"
else: else:
reason = "requires AMESim node/line submodel interpretation before closure construction" category = "component-boundary"
reason = "requires AMESim boundary component interpretation"
blocked_candidates.append( blocked_candidates.append(
TestMqlPneumaticTopologyCandidate( TestMqlPneumaticTopologyCandidate(
@@ -3949,15 +3980,97 @@ class TestMqlSystem:
target_port=str(spec["target_port"]), target_port=str(spec["target_port"]),
source_is_typed_pneumatic=source_is_typed, source_is_typed_pneumatic=source_is_typed,
target_is_typed_pneumatic=target_is_typed, target_is_typed_pneumatic=target_is_typed,
category=category,
reason=reason, reason=reason,
) )
) )
return TestMqlPneumaticTopologyDiscovery( return TestMqlPneumaticTopologyDiscovery(
branch_specs=(), branch_specs=(),
chamber_segment_specs=chamber_segment_specs,
blocked_candidates=tuple(blocked_candidates), blocked_candidates=tuple(blocked_candidates),
) )
def pneumatic_chamber_segment_closure_from_spec(
self,
spec,
*,
inlet_pressure_pa: float,
outlet_pressure_pa: float,
inlet_temperature_k: float = 293.15,
outlet_temperature_k: float = 293.15,
):
"""Build a reduced segment using absolute-pressure line boundaries.
Boundary values apply at the PNL0001/orifice interfaces, not at the nodes.
"""
from PythonModels.components.amesim_pneumatic import (
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
)
from PythonModels.systems.test_mql_closure import (
TestMqlPneumaticBoundaryCondition,
TestMqlPneumaticChamberSegmentClosure,
TestMqlPneumaticChamberSegmentComponents,
)
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 TestMqlPneumaticChamberSegmentClosure(
components=TestMqlPneumaticChamberSegmentComponents(
volume=volume,
inlet_orifice=inlet_orifice,
outlet_orifice=outlet_orifice,
spec=spec,
),
inlet_boundary=TestMqlPneumaticBoundaryCondition(
pressure_pa=inlet_pressure_pa,
temperature_k=inlet_temperature_k,
),
outlet_boundary=TestMqlPneumaticBoundaryCondition(
pressure_pa=outlet_pressure_pa,
temperature_k=outlet_temperature_k,
),
)
def simulate_pneumatic_chamber_segment_from_spec(
self,
spec,
*,
inlet_pressure_pa: float,
outlet_pressure_pa: float,
inlet_temperature_k: float = 293.15,
outlet_temperature_k: float = 293.15,
config: SolveIVPConfig | None = None,
t_eval: list[float] | None = None,
):
"""Integrate a reduced segment with absolute-pressure line boundaries."""
closure = self.pneumatic_chamber_segment_closure_from_spec(
spec,
inlet_pressure_pa=inlet_pressure_pa,
outlet_pressure_pa=outlet_pressure_pa,
inlet_temperature_k=inlet_temperature_k,
outlet_temperature_k=outlet_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): def pneumatic_branch_closure_from_spec(self, spec):
return self.pneumatic_branch_closure( return self.pneumatic_branch_closure(
name=spec.name, name=spec.name,
+195
View File
@@ -4,10 +4,12 @@ from dataclasses import dataclass
from typing import Callable from typing import Callable
from PythonModels.components.amesim_pneumatic import ( from PythonModels.components.amesim_pneumatic import (
AmesimPneumaticGas,
AmesimPneumaticOrifice, AmesimPneumaticOrifice,
AmesimPneumaticVolume, AmesimPneumaticVolume,
) )
from PythonModels.core.medium import ThermodynamicProperties from PythonModels.core.medium import ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState from PythonModels.core.state import VolumeState
@@ -30,15 +32,72 @@ class TestMqlPneumaticTopologyCandidate:
target_port: str target_port: str
source_is_typed_pneumatic: bool source_is_typed_pneumatic: bool
target_is_typed_pneumatic: bool target_is_typed_pneumatic: bool
category: str
reason: str reason: str
@dataclass(frozen=True)
class TestMqlPneumaticChamberSegmentSpec:
name: str
inlet_node_alias: str
inlet_line_alias: str
inlet_orifice_alias: str
inlet_orifice_boundary_port: str
inlet_orifice_volume_port: str
volume_alias: str
volume_inlet_port: str
volume_outlet_port: str
outlet_orifice_alias: str
outlet_orifice_volume_port: str
outlet_orifice_boundary_port: str
outlet_line_alias: str
outlet_node_alias: str
source: str = "amesim-cir-topology"
@dataclass(frozen=True) @dataclass(frozen=True)
class TestMqlPneumaticTopologyDiscovery: class TestMqlPneumaticTopologyDiscovery:
branch_specs: tuple[TestMqlPneumaticBranchSpec, ...] branch_specs: tuple[TestMqlPneumaticBranchSpec, ...]
chamber_segment_specs: tuple[TestMqlPneumaticChamberSegmentSpec, ...]
blocked_candidates: tuple[TestMqlPneumaticTopologyCandidate, ...] blocked_candidates: tuple[TestMqlPneumaticTopologyCandidate, ...]
@dataclass(frozen=True)
class TestMqlPneumaticBoundaryCondition:
pressure_pa: float
temperature_k: float
def properties(self, gas: AmesimPneumaticGas) -> ThermodynamicProperties:
if self.pressure_pa <= 0.0:
raise ValueError("boundary pressure must be positive")
if self.temperature_k <= 0.0:
raise ValueError("boundary temperature must be positive")
return ThermodynamicProperties(
p=self.pressure_pa,
T=self.temperature_k,
rho=gas.density(self.pressure_pa, self.temperature_k),
u=gas.specific_internal_energy(self.temperature_k),
h=gas.specific_enthalpy(self.temperature_k),
)
@dataclass(frozen=True)
class TestMqlPneumaticChamberSegmentComponents:
volume: AmesimPneumaticVolume
inlet_orifice: AmesimPneumaticOrifice
outlet_orifice: AmesimPneumaticOrifice
spec: TestMqlPneumaticChamberSegmentSpec
@dataclass(frozen=True)
class TestMqlPneumaticChamberSegmentSnapshot:
chamber: ThermodynamicProperties
inlet_boundary: ThermodynamicProperties
outlet_boundary: ThermodynamicProperties
inlet_flow: float
outlet_flow: float
@dataclass(frozen=True) @dataclass(frozen=True)
class TestMqlPneumaticBranchComponents: class TestMqlPneumaticBranchComponents:
name: str name: str
@@ -184,3 +243,139 @@ class TestMqlPneumaticClosure:
*upstream_derivative.as_vector(), *upstream_derivative.as_vector(),
*downstream_derivative.as_vector(), *downstream_derivative.as_vector(),
] ]
class TestMqlPneumaticChamberSegmentClosure:
"""Boundary-reduced fixed chamber segment discovered from AMESim topology.
The two PNL0001 lines remain prescribed boundary interfaces in this first
closure. Their aliases are retained in the spec so line dynamics can be
inserted later without rediscovering the component path.
"""
def __init__(
self,
*,
components: TestMqlPneumaticChamberSegmentComponents,
inlet_boundary: TestMqlPneumaticBoundaryCondition,
outlet_boundary: TestMqlPneumaticBoundaryCondition,
) -> None:
self.components = components
self.inlet_boundary = inlet_boundary
self.outlet_boundary = outlet_boundary
def initial_state_vector(self) -> list[float]:
return self.components.volume.get_state_vector()
def apply_state_vector(self, values: list[float]) -> None:
if len(values) != 2:
raise ValueError("single-chamber segment state vector requires two values")
self.components.volume.set_state_vector(values)
def snapshot(
self,
state_vector: list[float] | None = None,
) -> TestMqlPneumaticChamberSegmentSnapshot:
if state_vector is not None:
self.apply_state_vector(state_vector)
chamber = self.components.volume.properties()
inlet_boundary = self.inlet_boundary.properties(self.components.volume.gas)
outlet_boundary = self.outlet_boundary.properties(self.components.volume.gas)
inlet_temperature = (
inlet_boundary.T if inlet_boundary.p >= chamber.p else chamber.T
)
outlet_temperature = (
chamber.T if chamber.p >= outlet_boundary.p else outlet_boundary.T
)
inlet_flow = self.components.inlet_orifice.mass_flow(
inlet_boundary.p,
chamber.p,
inlet_temperature,
)
outlet_flow = self.components.outlet_orifice.mass_flow(
chamber.p,
outlet_boundary.p,
outlet_temperature,
)
snapshot = TestMqlPneumaticChamberSegmentSnapshot(
chamber=chamber,
inlet_boundary=inlet_boundary,
outlet_boundary=outlet_boundary,
inlet_flow=inlet_flow,
outlet_flow=outlet_flow,
)
self._write_port_states(snapshot)
return snapshot
@staticmethod
def _port(
component: AmesimPneumaticVolume | AmesimPneumaticOrifice,
port_name: str,
) -> PortState:
if port_name == "port_1":
return component.port_a
if port_name == "port_2":
return component.port_b
raise ValueError(f"unsupported pneumatic port: {port_name}")
def _write_port_states(
self,
snapshot: TestMqlPneumaticChamberSegmentSnapshot,
) -> None:
spec = self.components.spec
chamber = self.components.volume
inlet_orifice = self.components.inlet_orifice
outlet_orifice = self.components.outlet_orifice
inlet_boundary_port = self._port(
inlet_orifice,
spec.inlet_orifice_boundary_port,
)
inlet_volume_port = self._port(inlet_orifice, spec.inlet_orifice_volume_port)
inlet_boundary_port.p = snapshot.inlet_boundary.p
inlet_boundary_port.m_flow = snapshot.inlet_flow
inlet_boundary_port.h_outflow = snapshot.inlet_boundary.h
inlet_volume_port.p = snapshot.chamber.p
inlet_volume_port.m_flow = -snapshot.inlet_flow
inlet_volume_port.h_outflow = snapshot.chamber.h
chamber_inlet_port = self._port(chamber, spec.volume_inlet_port)
chamber_outlet_port = self._port(chamber, spec.volume_outlet_port)
chamber_inlet_port.m_flow = snapshot.inlet_flow
chamber_outlet_port.m_flow = -snapshot.outlet_flow
outlet_volume_port = self._port(
outlet_orifice,
spec.outlet_orifice_volume_port,
)
outlet_boundary_port = self._port(
outlet_orifice,
spec.outlet_orifice_boundary_port,
)
outlet_volume_port.p = snapshot.chamber.p
outlet_volume_port.m_flow = snapshot.outlet_flow
outlet_volume_port.h_outflow = snapshot.chamber.h
outlet_boundary_port.p = snapshot.outlet_boundary.p
outlet_boundary_port.m_flow = -snapshot.outlet_flow
outlet_boundary_port.h_outflow = snapshot.outlet_boundary.h
def rhs(self, state_vector: list[float]) -> list[float]:
snapshot = self.snapshot(state_vector)
spec = self.components.spec
chamber = self.components.volume
derivative = chamber.derivatives_from_two_connections(
port_a_m_flow=self._port(chamber, "port_1").m_flow,
connected_h_a=(
snapshot.inlet_boundary.h
if spec.volume_inlet_port == "port_1"
else snapshot.outlet_boundary.h
),
port_b_m_flow=self._port(chamber, "port_2").m_flow,
connected_h_b=(
snapshot.inlet_boundary.h
if spec.volume_inlet_port == "port_2"
else snapshot.outlet_boundary.h
),
internal_h=snapshot.chamber.h,
)
return derivative.as_vector()
@@ -0,0 +1,128 @@
from __future__ import annotations
from PythonModels.systems.test_mql_closure import TestMqlPneumaticChamberSegmentSpec
from PythonModels.systems.test_mql_topology import TestMqlCirTopology
def discover_fixed_chamber_segments(
topology: TestMqlCirTopology,
component_specs: list[dict[str, object]],
connection_specs: list[dict[str, object]],
) -> tuple[TestMqlPneumaticChamberSegmentSpec, ...]:
submodel_by_alias = {
str(component["alias"]): str(component["submodel"])
for component in component_specs
}
segments = []
for component in component_specs:
volume_alias = str(component["alias"])
if component["submodel"] != "PNCH023":
continue
orifice_contacts = []
for contact in topology.contacts_for(volume_alias):
other_alias, other_port = contact.other_endpoint(volume_alias)
if submodel_by_alias.get(other_alias) == "PNOR001":
orifice_contacts.append(
(
other_alias,
other_port,
contact.port_for(volume_alias),
)
)
if len(orifice_contacts) != 2:
raise ValueError(
f"{volume_alias} must contact exactly two PNOR001 orifices; "
f"found {len(orifice_contacts)}"
)
sides = [
_resolve_orifice_boundary(
orifice_alias=orifice_alias,
orifice_volume_port=orifice_volume_port,
volume_port=volume_port,
connection_specs=connection_specs,
submodel_by_alias=submodel_by_alias,
)
for orifice_alias, orifice_volume_port, volume_port in orifice_contacts
]
inlet_sides = [side for side in sides if side["role"] == "inlet"]
outlet_sides = [side for side in sides if side["role"] == "outlet"]
if len(inlet_sides) != 1 or len(outlet_sides) != 1:
raise ValueError(
f"{volume_alias} requires one inlet and one outlet topology side"
)
inlet = inlet_sides[0]
outlet = outlet_sides[0]
segments.append(
TestMqlPneumaticChamberSegmentSpec(
name=f"{volume_alias}_segment",
inlet_node_alias=inlet["node_alias"],
inlet_line_alias=inlet["line_alias"],
inlet_orifice_alias=inlet["orifice_alias"],
inlet_orifice_boundary_port=inlet["orifice_boundary_port"],
inlet_orifice_volume_port=inlet["orifice_volume_port"],
volume_alias=volume_alias,
volume_inlet_port=inlet["volume_port"],
volume_outlet_port=outlet["volume_port"],
outlet_orifice_alias=outlet["orifice_alias"],
outlet_orifice_volume_port=outlet["orifice_volume_port"],
outlet_orifice_boundary_port=outlet["orifice_boundary_port"],
outlet_line_alias=outlet["line_alias"],
outlet_node_alias=outlet["node_alias"],
)
)
return tuple(segments)
def _resolve_orifice_boundary(
*,
orifice_alias: str,
orifice_volume_port: str,
volume_port: str,
connection_specs: list[dict[str, object]],
submodel_by_alias: dict[str, str],
) -> dict[str, str]:
boundary_connections = []
for connection in connection_specs:
if (
connection["source_component"] == orifice_alias
and connection["source_port"] != orifice_volume_port
) or (
connection["target_component"] == orifice_alias
and connection["target_port"] != orifice_volume_port
):
boundary_connections.append(connection)
if len(boundary_connections) != 1:
raise ValueError(
f"{orifice_alias} must have exactly one non-volume boundary connection; "
f"found {len(boundary_connections)}"
)
connection = boundary_connections[0]
if connection["submodel"] != "PNL0001":
raise ValueError(
f"{orifice_alias} boundary must use PNL0001, got {connection['submodel']}"
)
if connection["target_component"] == orifice_alias:
role = "inlet"
node_alias = str(connection["source_component"])
orifice_boundary_port = str(connection["target_port"])
else:
role = "outlet"
node_alias = str(connection["target_component"])
orifice_boundary_port = str(connection["source_port"])
if submodel_by_alias.get(node_alias) != "PN3NODE2":
raise ValueError(
f"{orifice_alias} PNL0001 boundary must terminate at PN3NODE2, "
f"got {node_alias}"
)
return {
"role": role,
"node_alias": node_alias,
"line_alias": str(connection["alias"]),
"orifice_alias": orifice_alias,
"orifice_boundary_port": orifice_boundary_port,
"orifice_volume_port": orifice_volume_port,
"volume_port": volume_port,
}
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
import re
import tarfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class TestMqlComponentContact:
component_a: str
port_a: str
component_b: str
port_b: str
def other_endpoint(self, component_alias: str) -> tuple[str, str]:
if component_alias == self.component_a:
return self.component_b, self.port_b
if component_alias == self.component_b:
return self.component_a, self.port_a
raise KeyError(component_alias)
def port_for(self, component_alias: str) -> str:
if component_alias == self.component_a:
return self.port_a
if component_alias == self.component_b:
return self.port_b
raise KeyError(component_alias)
@dataclass(frozen=True)
class TestMqlCirTopology:
component_contacts: tuple[TestMqlComponentContact, ...]
def contacts_for(self, component_alias: str) -> tuple[TestMqlComponentContact, ...]:
return tuple(
contact
for contact in self.component_contacts
if component_alias in (contact.component_a, contact.component_b)
)
def load_test_mql_cir_topology(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> TestMqlCirTopology:
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
root = ET.fromstring(_topology_only_xml(cir_text))
components = root.findall(".//COMPS_LIST/COMP")
aliases = tuple(_required_text(component, "ALIAS") for component in components)
contacts: dict[
tuple[tuple[int, int], tuple[int, int]],
TestMqlComponentContact,
] = {}
directed_contacts: set[tuple[tuple[int, int], tuple[int, int]]] = set()
for component_index, component in enumerate(components):
ports = component.findall("./COMP_PORTS_LIST/COMP_PORT")
for port_index, port in enumerate(ports):
if port.findtext("PORT_CONNECT") != "1":
continue
for connection in port.findall("./CONNECT_LIST/CONNECT"):
target_index = int(_required_text(connection, "CONNECT_ENTITY_NUM"))
target_port_index = int(_required_text(connection, "CONNECT_ENTITY_PORT"))
if target_index < 0 or target_index >= len(components):
raise ValueError(f"Component contact references unknown entity {target_index}")
target_ports = components[target_index].findall("./COMP_PORTS_LIST/COMP_PORT")
if target_port_index < 0 or target_port_index >= len(target_ports):
raise ValueError(
f"Component contact references unknown port {target_port_index} "
f"on {aliases[target_index]}"
)
endpoint = (component_index, port_index)
target_endpoint = (target_index, target_port_index)
directed_contacts.add((endpoint, target_endpoint))
key = tuple(sorted((endpoint, target_endpoint)))
first, second = key
contacts[key] = TestMqlComponentContact(
component_a=aliases[first[0]],
port_a=f"port_{first[1] + 1}",
component_b=aliases[second[0]],
port_b=f"port_{second[1] + 1}",
)
for endpoint, target_endpoint in directed_contacts:
if (target_endpoint, endpoint) not in directed_contacts:
raise ValueError(
"AMESim component contact is not reciprocal: "
f"{endpoint} -> {target_endpoint}"
)
return TestMqlCirTopology(component_contacts=tuple(contacts.values()))
def _topology_only_xml(cir_text: str) -> str:
# AMESim expressions inside SUBMODEL contain unescaped && and <= operators.
# Topology lives outside those blocks, so omit them before XML parsing.
return re.sub(
r"<SUBMODEL>.*?</SUBMODEL>",
"<SUBMODEL />",
cir_text,
flags=re.DOTALL,
)
def _required_text(element: ET.Element, child_name: str) -> str:
value = element.findtext(child_name)
if value is None:
raise ValueError(f"Missing AMESim circuit element: {child_name}")
return value
+24
View File
@@ -55,6 +55,30 @@ class AmesimPneumaticComponentsTest(unittest.TestCase):
) )
self.assertGreater(volume.gas_mass_g(), 1300.0) self.assertGreater(volume.gas_mass_g(), 1300.0)
def test_volume_balances_two_connections_with_stream_enthalpy(self) -> None:
volume = AmesimPneumaticVolume.from_liters(
name="pn_general_chamber",
volume_liters=57.0,
p0=15.3e6,
T0=293.15,
)
internal_h = volume.properties().h
derivative = volume.derivatives_from_two_connections(
port_a_m_flow=0.2,
connected_h_a=internal_h + 1000.0,
port_b_m_flow=-0.1,
connected_h_b=internal_h - 1000.0,
internal_h=internal_h,
)
self.assertAlmostEqual(derivative.m, 0.1)
self.assertAlmostEqual(
derivative.U,
0.2 * (internal_h + 1000.0) - 0.1 * internal_h,
)
self.assertAlmostEqual(volume.port_a.p, volume.port_b.p)
def test_variable_volume_tracks_external_volume(self) -> None: def test_variable_volume_tracks_external_volume(self) -> None:
volume = AmesimVariablePneumaticVolume.from_liters( volume = AmesimVariablePneumaticVolume.from_liters(
name="pn_c1_8", name="pn_c1_8",
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import unittest
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.systems.test_mql import TestMqlSystem
class TestMqlPneumaticChamberSegmentTests(unittest.TestCase):
def setUp(self) -> None:
self.system = TestMqlSystem()
self.discovery = self.system.discover_pneumatic_branch_topology()
self.spec = self.discovery.chamber_segment_specs[0]
def test_discovers_four_fixed_chamber_segments_from_real_topology(self) -> None:
self.assertEqual(len(self.discovery.chamber_segment_specs), 4)
self.assertEqual(self.spec.volume_alias, "pn_general_chamber")
self.assertEqual(self.spec.inlet_node_alias, "pn_node3_8")
self.assertEqual(self.spec.inlet_line_alias, "pneumatic_96")
self.assertEqual(self.spec.inlet_orifice_alias, "pn_orifice_18")
self.assertEqual(self.spec.volume_inlet_port, "port_2")
self.assertEqual(self.spec.outlet_orifice_alias, "pn_orifice_19")
self.assertEqual(self.spec.outlet_line_alias, "pneumatic_97")
self.assertEqual(self.spec.outlet_node_alias, "pn_node3_9")
self.assertEqual(self.spec.source, "amesim-cir-topology")
def test_segment_closure_writes_real_amesim_port_directions(self) -> None:
closure = self.system.pneumatic_chamber_segment_closure_from_spec(
self.spec,
inlet_pressure_pa=16.0e6,
outlet_pressure_pa=1.0e6,
)
snapshot = closure.snapshot()
rhs = closure.rhs(closure.initial_state_vector())
self.assertGreater(snapshot.inlet_flow, 0.0)
self.assertGreater(snapshot.outlet_flow, 0.0)
self.assertAlmostEqual(
closure.components.volume.port_b.m_flow,
snapshot.inlet_flow,
)
self.assertAlmostEqual(
closure.components.volume.port_a.m_flow,
-snapshot.outlet_flow,
)
self.assertAlmostEqual(
closure.components.inlet_orifice.port_b.m_flow,
snapshot.inlet_flow,
)
self.assertAlmostEqual(
closure.components.outlet_orifice.port_a.m_flow,
-snapshot.outlet_flow,
)
self.assertEqual(len(rhs), 2)
self.assertAlmostEqual(rhs[0], snapshot.inlet_flow - snapshot.outlet_flow)
def test_segment_closure_handles_reverse_boundary_pressures(self) -> None:
closure = self.system.pneumatic_chamber_segment_closure_from_spec(
self.spec,
inlet_pressure_pa=1.0e6,
outlet_pressure_pa=16.0e6,
)
snapshot = closure.snapshot()
rhs = closure.rhs(closure.initial_state_vector())
self.assertLess(snapshot.inlet_flow, 0.0)
self.assertLess(snapshot.outlet_flow, 0.0)
self.assertAlmostEqual(rhs[0], snapshot.inlet_flow - snapshot.outlet_flow)
def test_simulates_topology_derived_segment_with_prescribed_boundaries(self) -> None:
solution = self.system.simulate_pneumatic_chamber_segment_from_spec(
self.spec,
inlet_pressure_pa=16.0e6,
outlet_pressure_pa=14.0e6,
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0e-5,
max_step=1.0e-6,
),
t_eval=[0.0, 5.0e-6, 1.0e-5],
)
self.assertTrue(solution.success)
self.assertEqual(len(solution.t), 3)
self.assertEqual(len(solution.y), 2)
self.assertEqual([len(row) for row in solution.y], [3, 3])
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import unittest
from pathlib import Path
from PythonModels.systems.test_mql_topology import load_test_mql_cir_topology
REPO_ROOT = Path(__file__).resolve().parents[1]
TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class TestMqlCirTopologyTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.topology = load_test_mql_cir_topology(TEST_MQL_AME)
def test_recovers_direct_component_contacts_omitted_from_line_specs(self) -> None:
self.assertEqual(len(self.topology.component_contacts), 54)
contacts = self.topology.contacts_for("pn_general_chamber")
self.assertEqual(len(contacts), 2)
self.assertEqual(
{
(
contact.port_for("pn_general_chamber"),
*contact.other_endpoint("pn_general_chamber"),
)
for contact in contacts
},
{
("port_1", "pn_orifice_19", "port_2"),
("port_2", "pn_orifice_18", "port_1"),
},
)
def test_component_contact_lookup_rejects_unrelated_alias(self) -> None:
contact = self.topology.contacts_for("pn_general_chamber")[0]
with self.assertRaises(KeyError):
contact.other_endpoint("not-an-endpoint")
if __name__ == "__main__":
unittest.main()