merge/model-development-into-main #2
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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user