From 3f51a124d68acaaaaa09a0db6c551287b59e3bce Mon Sep 17 00:00:00 2001 From: huojiarong Date: Thu, 30 Jul 2026 10:11:08 +0000 Subject: [PATCH] =?UTF-8?q?=E5=85=AC=E5=BC=80=E6=9B=B4=E5=A4=9A=20AMESim?= =?UTF-8?q?=20=E7=BB=84=E4=BB=B6=E5=B9=B6=E6=8E=A5=E5=85=A5=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=E6=9C=BA=E6=A2=B0=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/amesim/flow/orifices.py | 91 ++ .../components/amesim/flow/pipes.py | 890 +++++++++++++++++- app/simulation/components/amesim/library.py | 12 + .../components/amesim/mechanical/__init__.py | 1 + .../amesim/mechanical/translational.py | 293 ++++++ .../components/amesim/signals/__init__.py | 0 .../components/amesim/signals/sources.py | 204 ++++ .../components/amesim/storage/chambers.py | 271 ++++++ app/simulation/core/metadata.py | 7 + app/simulation/core/ports.py | 74 ++ app/simulation/solvers/algebraic.py | 58 +- app/simulation/solvers/signal.py | 62 ++ app/simulation/systems/generic.py | 33 +- docs/amesim-component-migration-matrix.md | 41 +- .../test_amesim_component_migration_matrix.py | 5 - ...est_amesim_mechanical_public_components.py | 69 ++ tests/test_amesim_mechanical_xml.py | 166 ++++ tests/test_amesim_pnch012_component.py | 120 +++ tests/test_amesim_pnch012_xml.py | 93 ++ tests/test_amesim_pnl0001_component.py | 139 +++ tests/test_amesim_pnl0001_xml.py | 101 ++ .../test_amesim_pnl0002_pnl0003_component.py | 160 ++++ tests/test_amesim_pnl0002_pnl0003_xml.py | 159 ++++ tests/test_amesim_pnvo001_signal_xml.py | 112 +++ tests/test_amesim_signal_components.py | 97 ++ tests/test_amesim_ud00_xml.py | 120 +++ tests/test_component_catalog.py | 57 +- tests/test_component_registry.py | 20 + 28 files changed, 3397 insertions(+), 58 deletions(-) create mode 100644 app/simulation/components/amesim/mechanical/__init__.py create mode 100644 app/simulation/components/amesim/mechanical/translational.py create mode 100644 app/simulation/components/amesim/signals/__init__.py create mode 100644 app/simulation/components/amesim/signals/sources.py create mode 100644 app/simulation/solvers/signal.py create mode 100644 tests/test_amesim_mechanical_public_components.py create mode 100644 tests/test_amesim_mechanical_xml.py create mode 100644 tests/test_amesim_pnch012_component.py create mode 100644 tests/test_amesim_pnch012_xml.py create mode 100644 tests/test_amesim_pnl0001_component.py create mode 100644 tests/test_amesim_pnl0001_xml.py create mode 100644 tests/test_amesim_pnl0002_pnl0003_component.py create mode 100644 tests/test_amesim_pnl0002_pnl0003_xml.py create mode 100644 tests/test_amesim_pnvo001_signal_xml.py create mode 100644 tests/test_amesim_signal_components.py create mode 100644 tests/test_amesim_ud00_xml.py diff --git a/app/simulation/components/amesim/flow/orifices.py b/app/simulation/components/amesim/flow/orifices.py index 96f4066..eef5d17 100644 --- a/app/simulation/components/amesim/flow/orifices.py +++ b/app/simulation/components/amesim/flow/orifices.py @@ -608,3 +608,94 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent): def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: self.port_2.h_outflow = connected_h["port_3"] self.port_3.h_outflow = connected_h["port_2"] + + +class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening): + """AMESim PNVO001 signal-controlled pneumatic orifice.""" + + MODEL_TYPE = "amesim_pnvo001" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.signal("res", nominal_role="input"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition("cq", 0.72, label="流量系数 Cq", quantity="dimensionless", unit="", minimum=1.0e-10, maximum=1.0), + ParameterDefinition("area0", 5.0e-6, label="最大孔口面积", quantity="area", unit="m2", minimum=0.0, maximum=1.0), + ParameterDefinition("Cv", 0.5, label="最大流量系数 Cv", quantity="dimensionless", unit="", minimum=0.0), + ParameterDefinition("Kv", 0.4, label="最大流量系数 Kv", quantity="dimensionless", unit="", minimum=0.0), + ParameterDefinition("gi", 1.0, label="气体类型索引", quantity="dimensionless", unit="", minimum=1.0, maximum=99.0), + ParameterDefinition("flowset", 1.0, label="流量系数设置", quantity="dimensionless", unit="", minimum=1.0, maximum=3.0), + ParameterDefinition("opening0", 1.0, label="初始开度", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0), + ) + RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES + DISPLAY = ComponentDisplaySpec( + label="PNVO001 信号开度气动孔口", + library_id="amesim", + category_id="flow", + symbol="orifice", + ports=( + PortDisplaySpec("res", "left", order=5), + PortDisplaySpec("port_2", "left", order=10), + PortDisplaySpec("port_3", "right", order=20), + ), + order=35, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + cq: float = 0.72, + area0: float = 5.0e-6, + Cv: float = 0.5, + Kv: float = 0.4, + gi: float = 1.0, + flowset: float = 1.0, + opening0: float = 1.0, + ) -> None: + AlgebraicComponent.__init__(self, name=name) + self.set_parameter_values( + { + "cq": cq, + "area0": area0, + "Cv": Cv, + "Kv": Kv, + "gi": gi, + "flowset": flowset, + "opening0": opening0, + } + ) + self.medium = medium + self.cq = float(cq) + self.area0 = float(area0) + self.Cv = float(Cv) + self.Kv = float(Kv) + self.gi = self._integer_parameter("gi", gi) + self.flowset = self._integer_parameter("flowset", flowset) + if self.flowset not in {1, 2, 3}: + raise ValueError("PNVO001 signal-opening flowset must be 1, 2, or 3.") + self.opening0 = min(1.0, max(0.0, float(opening0))) + self.res = self.register_declared_port("res") + self.res.signal = self.opening0 + initial_h = medium.specific_enthalpy(medium.T_ref) + self.port_2 = self.register_declared_port("port_2") + self.port_2.h_outflow = initial_h + self.port_3 = self.register_declared_port("port_3") + self.port_3.h_outflow = initial_h + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimPnvo001SignalOpening": + return cls(name=name, medium=medium, **dict(parameters)) + + @property + def opening(self) -> float: + return min(1.0, max(0.0, self.res.signal)) diff --git a/app/simulation/components/amesim/flow/pipes.py b/app/simulation/components/amesim/flow/pipes.py index 625daa8..4f8fb31 100644 --- a/app/simulation/components/amesim/flow/pipes.py +++ b/app/simulation/components/amesim/flow/pipes.py @@ -3,15 +3,17 @@ from __future__ import annotations from collections.abc import Mapping from math import isclose, log10, pi, sqrt -from app.simulation.core.base import AlgebraicComponent +from app.simulation.core.base import AlgebraicComponent, DynamicComponent, ThermodynamicVolumeComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec from app.simulation.core.equations import EquationResidual from app.simulation.core.metadata import ( ParameterDefinition, ResultVariableDefinition, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, ) -from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties from app.simulation.core.ports import PortDefinition +from app.simulation.core.state import VolumeState class AmesimPnl00r(AlgebraicComponent): @@ -308,3 +310,887 @@ class AmesimPnl00r(AlgebraicComponent): def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: self.port_1.h_outflow = connected_h["port_2"] self.port_2.h_outflow = connected_h["port_1"] + + +class AmesimPnl0001(ThermodynamicVolumeComponent): + """AMESim PNL0001 C-R pneumatic pipe with compressibility and friction.""" + + MODEL_TYPE = "amesim_pnl0001" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + "diam", + 0.01, + label="管径", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "le", + 1.0, + label="管长", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "rr", + 1.0e-5, + label="相对粗糙度", + quantity="dimensionless", + unit="", + minimum=0.0, + maximum=0.1, + ), + ParameterDefinition( + "k", + 1.35, + label="多方指数", + quantity="dimensionless", + unit="", + minimum=0.0, + minimum_exclusive=True, + maximum=2.0, + ), + ParameterDefinition( + "kth", + 0.0, + label="换热系数", + quantity="heat_transfer_coefficient", + unit="W/(m2*K)", + minimum=0.0, + ), + ParameterDefinition( + "extemp", + 293.15, + label="外部温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "gi", + 1.0, + label="气体类型索引", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=99.0, + ), + ParameterDefinition( + "mode", + 2.0, + label="热模型", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=2.0, + ), + ParameterDefinition( + "p0", + 100000.0, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 293.15, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + ( + ResultVariableDefinition( + "re", + label="Reynolds 数", + quantity="dimensionless", + unit="", + category="derived", + order=100, + ), + ResultVariableDefinition( + "cm", + label="质量流量参数", + quantity="dimensionless", + unit="", + category="derived", + order=110, + ), + ResultVariableDefinition( + "v", + label="平均气体速度", + quantity="velocity", + unit="m/s", + category="derived", + order=120, + ), + ResultVariableDefinition( + "ff", + label="摩擦因子", + quantity="dimensionless", + unit="", + category="derived", + order=130, + ), + ) + DISPLAY = ComponentDisplaySpec( + label="PNL0001 C-R 动态管路", + library_id="amesim", + category_id="flow", + symbol="pipe", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=30, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + diam: float = 0.01, + le: float = 1.0, + rr: float = 1.0e-5, + k: float = 1.35, + kth: float = 0.0, + extemp: float = 293.15, + gi: float = 1.0, + mode: float = 2.0, + p0: float = 100000.0, + T0: float = 293.15, + ) -> None: + super().__init__(name=name) + self.set_parameter_values( + { + "diam": diam, + "le": le, + "rr": rr, + "k": k, + "kth": kth, + "extemp": extemp, + "gi": gi, + "mode": mode, + "p0": p0, + "T0": T0, + } + ) + self.medium = medium + self.diam = float(diam) + self.le = float(le) + self.rr = float(rr) + self.k = float(k) + self.kth = float(kth) + self.extemp = float(extemp) + self.gi = self._integer_parameter("gi", gi) + self.mode = self._integer_parameter("mode", mode) + self.p0 = float(p0) + self.T0 = float(T0) + self.area = pi * self.diam * self.diam / 4.0 + self.volume = self.area * self.le + self.exchange_area = pi * self.diam * self.le + m0 = self.p0 * self.volume / (medium.R_gas * self.T0) + U0 = m0 * medium.specific_internal_energy(self.T0) + self.state = VolumeState(m=m0, U=U0) + initial_h = medium.specific_enthalpy(self.T0) + self.port_1 = self.register_declared_port("port_1") + self.port_1.p = self.p0 + self.port_1.h_outflow = initial_h + self.port_2 = self.register_declared_port("port_2") + self.port_2.p = self.p0 + self.port_2.h_outflow = initial_h + + @staticmethod + def _integer_parameter(name: str, value: float) -> int: + rounded = round(value) + if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12): + raise ValueError(f"PNL0001 parameter {name} must be an integer value.") + return int(rounded) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimPnl0001": + return cls( + name=name, + medium=medium, + diam=parameters["diam"], + le=parameters["le"], + rr=parameters["rr"], + k=parameters["k"], + kth=parameters["kth"], + extemp=parameters["extemp"], + gi=parameters["gi"], + mode=parameters["mode"], + p0=parameters["p0"], + T0=parameters["T0"], + ) + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) + self.port_1.h_outflow = props.h + self.port_2.p = props.p + self.port_2.h_outflow = props.h + return props + + def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: + return self.properties() + + def thermal_energy_flow_w(self, temperature: float) -> float: + if self.mode == 1: + return 0.0 + return self.kth * self.exchange_area * (self.extemp - temperature) + + @staticmethod + def _dynamic_viscosity(temperature_k: float) -> float: + return AmesimPnl00r._dynamic_viscosity(temperature_k) + + def reynolds_number(self, mass_flow: float, temperature: float) -> float: + viscosity = self._dynamic_viscosity(temperature) + return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity) + + def friction_factor(self, reynolds_number: float) -> float: + return AmesimPnl00r.friction_factor(self, reynolds_number) + + def darcy_pressure_drop( + self, + mass_flow: float, + *, + density: float, + temperature: float, + ) -> float: + if mass_flow == 0.0: + return 0.0 + reynolds = self.reynolds_number(mass_flow, temperature) + friction = self.friction_factor(reynolds) + velocity = mass_flow / (density * self.area) + magnitude = ( + friction + * (self.le / self.diam) + * density + * velocity + * velocity + / 2.0 + ) + return magnitude if mass_flow > 0.0 else -magnitude + + def _mass_flow_for_pressure_drop( + self, + pressure_drop: float, + *, + density: float, + temperature: float, + ) -> float: + if pressure_drop <= 0.0: + return 0.0 + upper = 1.0e-9 + while self.darcy_pressure_drop( + upper, + density=density, + temperature=temperature, + ) < pressure_drop: + upper *= 10.0 + if upper > 1.0e3: + raise ValueError("unable to bracket PNL0001 resistance flow") + lower = 0.0 + for _ in range(48): + middle = 0.5 * (lower + upper) + if self.darcy_pressure_drop( + middle, + density=density, + temperature=temperature, + ) < pressure_drop: + lower = middle + else: + upper = middle + return 0.5 * (lower + upper) + + def mass_flow(self, p_1: float, p_2: float, temperature: float) -> float: + if p_1 == p_2: + return 0.0 + pressure_difference = p_1 - p_2 + upstream_pressure = max(p_1, p_2, 1.0) + density = max(self.medium.density(upstream_pressure, temperature), 1.0e-12) + magnitude = self._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=density, + temperature=temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def component_result_values(self) -> Mapping[str, float]: + props = self.properties() + flow = self.mass_flow(self.port_1.p, props.p, props.T) + upstream_pressure = max(self.port_1.p, props.p, 1.0) + density = max(self.medium.density(upstream_pressure, props.T), 1.0e-12) + reynolds = self.reynolds_number(flow, props.T) + return { + "m": self.state.m, + "U": self.state.U, + "p": props.p, + "T": props.T, + "rho": props.rho, + "u": props.u, + "h": props.h, + "re": reynolds, + "cm": abs(flow) / max(self.area * upstream_pressure, 1.0e-18), + "v": flow / (density * self.area), + "ff": self.friction_factor(reynolds), + } + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) + return ( + EquationResidual( + id=f"{self.name}:port_2_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.port_2.p", f"{self.name}.state"), + role="effort", + value=self.port_2.p - props.p, + ), + EquationResidual( + id=f"{self.name}:port_1_pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_1.p", + f"{self.name}.port_2.p", + f"{self.name}.port_1.m_flow", + ), + role="flow", + value=self.port_1.m_flow + - self.mass_flow(self.port_1.p, props.p, props.T), + ), + ) + + def state_derivative_from_ports( + self, + connected_h: Mapping[str, float], + ) -> list[float]: + props = self.properties() + inlet_h_1 = self.connection_inlet_enthalpy( + port_m_flow=self.port_1.m_flow, + connected_h=connected_h["port_1"], + internal_h=props.h, + ) + inlet_h_2 = self.connection_inlet_enthalpy( + port_m_flow=self.port_2.m_flow, + connected_h=connected_h["port_2"], + internal_h=props.h, + ) + derivative = VolumeState( + m=self.port_1.m_flow + self.port_2.m_flow, + U=( + self.port_1.m_flow * inlet_h_1 + + self.port_2.m_flow * inlet_h_2 + + self.thermal_energy_flow_w(props.T) + ), + ) + return derivative.as_vector() + + +class AmesimPnl0002(AmesimPnl0001): + """AMESim PNL0002 R-C-R pneumatic pipe with one center compliance.""" + + MODEL_TYPE = "amesim_pnl0002" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + ) + PARAMETERS = AmesimPnl0001.PARAMETERS + RESULT_VARIABLES = AmesimPnl0001.RESULT_VARIABLES + DISPLAY = ComponentDisplaySpec( + label="PNL0002 R-C-R 动态管路", + library_id="amesim", + category_id="flow", + symbol="pipe", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=40, + ) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimPnl0002": + return cls( + name=name, + medium=medium, + diam=parameters["diam"], + le=parameters["le"], + rr=parameters["rr"], + k=parameters["k"], + kth=parameters["kth"], + extemp=parameters["extemp"], + gi=parameters["gi"], + mode=parameters["mode"], + p0=parameters["p0"], + T0=parameters["T0"], + ) + + @property + def resistance_length(self) -> float: + return self.le / 2.0 + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) + self.port_1.h_outflow = props.h + self.port_2.h_outflow = props.h + return props + + def darcy_pressure_drop( + self, + mass_flow: float, + *, + density: float, + temperature: float, + ) -> float: + if mass_flow == 0.0: + return 0.0 + reynolds = self.reynolds_number(mass_flow, temperature) + friction = self.friction_factor(reynolds) + velocity = mass_flow / (density * self.area) + magnitude = ( + friction + * (self.resistance_length / self.diam) + * density + * velocity + * velocity + / 2.0 + ) + return magnitude if mass_flow > 0.0 else -magnitude + + def port_mass_flow( + self, + port_pressure: float, + center_pressure: float, + center_temperature: float, + ) -> float: + return self.mass_flow(port_pressure, center_pressure, center_temperature) + + def component_result_values(self) -> Mapping[str, float]: + props = self.properties() + flow_1 = self.port_mass_flow(self.port_1.p, props.p, props.T) + flow_2 = self.port_mass_flow(self.port_2.p, props.p, props.T) + diagnostic_flow = flow_1 if abs(flow_1) >= abs(flow_2) else flow_2 + upstream_pressure = max(self.port_1.p, self.port_2.p, props.p, 1.0) + density = max(self.medium.density(upstream_pressure, props.T), 1.0e-12) + reynolds = self.reynolds_number(diagnostic_flow, props.T) + return { + "m": self.state.m, + "U": self.state.U, + "p": props.p, + "T": props.T, + "rho": props.rho, + "u": props.u, + "h": props.h, + "re": reynolds, + "cm": abs(diagnostic_flow) / max(self.area * upstream_pressure, 1.0e-18), + "v": diagnostic_flow / (density * self.area), + "ff": self.friction_factor(reynolds), + } + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) + return ( + EquationResidual( + id=f"{self.name}:port_1_pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_1.p", + f"{self.name}.state", + f"{self.name}.port_1.m_flow", + ), + role="flow", + value=self.port_1.m_flow + - self.port_mass_flow(self.port_1.p, props.p, props.T), + ), + EquationResidual( + id=f"{self.name}:port_2_pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_2.p", + f"{self.name}.state", + f"{self.name}.port_2.m_flow", + ), + role="flow", + value=self.port_2.m_flow + - self.port_mass_flow(self.port_2.p, props.p, props.T), + ), + ) + + +class AmesimPnl0003(DynamicComponent): + """AMESim PNL0003 C-R-C pneumatic pipe with two end compliances.""" + + state_size = 4 + MODEL_TYPE = "amesim_pnl0003" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + ) + PARAMETERS = AmesimPnl0001.PARAMETERS[:-2] + ( + ParameterDefinition( + "p1_0", + 100000.0, + label="端口 1 初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T1_0", + 293.15, + label="端口 1 初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "p2_0", + 100000.0, + label="端口 2 初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T2_0", + 293.15, + label="端口 2 初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = ( + ResultVariableDefinition("m1", "端口 1 侧质量", "mass", "kg", "state", 10), + ResultVariableDefinition("U1", "端口 1 侧内能", "internal_energy", "J", "state", 20), + ResultVariableDefinition("p1", "端口 1 侧压力", "pressure", "Pa", "thermodynamic", 30), + ResultVariableDefinition("T1", "端口 1 侧温度", "temperature", "K", "thermodynamic", 40), + ResultVariableDefinition("rho1", "端口 1 侧密度", "density", "kg/m³", "thermodynamic", 50), + ResultVariableDefinition("u1", "端口 1 侧比内能", "specific_internal_energy", "J/kg", "thermodynamic", 60), + ResultVariableDefinition("h1", "端口 1 侧比焓", "specific_enthalpy", "J/kg", "thermodynamic", 70), + ResultVariableDefinition("m2", "端口 2 侧质量", "mass", "kg", "state", 80), + ResultVariableDefinition("U2", "端口 2 侧内能", "internal_energy", "J", "state", 90), + ResultVariableDefinition("p2", "端口 2 侧压力", "pressure", "Pa", "thermodynamic", 100), + ResultVariableDefinition("T2", "端口 2 侧温度", "temperature", "K", "thermodynamic", 110), + ResultVariableDefinition("rho2", "端口 2 侧密度", "density", "kg/m³", "thermodynamic", 120), + ResultVariableDefinition("u2", "端口 2 侧比内能", "specific_internal_energy", "J/kg", "thermodynamic", 130), + ResultVariableDefinition("h2", "端口 2 侧比焓", "specific_enthalpy", "J/kg", "thermodynamic", 140), + ResultVariableDefinition("dmctr", "中心质量流量", "mass_flow", "kg/s", "derived", 150), + ResultVariableDefinition("re", "Reynolds 数", "dimensionless", "", "derived", 160), + ResultVariableDefinition("cm", "质量流量参数", "dimensionless", "", "derived", 170), + ResultVariableDefinition("v", "平均气体速度", "velocity", "m/s", "derived", 180), + ResultVariableDefinition("ff", "摩擦因子", "dimensionless", "", "derived", 190), + ) + DISPLAY = ComponentDisplaySpec( + label="PNL0003 C-R-C 动态管路", + library_id="amesim", + category_id="flow", + symbol="pipe", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=50, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + diam: float = 0.01, + le: float = 1.0, + rr: float = 1.0e-5, + k: float = 1.35, + kth: float = 0.0, + extemp: float = 293.15, + gi: float = 1.0, + mode: float = 2.0, + p1_0: float = 100000.0, + T1_0: float = 293.15, + p2_0: float = 100000.0, + T2_0: float = 293.15, + ) -> None: + super().__init__(name=name) + self.set_parameter_values( + { + "diam": diam, + "le": le, + "rr": rr, + "k": k, + "kth": kth, + "extemp": extemp, + "gi": gi, + "mode": mode, + "p1_0": p1_0, + "T1_0": T1_0, + "p2_0": p2_0, + "T2_0": T2_0, + } + ) + self.medium = medium + self.diam = float(diam) + self.le = float(le) + self.rr = float(rr) + self.k = float(k) + self.kth = float(kth) + self.extemp = float(extemp) + self.gi = AmesimPnl0001._integer_parameter("gi", gi) + self.mode = AmesimPnl0001._integer_parameter("mode", mode) + self.area = pi * self.diam * self.diam / 4.0 + self.volume = self.area * self.le + self.compliance_volume = self.volume / 2.0 + self.exchange_area = pi * self.diam * self.le + self.state_1 = self._initial_state(float(p1_0), float(T1_0)) + self.state_2 = self._initial_state(float(p2_0), float(T2_0)) + h1 = medium.specific_enthalpy(float(T1_0)) + h2 = medium.specific_enthalpy(float(T2_0)) + self.port_1 = self.register_declared_port("port_1") + self.port_1.p = float(p1_0) + self.port_1.h_outflow = h1 + self.port_2 = self.register_declared_port("port_2") + self.port_2.p = float(p2_0) + self.port_2.h_outflow = h2 + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimPnl0003": + return cls(name=name, medium=medium, **dict(parameters)) + + def _initial_state(self, pressure: float, temperature: float) -> VolumeState: + mass = pressure * self.compliance_volume / (self.medium.R_gas * temperature) + return VolumeState(m=mass, U=mass * self.medium.specific_internal_energy(temperature)) + + def get_state_vector(self) -> list[float]: + return [*self.state_1.as_vector(), *self.state_2.as_vector()] + + def set_state_vector(self, values: list[float]) -> None: + if len(values) != 4: + raise ValueError("PNL0003 state vector requires four values") + self.state_1 = VolumeState.from_vector(values[:2]) + self.state_2 = VolumeState.from_vector(values[2:]) + + def _properties(self, state: VolumeState) -> ThermodynamicProperties: + return self.medium.properties_from_mU(state.m, state.U, self.compliance_volume) + + def properties_1(self) -> ThermodynamicProperties: + props = self._properties(self.state_1) + self.port_1.p = props.p + self.port_1.h_outflow = props.h + return props + + def properties_2(self) -> ThermodynamicProperties: + props = self._properties(self.state_2) + self.port_2.p = props.p + self.port_2.h_outflow = props.h + return props + + def refresh_thermodynamic_ports(self) -> tuple[ThermodynamicProperties, ThermodynamicProperties]: + return self.properties_1(), self.properties_2() + + @staticmethod + def _dynamic_viscosity(temperature_k: float) -> float: + return AmesimPnl00r._dynamic_viscosity(temperature_k) + + def reynolds_number(self, mass_flow: float, temperature: float) -> float: + viscosity = self._dynamic_viscosity(temperature) + return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity) + + def friction_factor(self, reynolds_number: float) -> float: + return AmesimPnl00r.friction_factor(self, reynolds_number) + + def darcy_pressure_drop( + self, + mass_flow: float, + *, + density: float, + temperature: float, + ) -> float: + if mass_flow == 0.0: + return 0.0 + reynolds = self.reynolds_number(mass_flow, temperature) + friction = self.friction_factor(reynolds) + velocity = mass_flow / (density * self.area) + magnitude = friction * (self.le / self.diam) * density * velocity * velocity / 2.0 + return magnitude if mass_flow > 0.0 else -magnitude + + def _mass_flow_for_pressure_drop( + self, + pressure_drop: float, + *, + density: float, + temperature: float, + ) -> float: + if pressure_drop <= 0.0: + return 0.0 + upper = 1.0e-9 + while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop: + upper *= 10.0 + if upper > 1.0e3: + raise ValueError("unable to bracket PNL0003 resistance flow") + lower = 0.0 + for _ in range(48): + middle = 0.5 * (lower + upper) + if self.darcy_pressure_drop(middle, density=density, temperature=temperature) < pressure_drop: + lower = middle + else: + upper = middle + return 0.5 * (lower + upper) + + def resistance_mass_flow(self) -> float: + port_1 = self._properties(self.state_1) + port_2 = self._properties(self.state_2) + pressure_difference = port_1.p - port_2.p + if pressure_difference == 0.0: + return 0.0 + upstream = port_1 if pressure_difference > 0.0 else port_2 + magnitude = self._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=upstream.rho, + temperature=upstream.T, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def _heat_flow_each(self, temperature_1: float, temperature_2: float) -> float: + if self.mode == 1: + return 0.0 + return self.kth * self.exchange_area * (self.extemp - 0.5 * (temperature_1 + temperature_2)) / 2.0 + + def component_result_values(self) -> Mapping[str, float]: + port_1 = self.properties_1() + port_2 = self.properties_2() + center_flow = self.resistance_mass_flow() + upstream = port_1 if center_flow >= 0.0 else port_2 + reynolds = self.reynolds_number(center_flow, upstream.T) + return { + "m1": self.state_1.m, + "U1": self.state_1.U, + "p1": port_1.p, + "T1": port_1.T, + "rho1": port_1.rho, + "u1": port_1.u, + "h1": port_1.h, + "m2": self.state_2.m, + "U2": self.state_2.U, + "p2": port_2.p, + "T2": port_2.T, + "rho2": port_2.rho, + "u2": port_2.u, + "h2": port_2.h, + "dmctr": center_flow, + "re": reynolds, + "cm": abs(center_flow) / max(self.area * max(port_1.p, port_2.p, 1.0), 1.0e-18), + "v": center_flow / (max(upstream.rho, 1.0e-12) * self.area), + "ff": self.friction_factor(reynolds), + } + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + port_1 = self._properties(self.state_1) + port_2 = self._properties(self.state_2) + return ( + EquationResidual( + id=f"{self.name}:port_1_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.port_1.p", f"{self.name}.state"), + role="effort", + value=self.port_1.p - port_1.p, + ), + EquationResidual( + id=f"{self.name}:port_2_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.port_2.p", f"{self.name}.state"), + role="effort", + value=self.port_2.p - port_2.p, + ), + ) + + def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]: + port_1 = self.properties_1() + port_2 = self.properties_2() + center_flow = self.resistance_mass_flow() + heat_flow_each = self._heat_flow_each(port_1.T, port_2.T) + port_1_external_h = self.connection_inlet_enthalpy( + port_m_flow=self.port_1.m_flow, + connected_h=connected_h["port_1"], + internal_h=port_1.h, + ) + port_2_external_h = self.connection_inlet_enthalpy( + port_m_flow=self.port_2.m_flow, + connected_h=connected_h["port_2"], + internal_h=port_2.h, + ) + port_1_center_h = self.connection_inlet_enthalpy( + port_m_flow=-center_flow, + connected_h=port_2.h, + internal_h=port_1.h, + ) + port_2_center_h = self.connection_inlet_enthalpy( + port_m_flow=center_flow, + connected_h=port_1.h, + internal_h=port_2.h, + ) + d1 = VolumeState( + m=self.port_1.m_flow - center_flow, + U=self.port_1.m_flow * port_1_external_h - center_flow * port_1_center_h + heat_flow_each, + ) + d2 = VolumeState( + m=self.port_2.m_flow + center_flow, + U=self.port_2.m_flow * port_2_external_h + center_flow * port_2_center_h + heat_flow_each, + ) + return [*d1.as_vector(), *d2.as_vector()] diff --git a/app/simulation/components/amesim/library.py b/app/simulation/components/amesim/library.py index 70cedd9..1a2f1ed 100644 --- a/app/simulation/components/amesim/library.py +++ b/app/simulation/components/amesim/library.py @@ -18,13 +18,25 @@ LIBRARY = ComponentLibrarySpec( ComponentCategorySpec(id="flow", label="流动元件", order=20), ComponentCategorySpec(id="junctions", label="连接元件", order=30), ComponentCategorySpec(id="boundary", label="边界元件", order=40), + ComponentCategorySpec(id="signals", label="信号元件", order=50), + ComponentCategorySpec(id="mechanical", label="机械元件", order=60), ), models=( "app.simulation.components.amesim.boundary.sources:AmesimPnpl01", + "app.simulation.components.amesim.signals.sources:AmesimStep0", + "app.simulation.components.amesim.signals.sources:AmesimUd00", + "app.simulation.components.amesim.mechanical.translational:AmesimF000", + "app.simulation.components.amesim.mechanical.translational:AmesimForc", + "app.simulation.components.amesim.mechanical.translational:AmesimMecmas21", "app.simulation.components.amesim.storage.chambers:AmesimPnch023", + "app.simulation.components.amesim.storage.chambers:AmesimPnch012", "app.simulation.components.amesim.flow.orifices:AmesimPnor001", "app.simulation.components.amesim.flow.orifices:AmesimPnvo001FixedOpening", + "app.simulation.components.amesim.flow.orifices:AmesimPnvo001SignalOpening", "app.simulation.components.amesim.flow.pipes:AmesimPnl00r", + "app.simulation.components.amesim.flow.pipes:AmesimPnl0001", + "app.simulation.components.amesim.flow.pipes:AmesimPnl0002", + "app.simulation.components.amesim.flow.pipes:AmesimPnl0003", "app.simulation.components.amesim.junctions.nodes:AmesimPn3Node2", "app.simulation.components.amesim.junctions.nodes:AmesimP4Node2", ), diff --git a/app/simulation/components/amesim/mechanical/__init__.py b/app/simulation/components/amesim/mechanical/__init__.py new file mode 100644 index 0000000..a733997 --- /dev/null +++ b/app/simulation/components/amesim/mechanical/__init__.py @@ -0,0 +1 @@ +"""AMESim mechanical components.""" diff --git a/app/simulation/components/amesim/mechanical/translational.py b/app/simulation/components/amesim/mechanical/translational.py new file mode 100644 index 0000000..0a2efab --- /dev/null +++ b/app/simulation/components/amesim/mechanical/translational.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from app.simulation.core.base import AlgebraicComponent, DynamicComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition + + +class AmesimF000(AlgebraicComponent): + """AMESim F000 zero force source.""" + + MODEL_TYPE = "amesim_f000" + MODEL_VERSION = "0.1.0" + PORTS = (PortDefinition.mechanical_translational("port_1"),) + PARAMETERS = () + RESULT_VARIABLES = () + DISPLAY = ComponentDisplaySpec( + label="F000 零力源", + library_id="amesim", + category_id="mechanical", + symbol="generic", + ports=(PortDisplaySpec("port_1", "right", order=10),), + order=10, + ) + + def __init__(self, name: str) -> None: + super().__init__(name=name) + self.set_parameter_values({}) + self.port_1 = self.register_declared_port("port_1") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimF000": + return cls(name=name) + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + return ( + EquationResidual( + id=f"{self.name}:zero_force", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=(f"{self.name}.port_1.f",), + role="flow", + value=self.port_1.f, + ), + ) + + +class AmesimForc(AlgebraicComponent): + """AMESim FORC signal-to-force converter.""" + + MODEL_TYPE = "amesim_forc" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.signal("res", nominal_role="input"), + PortDefinition.mechanical_translational("port_2"), + ) + PARAMETERS = () + RESULT_VARIABLES = ( + ResultVariableDefinition("force", "输出力", "force", "N", "signal", 10), + ) + DISPLAY = ComponentDisplaySpec( + label="FORC 信号转力", + library_id="amesim", + category_id="mechanical", + symbol="signal", + ports=( + PortDisplaySpec("res", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=20, + ) + + def __init__(self, name: str) -> None: + super().__init__(name=name) + self.set_parameter_values({}) + self.res = self.register_declared_port("res") + self.port_2 = self.register_declared_port("port_2") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimForc": + return cls(name=name) + + @property + def output_force(self) -> float: + return float(self.res.signal) + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + return ( + EquationResidual( + id=f"{self.name}:signal_force", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"), + role="flow", + value=self.port_2.f + self.output_force, + ), + ) + + def component_result_values(self) -> Mapping[str, float]: + return {"force": self.output_force} + + +class AmesimMecmas21(DynamicComponent): + """AMESim MECMAS21 first public one-dimensional translational mass.""" + + MODEL_TYPE = "amesim_mecmas21" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.mechanical_translational("port_1"), + PortDefinition.mechanical_translational("port_2"), + ) + PARAMETERS = ( + ParameterDefinition("mass", 1.0, label="质量", quantity="mass", unit="kg", minimum=0.0, minimum_exclusive=True), + ParameterDefinition("fstick", 0.0, label="静摩擦力", quantity="force", unit="N", minimum=0.0), + ParameterDefinition("fcoul", 0.0, label="库仑摩擦力", quantity="force", unit="N", minimum=0.0), + ParameterDefinition("rvisc", 0.0, label="黏性摩擦系数", quantity="translational_damping", unit="N/(m/s)", minimum=0.0), + ParameterDefinition("wind", 0.0, label="风阻系数", quantity="windage", unit="N/(m/s)^2", minimum=0.0), + ParameterDefinition("dvel", 1.0e-6, label="粘滞速度阈值", quantity="velocity", unit="m/s", minimum=0.0), + ParameterDefinition("restdvel", 1.0e-6, label="恢复速度阈值", quantity="velocity", unit="m/s", minimum=0.0), + ParameterDefinition("restcoeff", 0.65, label="恢复系数", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0), + ParameterDefinition("astrib", 1.0e-3, label="Stribeck 常数", quantity="velocity", unit="m/s", minimum=0.0), + ParameterDefinition("xmin", -1.0, label="下位移限位", quantity="length", unit="m"), + ParameterDefinition("Kbmin", 1.0e9, label="下限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0), + ParameterDefinition("Dbmin", 1.0e4, label="下限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0), + ParameterDefinition("Pdmin", 1.0e-4, label="下限位满阻尼穿透", quantity="length", unit="m", minimum=0.0), + ParameterDefinition("xmax", 0.8, label="上位移限位", quantity="length", unit="m"), + ParameterDefinition("Kbmax", 1.0e9, label="上限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0), + ParameterDefinition("Dbmax", 1.0e4, label="上限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0), + ParameterDefinition("Pdmax", 1.0e-4, label="上限位满阻尼穿透", quantity="length", unit="m", minimum=0.0), + ParameterDefinition("theta", 0.0, label="倾角", quantity="dimensionless", unit=""), + ParameterDefinition("useFriction", 1.0, label="启用摩擦", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0), + ParameterDefinition("stoptype", 4.0, label="限位类型", quantity="dimensionless", unit="", minimum=0.0), + ParameterDefinition("discContactOption", 1.0, label="接触选项", quantity="dimensionless", unit="", minimum=0.0), + ParameterDefinition("strib", 1.0, label="Stribeck 选项", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0), + ParameterDefinition("frictionType", 1.0, label="摩擦类型", quantity="dimensionless", unit="", minimum=0.0), + ParameterDefinition("v0", 0.0, label="初始速度", quantity="velocity", unit="m/s"), + ParameterDefinition("x0", 0.0, label="初始位移", quantity="length", unit="m"), + ) + RESULT_VARIABLES = ( + ResultVariableDefinition("a", "加速度", "acceleration", "m/s2", "state", 10), + ResultVariableDefinition("v", "速度", "velocity", "m/s", "state", 20), + ResultVariableDefinition("x", "位移", "length", "m", "state", 30), + ResultVariableDefinition("Fvisc", "黏性摩擦力", "force", "N", "derived", 40), + ResultVariableDefinition("Ffric", "干摩擦力", "force", "N", "derived", 50), + ResultVariableDefinition("Fmin", "下限位力", "force", "N", "derived", 60), + ResultVariableDefinition("Fmax", "上限位力", "force", "N", "derived", 70), + ) + DISPLAY = ComponentDisplaySpec( + label="MECMAS21 一维质量", + library_id="amesim", + category_id="mechanical", + symbol="generic", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=30, + ) + state_size = 2 + + def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None: + super().__init__(name=name) + resolved = {definition.name: parameters.get(definition.name, definition.default) for definition in self.PARAMETERS} + self.set_parameter_values(resolved) + for name, value in resolved.items(): + setattr(self, name, float(value)) + self.use_friction = bool(int(self.useFriction)) + self.port_1 = self.register_declared_port("port_1") + self.port_2 = self.register_declared_port("port_2") + self.v = float(self.v0) + self.x = float(self.x0) + self.refresh_thermodynamic_ports() + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimMecmas21": + for integer_name in ("useFriction", "stoptype", "discContactOption", "strib", "frictionType"): + if not float(parameters[integer_name]).is_integer(): + raise ValueError(f"MECMAS21 {integer_name} must be an integer.") + return cls(name=name, medium=medium, **dict(parameters)) + + def get_state_vector(self) -> list[float]: + return [self.v, self.x] + + def set_state_vector(self, values: list[float]) -> None: + if len(values) != 2: + raise ValueError("MECMAS21 state vector requires [v, x].") + self.v = float(values[0]) + self.x = float(values[1]) + self.refresh_thermodynamic_ports() + + def refresh_thermodynamic_ports(self) -> None: + for port in (self.port_1, self.port_2): + port.x = self.x + port.v = self.v + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + return ( + self._state_residual("port_1", "x", self.port_1.x - self.x), + self._state_residual("port_1", "v", self.port_1.v - self.v), + self._state_residual("port_2", "x", self.port_2.x - self.x), + self._state_residual("port_2", "v", self.port_2.v - self.v), + ) + + def _state_residual(self, port_name: str, variable: str, value: float) -> EquationResidual: + return EquationResidual( + id=f"{self.name}:{port_name}_{variable}_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.{port_name}.{variable}",), + role="effort", + value=value, + ) + + def _viscous_friction_force(self) -> float: + if not self.use_friction: + return 0.0 + return -self.rvisc * self.v + + def _windage_force(self) -> float: + if not self.use_friction: + return 0.0 + return -self.wind * self.v * abs(self.v) + + def _dry_friction_force(self) -> float: + if not self.use_friction: + return 0.0 + if self.v > 0.0: + return -self.fcoul + if self.v < 0.0: + return self.fcoul + return 0.0 + + def _lower_limit_force(self) -> float: + penetration = max(self.xmin - self.x, 0.0) + if penetration <= 0.0: + return 0.0 + return self.Kbmin * penetration + max(-self.Dbmin * self.v, 0.0) + + def _upper_limit_force(self) -> float: + penetration = max(self.x - self.xmax, 0.0) + if penetration <= 0.0: + return 0.0 + return self.Kbmax * penetration + max(self.Dbmax * self.v, 0.0) + + def acceleration(self) -> float: + return ( + self.port_1.f + + self.port_2.f + + self._viscous_friction_force() + + self._windage_force() + + self._dry_friction_force() + + self._lower_limit_force() + - self._upper_limit_force() + ) / self.mass + + def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]: + return [self.acceleration(), self.v] + + def component_result_values(self) -> Mapping[str, float]: + return { + "a": self.acceleration(), + "v": self.v, + "x": self.x, + "Fvisc": self._viscous_friction_force(), + "Ffric": self._dry_friction_force(), + "Fmin": self._lower_limit_force(), + "Fmax": self._upper_limit_force(), + } diff --git a/app/simulation/components/amesim/signals/__init__.py b/app/simulation/components/amesim/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/simulation/components/amesim/signals/sources.py b/app/simulation/components/amesim/signals/sources.py new file mode 100644 index 0000000..e6f9442 --- /dev/null +++ b/app/simulation/components/amesim/signals/sources.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from app.simulation.core.base import AlgebraicComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition + + +class AmesimStep0(AlgebraicComponent): + """AMESim STEP0 scalar step signal source.""" + + MODEL_TYPE = "amesim_step0" + MODEL_VERSION = "0.1.0" + PORTS = (PortDefinition.signal("out", nominal_role="output"),) + PARAMETERS = ( + ParameterDefinition("initial", 0.0, label="初始值", quantity="dimensionless", unit=""), + ParameterDefinition("final", 1.0, label="阶跃后值", quantity="dimensionless", unit=""), + ParameterDefinition("time", 0.0, label="阶跃时间", quantity="time", unit="s"), + ) + RESULT_VARIABLES = ( + ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10), + ) + DISPLAY = ComponentDisplaySpec( + label="STEP0 阶跃信号", + library_id="amesim", + category_id="signals", + symbol="signal", + ports=(PortDisplaySpec("out", "right", order=10),), + order=10, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + initial: float = 0.0, + final: float = 1.0, + time: float = 0.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values({"initial": initial, "final": final, "time": time}) + self.initial = float(initial) + self.final = float(final) + self.time = float(time) + self.out = self.register_declared_port("out") + self.out.signal = self.output_at(0.0) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimStep0": + return cls( + name=name, + medium=medium, + initial=parameters["initial"], + final=parameters["final"], + time=parameters["time"], + ) + + def output_at(self, time: float) -> float: + return self.final if time >= self.time else self.initial + + def signal_output_values(self, time: float) -> dict[str, float]: + return {"out": self.output_at(time)} + + def component_result_values(self) -> Mapping[str, float]: + return {"y": self.out.signal} + + +class AmesimUd00(AlgebraicComponent): + """AMESim UD00 piecewise-linear scalar signal source.""" + + MODEL_TYPE = "amesim_ud00" + MODEL_VERSION = "0.1.0" + PORTS = (PortDefinition.signal("out", nominal_role="output"),) + PARAMETERS = ( + ParameterDefinition("tstart", 0.0, label="启动时间", quantity="time", unit="s"), + ParameterDefinition("start1", 0.0, label="第 1 段起点", quantity="dimensionless", unit=""), + ParameterDefinition("end1", 1.0, label="第 1 段终点", quantity="dimensionless", unit=""), + ParameterDefinition("t1", 1.0, label="第 1 段时长", quantity="time", unit="s", minimum=0.0), + ParameterDefinition("start2", 1.0, label="第 2 段起点", quantity="dimensionless", unit=""), + ParameterDefinition("end2", 1.0, label="第 2 段终点", quantity="dimensionless", unit=""), + ParameterDefinition("t2", 0.0, label="第 2 段时长", quantity="time", unit="s", minimum=0.0), + ParameterDefinition("start3", 1.0, label="第 3 段起点", quantity="dimensionless", unit=""), + ParameterDefinition("end3", 1.0, label="第 3 段终点", quantity="dimensionless", unit=""), + ParameterDefinition("t3", 0.0, label="第 3 段时长", quantity="time", unit="s", minimum=0.0), + ParameterDefinition("start4", 1.0, label="第 4 段起点", quantity="dimensionless", unit=""), + ParameterDefinition("end4", 1.0, label="第 4 段终点", quantity="dimensionless", unit=""), + ParameterDefinition("t4", 0.0, label="第 4 段时长", quantity="time", unit="s", minimum=0.0), + ParameterDefinition("start5", 1.0, label="第 5 段起点", quantity="dimensionless", unit=""), + ParameterDefinition("end5", 1.0, label="第 5 段终点", quantity="dimensionless", unit=""), + ParameterDefinition("t5", 0.0, label="第 5 段时长", quantity="time", unit="s", minimum=0.0), + ParameterDefinition("start6", 1.0, label="第 6 段起点", quantity="dimensionless", unit=""), + ParameterDefinition("end6", 1.0, label="第 6 段终点", quantity="dimensionless", unit=""), + ParameterDefinition("t6", 0.0, label="第 6 段时长", quantity="time", unit="s", minimum=0.0), + ParameterDefinition("start7", 1.0, label="第 7 段起点", quantity="dimensionless", unit=""), + ParameterDefinition("end7", 1.0, label="第 7 段终点", quantity="dimensionless", unit=""), + ParameterDefinition("t7", 0.0, label="第 7 段时长", quantity="time", unit="s", minimum=0.0), + ParameterDefinition("start8", 1.0, label="第 8 段起点", quantity="dimensionless", unit=""), + ParameterDefinition("end8", 1.0, label="第 8 段终点", quantity="dimensionless", unit=""), + ParameterDefinition("t8", 0.0, label="第 8 段时长", quantity="time", unit="s", minimum=0.0), + ParameterDefinition("nstages", 1.0, label="段数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0), + ParameterDefinition("iscyclic", 0.0, label="循环", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0), + ) + RESULT_VARIABLES = ( + ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10), + ) + DISPLAY = ComponentDisplaySpec( + label="UD00 分段线性信号", + library_id="amesim", + category_id="signals", + symbol="signal", + ports=(PortDisplaySpec("out", "right", order=10),), + order=20, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + tstart: float = 0.0, + starts: tuple[float, ...] = (0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), + ends: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), + durations: tuple[float, ...] = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + nstages: int = 1, + iscyclic: bool = False, + ) -> None: + super().__init__(name=name) + if len(starts) != 8 or len(ends) != 8 or len(durations) != 8: + raise ValueError("UD00 requires exactly eight start, end, and duration values.") + if nstages < 1 or nstages > 8: + raise ValueError("UD00 nstages must be between 1 and 8.") + self.tstart = float(tstart) + self.starts = tuple(float(value) for value in starts) + self.ends = tuple(float(value) for value in ends) + self.durations = tuple(float(value) for value in durations) + self.nstages = int(nstages) + self.iscyclic = bool(iscyclic) + values: dict[str, float] = {"tstart": self.tstart, "nstages": float(self.nstages), "iscyclic": float(int(self.iscyclic))} + for index in range(1, 9): + values[f"start{index}"] = self.starts[index - 1] + values[f"end{index}"] = self.ends[index - 1] + values[f"t{index}"] = self.durations[index - 1] + self.set_parameter_values(values) + self.out = self.register_declared_port("out") + self.out.signal = self.output_at(0.0) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimUd00": + nstages = parameters["nstages"] + iscyclic = parameters["iscyclic"] + if not float(nstages).is_integer(): + raise ValueError("UD00 nstages must be an integer.") + if not float(iscyclic).is_integer(): + raise ValueError("UD00 iscyclic must be 0 or 1.") + return cls( + name=name, + medium=medium, + tstart=parameters["tstart"], + starts=tuple(parameters[f"start{index}"] for index in range(1, 9)), + ends=tuple(parameters[f"end{index}"] for index in range(1, 9)), + durations=tuple(parameters[f"t{index}"] for index in range(1, 9)), + nstages=int(nstages), + iscyclic=bool(int(iscyclic)), + ) + + def output_at(self, time: float) -> float: + elapsed = max(float(time) - self.tstart, 0.0) + active_durations = self.durations[: self.nstages] + total_duration = sum(active_durations) + if self.iscyclic and total_duration > 0.0: + elapsed = elapsed % total_duration + + stage_start_time = 0.0 + for index, duration in enumerate(active_durations): + stage_end_time = stage_start_time + duration + if elapsed < stage_end_time or index == self.nstages - 1: + if duration <= 0.0: + return self.ends[index] + fraction = (elapsed - stage_start_time) / duration + return self.starts[index] + fraction * (self.ends[index] - self.starts[index]) + stage_start_time = stage_end_time + return self.ends[self.nstages - 1] + + def signal_output_values(self, time: float) -> dict[str, float]: + return {"out": self.output_at(time)} + + def component_result_values(self) -> Mapping[str, float]: + return {"y": self.out.signal} diff --git a/app/simulation/components/amesim/storage/chambers.py b/app/simulation/components/amesim/storage/chambers.py index 9e4c368..0754d2a 100644 --- a/app/simulation/components/amesim/storage/chambers.py +++ b/app/simulation/components/amesim/storage/chambers.py @@ -8,6 +8,7 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec from app.simulation.core.equations import EquationResidual from app.simulation.core.metadata import ( ParameterDefinition, + ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES, ) from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties @@ -248,3 +249,273 @@ class AmesimPnch023(ThermodynamicVolumeComponent): value=self.port_2.p - pressure, ), ) + + +class AmesimPnch012(ThermodynamicVolumeComponent): + """AMESim PNCH012 variable-volume pneumatic chamber. + + AMESim supplies four external volume and volume-rate inputs through the + chamber ports. The current public System XML contract has pneumatic ports + only, so this first public model exposes those external volume inputs as SI + parameters. This represents fixed or prescribed-volume PNCH012 cases and is + not yet the full mechanical-coupled submodel. + """ + + MODEL_TYPE = "amesim_pnch012" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_4", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + "cvol0", + 0.015, + label="死容积", + quantity="volume", + unit="m3", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "kth", + 0.0, + label="换热系数", + quantity="heat_transfer_coefficient", + unit="W/(m2*K)", + minimum=0.0, + ), + ParameterDefinition( + "sth", + 0.1, + label="换热面积", + quantity="area", + unit="m2", + minimum=0.0, + ), + ParameterDefinition( + "extemp", + 293.15, + label="外部温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "gi", + 1.0, + label="气体类型索引", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=99.0, + ), + ParameterDefinition( + "p0", + 100000.0, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 293.15, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition("vol1", 0.0, label="端口 1 外部容积", quantity="volume", unit="m3"), + ParameterDefinition("vol2", 0.0, label="端口 2 外部容积", quantity="volume", unit="m3"), + ParameterDefinition("vol3", 0.0, label="端口 3 外部容积", quantity="volume", unit="m3"), + ParameterDefinition("vol4", 0.0, label="端口 4 外部容积", quantity="volume", unit="m3"), + ParameterDefinition("dvol1", 0.0, label="端口 1 容积变化率", quantity="volume_flow", unit="m3/s"), + ParameterDefinition("dvol2", 0.0, label="端口 2 容积变化率", quantity="volume_flow", unit="m3/s"), + ParameterDefinition("dvol3", 0.0, label="端口 3 容积变化率", quantity="volume_flow", unit="m3/s"), + ParameterDefinition("dvol4", 0.0, label="端口 4 容积变化率", quantity="volume_flow", unit="m3/s"), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + ( + ResultVariableDefinition("vol", "气室总容积", "volume", "m3", "derived", 100), + ResultVariableDefinition("dvol", "总容积变化率", "volume_flow", "m3/s", "derived", 110), + ) + DISPLAY = ComponentDisplaySpec( + label="PNCH012 变容气室", + library_id="amesim", + category_id="storage", + symbol="tank", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + PortDisplaySpec("port_3", "left", order=30), + PortDisplaySpec("port_4", "right", order=40), + ), + order=20, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + cvol0: float = 0.015, + kth: float = 0.0, + sth: float = 0.1, + extemp: float = 293.15, + gi: float = 1.0, + p0: float = 100000.0, + T0: float = 293.15, + vol1: float = 0.0, + vol2: float = 0.0, + vol3: float = 0.0, + vol4: float = 0.0, + dvol1: float = 0.0, + dvol2: float = 0.0, + dvol3: float = 0.0, + dvol4: float = 0.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values( + { + "cvol0": cvol0, + "kth": kth, + "sth": sth, + "extemp": extemp, + "gi": gi, + "p0": p0, + "T0": T0, + "vol1": vol1, + "vol2": vol2, + "vol3": vol3, + "vol4": vol4, + "dvol1": dvol1, + "dvol2": dvol2, + "dvol3": dvol3, + "dvol4": dvol4, + } + ) + self.medium = medium + self.cvol0 = float(cvol0) + self.kth = float(kth) + self.sth = float(sth) + self.extemp = float(extemp) + self.gi = AmesimPnch023._integer_parameter("gi", gi) + self.p0 = float(p0) + self.T0 = float(T0) + self.external_volumes = { + "port_1": float(vol1), + "port_2": float(vol2), + "port_3": float(vol3), + "port_4": float(vol4), + } + self.external_volume_rates = { + "port_1": float(dvol1), + "port_2": float(dvol2), + "port_3": float(dvol3), + "port_4": float(dvol4), + } + if self.total_volume() <= 0.0: + raise ValueError("PNCH012 total volume must be positive.") + m0 = self.p0 * self.total_volume() / (medium.R_gas * self.T0) + U0 = m0 * medium.specific_internal_energy(self.T0) + self.state = VolumeState(m=m0, U=U0) + initial_h = medium.specific_enthalpy(self.T0) + for port_name in ("port_1", "port_2", "port_3", "port_4"): + port = self.register_declared_port(port_name) + port.p = self.p0 + port.h_outflow = initial_h + setattr(self, port_name, port) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> "AmesimPnch012": + return cls(name=name, medium=medium, **dict(parameters)) + + def total_volume(self) -> float: + minimum_volume = self.cvol0 / 100.0 + return max(self.cvol0 + sum(self.external_volumes.values()), minimum_volume) + + def total_volume_rate(self) -> float: + if self.total_volume() <= self.cvol0 / 100.0: + return 0.0 + return sum(self.external_volume_rates.values()) + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.total_volume()) + for port_name in ("port_1", "port_2", "port_3", "port_4"): + port = self.get_port(port_name) + port.p = props.p + port.h_outflow = props.h + return props + + def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: + return self.properties() + + def thermal_energy_flow_w(self, temperature: float) -> float: + return self.kth * self.sth * (self.extemp - temperature) + + def component_result_values(self) -> Mapping[str, float]: + props = self.properties() + return { + "m": self.state.m, + "U": self.state.U, + "p": props.p, + "T": props.T, + "rho": props.rho, + "u": props.u, + "h": props.h, + "vol": self.total_volume(), + "dvol": self.total_volume_rate(), + } + + def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]: + props = self.properties() + mass_derivative = 0.0 + energy_derivative = 0.0 + for port_name in ("port_1", "port_2", "port_3", "port_4"): + port = self.get_port(port_name) + inlet_h = self.connection_inlet_enthalpy( + port_m_flow=port.m_flow, + connected_h=connected_h[port_name], + internal_h=props.h, + ) + mass_derivative += port.m_flow + energy_derivative += port.m_flow * inlet_h + energy_derivative += self.thermal_energy_flow_w(props.T) + energy_derivative -= props.p * self.total_volume_rate() + return VolumeState(m=mass_derivative, U=energy_derivative).as_vector() + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + pressure = self.medium.properties_from_mU( + self.state.m, + self.state.U, + self.total_volume(), + ).p + return tuple( + EquationResidual( + id=f"{self.name}:{port_name}_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.{port_name}.p", f"{self.name}.state"), + role="effort", + value=self.get_port(port_name).p - pressure, + ) + for port_name in ("port_1", "port_2", "port_3", "port_4") + ) diff --git a/app/simulation/core/metadata.py b/app/simulation/core/metadata.py index 5fa7150..75aa23d 100644 --- a/app/simulation/core/metadata.py +++ b/app/simulation/core/metadata.py @@ -9,10 +9,12 @@ ResultVariableScope = Literal["component", "port"] SI_UNIT_BY_QUANTITY: dict[str, str] = { + "acceleration": "m/s2", "area": "m2", "dimensionless": "", "density": "kg/m³", "flow_coefficient": "kg/(s*Pa^0.5)", + "force": "N", "heat_transfer_coefficient": "W/(m2*K)", "internal_energy": "J", "length": "m", @@ -22,8 +24,13 @@ SI_UNIT_BY_QUANTITY: dict[str, str] = { "specific_enthalpy": "J/kg", "specific_internal_energy": "J/kg", "temperature": "K", + "translational_damping": "N/(m/s)", + "translational_stiffness": "N/m", + "time": "s", "velocity": "m/s", "volume": "m3", + "volume_flow": "m3/s", + "windage": "N/(m/s)^2", } diff --git a/app/simulation/core/ports.py b/app/simulation/core/ports.py index 401fe52..1ac3bac 100644 --- a/app/simulation/core/ports.py +++ b/app/simulation/core/ports.py @@ -90,6 +90,76 @@ class PortDefinition: ), ) + @classmethod + def mechanical_translational( + cls, + name: str, + *, + nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional", + ) -> PortDefinition: + return cls( + name=name, + kind="physical", + domain="mechanical", + nominal_role=nominal_role, + positive_flow_direction="intoComponent", + variables=( + PortVariableDefinition( + "x", + "effort", + "equal", + label="位移", + quantity="length", + unit="m", + order=10, + ), + PortVariableDefinition( + "v", + "effort", + "equal", + label="速度", + quantity="velocity", + unit="m/s", + order=20, + ), + PortVariableDefinition( + "f", + "flow", + "sumToZero", + label="力", + quantity="force", + unit="N", + order=30, + ), + ), + ) + + @classmethod + def signal( + cls, + name: str, + *, + nominal_role: Literal["input", "output"], + domain: str = "signal", + ) -> PortDefinition: + return cls( + name=name, + kind="signal", + domain=domain, + nominal_role=nominal_role, + variables=( + PortVariableDefinition( + "signal", + "signal", + "directed", + label="信号值", + quantity="dimensionless", + unit="", + order=10, + ), + ), + ) + def as_interface_dict(self) -> dict[str, object]: return { "name": self.name, @@ -108,6 +178,10 @@ class PortState: p: float = 0.0 m_flow: float = 0.0 h_outflow: float = 0.0 + signal: float = 0.0 + x: float = 0.0 + v: float = 0.0 + f: float = 0.0 definition: PortDefinition | None = field(default=None, repr=False, compare=False) @classmethod diff --git a/app/simulation/solvers/algebraic.py b/app/simulation/solvers/algebraic.py index bd3216d..77198d0 100644 --- a/app/simulation/solvers/algebraic.py +++ b/app/simulation/solvers/algebraic.py @@ -134,12 +134,12 @@ class PressureFlowSolver: if not changed: break - def _scales(self) -> tuple[float, float]: + def _scales(self) -> dict[str, float]: pressure_scale = max( [ abs(unknown.read()) for unknown in self.unknowns - if unknown.role == "effort" and unknown.read() > 0.0 + if unknown.variable == "p" and unknown.read() > 0.0 ] + [1e5] ) @@ -148,16 +148,31 @@ class PressureFlowSolver: for component in self.network.components.values() if hasattr(component, "K_eff") ] - flow_scale = max( + mass_flow_scale = max( estimated_flows + [ abs(unknown.read()) for unknown in self.unknowns - if unknown.role == "flow" + if unknown.variable == "m_flow" ] + [1e-3] ) - return pressure_scale, flow_scale + return { + "p": pressure_scale, + "m_flow": mass_flow_scale, + "x": max( + [abs(unknown.read()) for unknown in self.unknowns if unknown.variable == "x"] + + [1.0] + ), + "v": max( + [abs(unknown.read()) for unknown in self.unknowns if unknown.variable == "v"] + + [1.0] + ), + "f": max( + [abs(unknown.read()) for unknown in self.unknowns if unknown.variable == "f"] + + [1.0] + ), + } def solve(self) -> AlgebraicSolveDiagnostics: try: @@ -169,11 +184,13 @@ class PressureFlowSolver: ) from exc self._seed_equal_pressures() - pressure_scale, flow_scale = self._scales() + scales = self._scales() + pressure_scale = scales["p"] + flow_scale = scales["m_flow"] positive_pressures = [ unknown.read() for unknown in self.unknowns - if unknown.role == "effort" and unknown.read() > 0.0 + if unknown.variable == "p" and unknown.read() > 0.0 ] fallback_pressure = ( sum(positive_pressures) / len(positive_pressures) @@ -182,13 +199,28 @@ class PressureFlowSolver: ) def variable_scale(unknown: AlgebraicUnknown) -> float: - return pressure_scale if unknown.role == "effort" else flow_scale + return scales.get(unknown.variable, max(abs(unknown.read()), 1.0)) + + def equation_scale(equation) -> float: + variable_names = [ + variable.rsplit(".", 1)[-1] + for variable in equation.variables + ] + if equation.role == "flow": + return scales["f"] if "f" in variable_names else flow_scale + if equation.role == "effort": + if "x" in variable_names: + return scales["x"] + if "v" in variable_names: + return scales["v"] + return pressure_scale + return max([scales.get(name, 1.0) for name in variable_names] + [1.0]) x0 = np.asarray( [ ( unknown.read() - if unknown.role != "effort" or unknown.read() > 0.0 + if unknown.variable != "p" or unknown.read() > 0.0 else fallback_pressure ) / variable_scale(unknown) @@ -198,7 +230,7 @@ class PressureFlowSolver: ) lower = np.asarray( [ - 1.0 / pressure_scale if unknown.role == "effort" else -np.inf + 1.0 / pressure_scale if unknown.variable == "p" else -np.inf for unknown in self.unknowns ] ) @@ -213,8 +245,7 @@ class PressureFlowSolver: equations = self.network.pressure_flow_equation_residuals() return np.asarray( [ - equation.value - / (pressure_scale if equation.role == "effort" else flow_scale) + equation.value / equation_scale(equation) for equation in equations ], dtype=float, @@ -234,8 +265,7 @@ class PressureFlowSolver: equations = self.network.pressure_flow_equation_residuals() scaled = [ abs( - equation.value - / (pressure_scale if equation.role == "effort" else flow_scale) + equation.value / equation_scale(equation) ) for equation in equations ] diff --git a/app/simulation/solvers/signal.py b/app/simulation/solvers/signal.py new file mode 100644 index 0000000..467639b --- /dev/null +++ b/app/simulation/solvers/signal.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from app.simulation.systems.network import Endpoint, SimulationNetwork + + +class SignalOutputComponent(Protocol): + name: str + + def signal_output_values(self, time: float) -> dict[str, float]: + ... + + +@dataclass(frozen=True) +class SignalSolveDiagnostics: + propagated: int + + def as_dict(self) -> dict[str, object]: + return {"propagated": self.propagated} + + +class SignalResolver: + """Propagate scalar signal connections from output ports to input ports.""" + + def __init__(self, network: SimulationNetwork) -> None: + self.network = network + self._connections = [ + connection for connection in network.connections if connection.kind == "signal" + ] + self.last_diagnostics: SignalSolveDiagnostics | None = None + + def solve(self, time: float) -> SignalSolveDiagnostics: + for component in self.network.components.values(): + signal_output_values = getattr(component, "signal_output_values", None) + if signal_output_values is None: + continue + for port_name, value in signal_output_values(time).items(): + component.get_port(port_name).signal = float(value) + + propagated = 0 + for connection in self._connections: + source, target = self._source_target(connection.endpoints) + source_port = self.network.components[source.component].get_port(source.port) + target_port = self.network.components[target.component].get_port(target.port) + target_port.signal = source_port.signal + propagated += 1 + + diagnostics = SignalSolveDiagnostics(propagated=propagated) + self.last_diagnostics = diagnostics + return diagnostics + + def _source_target(self, endpoints: tuple[Endpoint, Endpoint]) -> tuple[Endpoint, Endpoint]: + first, second = endpoints + first_port = self.network.components[first.component].get_port(first.port) + second_port = self.network.components[second.component].get_port(second.port) + if first_port.definition is not None and first_port.definition.nominal_role == "output": + return first, second + if second_port.definition is not None and second_port.definition.nominal_role == "output": + return second, first + raise ValueError("Signal connection must contain one output endpoint.") diff --git a/app/simulation/systems/generic.py b/app/simulation/systems/generic.py index d3ae1ea..3944c20 100644 --- a/app/simulation/systems/generic.py +++ b/app/simulation/systems/generic.py @@ -9,6 +9,7 @@ from app.simulation.core.base import DynamicComponent from app.simulation.core.metadata import ResultVariableMetadata from app.simulation.solvers.algebraic import PressureFlowSolver from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode +from app.simulation.solvers.signal import SignalResolver from app.simulation.solvers.stream import StreamResolver from app.simulation.systems.network import Endpoint, SimulationNetwork @@ -111,18 +112,6 @@ def simulation_preparation_issues( ) ) - if any( - definition.kind == "signal" - for component in network.components.values() - for definition in component.port_definitions - ): - issues.append( - SimulationPreparationIssue( - "SIGNAL_PORT_UNSUPPORTED", - "Signal-port simulation is not implemented in the current MVP solver.", - ) - ) - structure = network.pressure_flow_structure_dict() if not structure["isSquare"]: issues.append( @@ -244,11 +233,13 @@ class GenericFluidSystem: self.network = network self.dynamic_components = network.dynamic_components() self.pressure_flow_solver = PressureFlowSolver(network) + self.signal_resolver = SignalResolver(network) self.stream_resolver = StreamResolver(network) self.algebraic_solve_count = 0 self.max_algebraic_residual = 0.0 self.max_algebraic_evaluations = 0 self.max_stream_iterations = 0 + self.signal_propagation_count = 0 def initial_state_vector(self) -> list[float]: return self.network.initial_state_vector() @@ -256,7 +247,9 @@ class GenericFluidSystem: def apply_state_vector(self, values: list[float]) -> None: self.network.apply_state_vector(values) - def _close_current_state(self) -> dict[str, dict[str, float]]: + def _close_current_state(self, time: float) -> dict[str, dict[str, float]]: + signal = self.signal_resolver.solve(time) + self.signal_propagation_count += signal.propagated for component in self.dynamic_components: component.refresh_thermodynamic_ports() algebraic = self.pressure_flow_solver.solve() @@ -279,12 +272,12 @@ class GenericFluidSystem: def consistent_initial_state_vector(self) -> list[float]: state = self.initial_state_vector() self.apply_state_vector(state) - self._close_current_state() + self._close_current_state(0.0) return state def rhs(self, _time: float, state_vector: list[float]) -> list[float]: self.apply_state_vector(state_vector) - connected_h = self._close_current_state() + connected_h = self._close_current_state(_time) derivatives: list[float] = [] for component in self.dynamic_components: derivatives.extend( @@ -395,7 +388,7 @@ class GenericFluidSystem: ] try: self.apply_state_vector(state) - self._close_current_state() + self._close_current_state(times[time_index]) self._append_current_state(series) series["time"].append(times[time_index]) except Exception as exc: @@ -433,6 +426,14 @@ class GenericFluidSystem: else None ), }, + "signal": { + "propagations": self.signal_propagation_count, + "last": ( + self.signal_resolver.last_diagnostics.as_dict() + if self.signal_resolver.last_diagnostics is not None + else None + ), + }, "stateCount": len(initial_state), "sampleCount": len(series["time"]), } diff --git a/docs/amesim-component-migration-matrix.md b/docs/amesim-component-migration-matrix.md index 9f14f09..5b2b177 100644 --- a/docs/amesim-component-migration-matrix.md +++ b/docs/amesim-component-migration-matrix.md @@ -31,25 +31,25 @@ | AMESim 子模型 | 数量 | AMESim 角色 | 当前公开状态 | 建议目标 | 先决条件 / 限制 | | --- | ---: | --- | --- | --- | --- | | `PNCH023` | 4 | 固定容积气室,带换热 | 候选公开 | `amesim_pnch023`,`storage` | 可基于 `ThermodynamicVolumeComponent`;需按 AMESim `cvol/extemp/kth/sth/gi` 复核质量、能量、换热方程。 | -| `PNCH012` | 8 | 变容气室,带换热 | 暂不公开 | 内部 `test_mql` 模型;后续 `amesim_pnch012` | 体积由机械/活塞运动耦合;公开前需要机械域或明确外部体积输入协议。 | +| `PNCH012` | 8 | 变容气室,带换热 | 参数化第一版公开 | `amesim_pnch012`,`storage` | 已将 AMESim `vol1..4/dvol1..4` 外部体积输入映射为 SI 参数,支持固定/预设体积场景;实时机械耦合仍需后续机械域或输入端口协议。 | | `PNOR001` | 8 | 常系数气动孔口 | 候选公开 | `amesim_pnor001`,`flow` | 可基于 `AlgebraicComponent`;需按 AMESim `cq/area/Cv/Kv/flowset/gi` 复核双向流、零压差正则化和单位换算。 | -| `PNVO001` | 8 | 信号调制气动孔口 | 暂不公开 | 内部固定算例;后续 `amesim_pnvo001` | 依赖信号端口和事件/阶跃控制;当前公开组件库不支持信号仿真。可先拆出无信号开度参数版本,但不得等同 AMESim `PNVO001`。 | +| `PNVO001` | 8 | 信号调制气动孔口 | 第一版公开 | `amesim_pnvo001`,`flow` | 已接入标量信号端口 `res`,可由 `amesim_step0` 驱动开度;精确事件语义和 AMESim baseline 仍留后续修模。固定开度变体 `amesim_pnvo001_fixed` 继续保留。 | | `PN3NODE2` | 8 | 三端气动节点,压力温度由 port 2 固定 | 候选公开 | `amesim_pn3node2`,`junctions` | 当前 `Tee` 是通用三通近似;AMESim port 2 参考温压语义和 stream 混合需单独测试。 | | `P4NODE2` | 8 | 四端气动节点,压力温度由 port 2 固定 | 候选公开 | `amesim_p4node2`,`junctions` | 需要新增四端 junction 基类/模型;复核 port 2 参考温压和多支路混合。 | | `PNL00R` | 4 | 管路纯阻性摩擦段 | 候选公开 | `amesim_pnl00r`,`flow` | 可基于准稳态阻性管;需按 AMESim `PNL00R` 参数和摩擦公式复核。 | -| `PNL0001` | 20 | C-R 动态管路 | 先内部 | 后续 `amesim_pnl0001` | 含管内储气动态和摩擦耦合;当前 `pipe` 只是准稳态阻性模型,不能直接宣称等价。 | -| `PNL0002` | 8 | R-C-R 动态管路 | 先内部 | 后续 `amesim_pnl0002` | 需要多段动态状态和端口/中心阻力结构测试。 | -| `PNL0003` | 8 | C-R-C 动态管路 | 先内部 | 后续 `amesim_pnl0003` | 当前 `test_mql` 仍在诊断事件早期导数差异;公开前必须完成 AMESim baseline 对齐。 | -| `PNPL01` | 16 | 零气动流源 | 暂不公开 | 后续边界/源元件 | 当前网络要求物理端口连接并由组件/储能闭合;需设计一端口源/边界组件契约。 | +| `PNL0001` | 20 | C-R 动态管路 | 第一版公开 | `amesim_pnl0001`,`flow` | 已按公开契约接入两状态管内容积 + port 1 摩擦残差 + mode 2 换热项;仍需后续按 AMESim baseline 复核 `pn2pipefr_` 和 mode 1 多方语义。 | +| `PNL0002` | 8 | R-C-R 动态管路 | 第一版公开 | `amesim_pnl0002`,`flow` | 已按公开契约接入中心两状态容积 + 两端半长摩擦残差 + mode 2 换热项;仍需后续按 AMESim baseline 复核 `pn2pipefr_` 和 mode 1 多方语义。 | +| `PNL0003` | 8 | C-R-C 动态管路 | 第一版公开 | `amesim_pnl0003`,`flow` | 已按公开契约接入两端四状态容积 + 中心摩擦流 + mode 2 换热项;大压差动态闭合和 AMESim baseline 误差仍留后续修模。 | +| `PNPL01` | 16 | 零气动流源 | 第一版公开 | `amesim_pnpl01`,`boundary` | 当前实现一端零流边界,只约束端口质量流量为 0;压力源/外部边界语义留后续扩展。 | | `PNGD00` | 1 | 氦气气体定义 | 暂不公开 | medium 配置,不是画布物理组件 | 应映射为系统/介质设置;不能作为普通可连接组件注册。 | | `PNRP17` | 8 | 气动活塞与移动体耦合 | 暂不公开 | 内部固定算例;后续跨域组件 | 依赖气动端口和机械端口耦合;需要新增机械域、跨域状态和连接规则。 | -| `MECMAS21` | 10 | 一维平动质量 | 暂不公开 | 后续机械域组件 | 当前无机械端口、力/速度/位移连接规则和机械求解器。 | +| `MECMAS21` | 10 | 一维平动质量 | 第一版公开 | `amesim_mecmas21`,`mechanical` | 已接入一维机械端口 `x/v/f`、双端质量状态和基本摩擦/限位项,并跑通零力源与信号力源最小 System XML;完整 AMESim 接触/事件语义仍留后续对齐。 | | `LMECHN1` | 2 | 动态线性机械节点 | 暂不公开 | 后续机械域组件 | 同上。 | | `LSTP00A` | 8 | 弹性接触/端止动 | 暂不公开 | 后续机械域组件 | 依赖机械接触和事件/非光滑力模型。 | -| `F000` | 16 | 零力源 | 暂不公开 | 后续机械边界组件 | 依赖机械域。 | -| `FORC` | 2 | 信号转力 | 暂不公开 | 后续信号-机械跨域组件 | 依赖信号端口与机械端口。 | -| `STEP0` | 8 | 阶跃信号源 | 暂不公开 | 后续信号源组件 | 当前公开求解器不支持信号端口仿真和事件。 | -| `UD00` | 2 | 分段线性信号源 | 暂不公开 | 后续信号源组件 | 同上。 | +| `F000` | 16 | 零力源 | 第一版公开 | `amesim_f000`,`mechanical` | 已作为一端机械零力边界公开,约束端口力为 0。 | +| `FORC` | 2 | 信号转力 | 第一版公开 | `amesim_forc`,`mechanical` | 已接入信号输入 `res` 到机械端口力源,可由 `STEP0/UD00` 驱动质量组件。 | +| `STEP0` | 8 | 阶跃信号源 | 第一版公开 | `amesim_step0`,`signals` | 已接入标量信号输出端口和求解时信号传播;当前是基础阶跃,不含更复杂事件调度语义。 | +| `UD00` | 2 | 分段线性信号源 | 第一版公开 | `amesim_ud00`,`signals` | 已接入标量信号输出端口、8 段 start/end/t 参数、循环模式和 System XML signal connection;当前按已转换 PythonModels 语义处理最后一段外推,AMESim baseline 仍留后续复核。 | | `DIRECT` | 44 | 直接连接 | 不注册为组件 | System XML `Connection` | 连接不是组件;物理连接必须保持无方向端点语义。 | ## 建议迁移批次 @@ -68,22 +68,25 @@ ### 批次 2:动态管路 -- `amesim_pnl0001` -- `amesim_pnl0002` -- `amesim_pnl0003` +- `amesim_pnl0001`:已第一版公开,保留后续 baseline 精修。 +- `amesim_pnl0002`:已第一版公开,保留后续 baseline 精修。 +- `amesim_pnl0003`:已第一版公开,保留后续 baseline 精修。 -这些模型是 `test_mql` 对齐工作的核心,公开前需要先在内部固定算例中完成 AMESim baseline 诊断闭环。不能用当前准稳态 `pipe` 替代动态管路并宣称等价。 +这些模型是 `test_mql` 对齐工作的核心。当前已先按动态组件契约公开,后续仍需要在固定算例和 AMESim baseline 上复核摩擦、换热、多方模式和大压差动态闭合;不能用准稳态 `pipe` 替代这些动态管路并宣称等价。 ### 批次 3:信号、事件、机械和跨域组件 -- `STEP0`、`UD00`、`PNVO001` -- `MECMAS21`、`LMECHN1`、`LSTP00A`、`F000`、`FORC`、`PNRP17` +- `STEP0`:已第一版公开。 +- `PNVO001`:已第一版公开,保留固定开度变体。 +- `UD00`:已第一版公开。 +- `MECMAS21`、`F000`、`FORC`:已第一版公开。 +- `LMECHN1`、`LSTP00A`、`PNRP17` -这些需要先设计新物理域或信号求解协议,包括端口变量、连接规则、XML 协议、前端连线兼容和最小闭合系统测试。 +当前已具备一对一标量信号传播和一维机械端口基础闭合;剩余机械/跨域组件仍需要继续设计多端机械节点、接触事件、气动-机械耦合、XML 协议、前端连线兼容和最小闭合系统测试。 ## 下一步执行建议 1. 先为 AMESim 公开组件建立独立库,例如 `app.simulation.components.amesim`,不要混入 `experimental`。 2. 先只登记一个完成度最高的气动代数组件,例如 `PNOR001` 或 `PNL00R`。 3. 每登记一个模型,都同步补充测试和目录校验,确认 `/api/components/catalog`、System XML 编译和最小仿真都通过。 -4. 动态管路和机械/信号组件继续保留在内部 `test_mql` 路径,直到方程和求解能力补齐。 +4. 动态管路 `PNL0001/2/3`、参数化 `PNCH012`、`STEP0`、`UD00`、信号版 `PNVO001` 和机械基础件 `F000/FORC/MECMAS21` 已完成第一版公开接入;下一步转向 `LMECHN1/LSTP00A/PNRP17` 或完整实时耦合。 diff --git a/tests/test_amesim_component_migration_matrix.py b/tests/test_amesim_component_migration_matrix.py index ae0d677..45f09a1 100644 --- a/tests/test_amesim_component_migration_matrix.py +++ b/tests/test_amesim_component_migration_matrix.py @@ -21,14 +21,9 @@ class AmesimComponentMigrationMatrixTests(unittest.TestCase): def test_unsupported_domains_are_not_marked_as_public_candidates(self) -> None: text = MATRIX_PATH.read_text(encoding="utf-8") unsupported = { - "F000", - "FORC", "LMECHN1", "LSTP00A", - "MECMAS21", "PNRP17", - "STEP0", - "UD00", } for family in unsupported: diff --git a/tests/test_amesim_mechanical_public_components.py b/tests/test_amesim_mechanical_public_components.py new file mode 100644 index 0000000..09e581c --- /dev/null +++ b/tests/test_amesim_mechanical_public_components.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.mechanical.translational import ( + AmesimF000, + AmesimForc, + AmesimMecmas21, +) +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimMechanicalPublicComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_f000_constrains_mechanical_port_force_to_zero(self) -> None: + source = AmesimF000("zero_1") + source.port_1.f = 12.5 + + residuals = source.pressure_flow_equation_residuals() + + self.assertEqual(len(residuals), 1) + self.assertEqual(residuals[0].variables, ("zero_1.port_1.f",)) + self.assertAlmostEqual(residuals[0].value, 12.5) + + def test_forc_converts_signal_to_opposing_source_port_force(self) -> None: + converter = AmesimForc("force_1") + converter.res.signal = 20.0 + converter.port_2.f = -20.0 + + residuals = converter.pressure_flow_equation_residuals() + + self.assertAlmostEqual(converter.output_force, 20.0) + self.assertAlmostEqual(residuals[0].value, 0.0) + self.assertEqual(converter.component_result_values(), {"force": 20.0}) + + def test_mecmas21_acceleration_uses_connected_port_forces(self) -> None: + mass = AmesimMecmas21( + "mass_1", + self.medium, + mass=2.0, + fcoul=0.0, + rvisc=0.0, + wind=0.0, + x0=0.1, + v0=0.2, + ) + mass.port_1.f = 10.0 + mass.port_2.f = -2.0 + + self.assertEqual(mass.get_state_vector(), [0.2, 0.1]) + self.assertAlmostEqual(mass.acceleration(), 4.0) + self.assertEqual(mass.state_derivative_from_ports({}), [4.0, 0.2]) + self.assertAlmostEqual(mass.port_1.x, 0.1) + self.assertAlmostEqual(mass.port_2.v, 0.2) + + def test_mecmas21_registry_rejects_fractional_integer_options(self) -> None: + with self.assertRaisesRegex(ValueError, "useFriction must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_mecmas21"].create( + "mass_1", + self.medium, + {"useFriction": 0.5}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_mechanical_xml.py b/tests/test_amesim_mechanical_xml.py new file mode 100644 index 0000000..fb03b33 --- /dev/null +++ b/tests/test_amesim_mechanical_xml.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_amesim_pnvo001_signal_xml import signal_edge, signal_port +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +MECMAS21_DEFAULTS = { + "mass": 2.0, + "fstick": 0.0, + "fcoul": 0.0, + "rvisc": 0.0, + "wind": 0.0, + "dvel": 1.0e-6, + "restdvel": 1.0e-6, + "restcoeff": 0.65, + "astrib": 1.0e-3, + "xmin": -1.0, + "Kbmin": 1.0e9, + "Dbmin": 1.0e4, + "Pdmin": 1.0e-4, + "xmax": 1.0, + "Kbmax": 1.0e9, + "Dbmax": 1.0e4, + "Pdmax": 1.0e-4, + "theta": 0.0, + "useFriction": 1.0, + "stoptype": 4.0, + "discContactOption": 1.0, + "strib": 1.0, + "frictionType": 1.0, + "v0": 0.0, + "x0": 0.0, +} + + +def mechanical_port(name: str, side: str) -> dict[str, str]: + return physical_port(name, "bidirectional", side, domain="mechanical") + + +def zero_force_mass_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-mechanical-zero-force-smoke", + nodes=[ + component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]), + component_node( + "mass_1", + "amesim_mecmas21", + [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], + MECMAS21_DEFAULTS, + ), + component_node("zero_right", "amesim_f000", [mechanical_port("port_1", "left")]), + ], + edges=[ + physical_edge("edge-1", "zero_left", "port_1", "mass_1", "port_1"), + physical_edge("edge-2", "mass_1", "port_2", "zero_right", "port_1"), + ], + simulation={"t_start": 0.0, "t_stop": 0.02, "step": 0.01, "max_step": 0.01, "method": "BDF"}, + ) + + +def signal_force_mass_project() -> ReactFlowProjectPayload: + parameters = dict(MECMAS21_DEFAULTS) + parameters["mass"] = 2.0 + return ReactFlowProjectPayload( + name="amesim-mechanical-signal-force-smoke", + nodes=[ + component_node( + "force_signal", + "amesim_ud00", + [signal_port("out", "output", "right")], + { + "tstart": 0.0, + "start1": 10.0, + "end1": 10.0, + "t1": 1.0, + "start2": 10.0, + "end2": 10.0, + "t2": 0.0, + "start3": 10.0, + "end3": 10.0, + "t3": 0.0, + "start4": 10.0, + "end4": 10.0, + "t4": 0.0, + "start5": 10.0, + "end5": 10.0, + "t5": 0.0, + "start6": 10.0, + "end6": 10.0, + "t6": 0.0, + "start7": 10.0, + "end7": 10.0, + "t7": 0.0, + "start8": 10.0, + "end8": 10.0, + "t8": 0.0, + "nstages": 1.0, + "iscyclic": 0.0, + }, + ), + component_node( + "force_1", + "amesim_forc", + [signal_port("res", "input", "left"), mechanical_port("port_2", "right")], + ), + component_node( + "mass_1", + "amesim_mecmas21", + [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], + parameters, + ), + component_node("zero_right", "amesim_f000", [mechanical_port("port_1", "left")]), + ], + edges=[ + signal_edge("signal-1", "force_signal", "out", "force_1", "res"), + physical_edge("edge-1", "force_1", "port_2", "mass_1", "port_1"), + physical_edge("edge-2", "mass_1", "port_2", "zero_right", "port_1"), + ], + simulation={"t_start": 0.0, "t_stop": 0.02, "step": 0.01, "max_step": 0.005, "method": "BDF"}, + ) + + +class AmesimMechanicalXmlTests(unittest.TestCase): + def test_zero_force_mechanical_project_compiles_and_simulates(self) -> None: + xml = build_reactflow_system_xml(zero_force_mass_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + network = compile_reactflow_network(zero_force_mass_project()) + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.01, 0.02]) + self.assertEqual(result["series"]["mass_1.v"], [0.0, 0.0, 0.0]) + self.assertEqual(result["series"]["mass_1.x"], [0.0, 0.0, 0.0]) + + def test_signal_force_mechanical_project_compiles_and_simulates(self) -> None: + xml = build_reactflow_system_xml(signal_force_mass_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + network = compile_reactflow_network(signal_force_mass_project()) + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["force_signal.out.signal"], [10.0, 10.0, 10.0]) + self.assertEqual(result["series"]["force_1.res.signal"], [10.0, 10.0, 10.0]) + self.assertAlmostEqual(result["series"]["mass_1.a"][0], 5.0) + self.assertGreater(result["series"]["mass_1.v"][-1], 0.0) + self.assertGreater(result["series"]["mass_1.x"][-1], 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnch012_component.py b/tests/test_amesim_pnch012_component.py new file mode 100644 index 0000000..0008310 --- /dev/null +++ b/tests/test_amesim_pnch012_component.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.storage.chambers import AmesimPnch012 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnch012ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_parameterized_volume_contract(self) -> None: + chamber = COMPONENT_MODEL_REGISTRY["amesim_pnch012"].create( + "chamber_1", + self.medium, + {}, + ) + + self.assertIsInstance(chamber, AmesimPnch012) + self.assertEqual(set(chamber.ports), {"port_1", "port_2", "port_3", "port_4"}) + self.assertEqual(len(chamber.get_state_vector()), 2) + self.assertEqual(chamber.parameter_values["cvol0"], 0.015) + self.assertAlmostEqual(chamber.total_volume(), 0.015) + + def test_total_volume_adds_four_external_volume_parameters(self) -> None: + chamber = AmesimPnch012( + "chamber_1", + self.medium, + cvol0=0.015, + vol1=0.001, + vol2=0.002, + vol3=0.003, + vol4=0.004, + dvol1=1.0e-6, + dvol2=2.0e-6, + dvol3=-1.0e-6, + dvol4=0.0, + ) + + self.assertAlmostEqual(chamber.total_volume(), 0.025) + self.assertAlmostEqual(chamber.total_volume_rate(), 2.0e-6) + + def test_initial_state_uses_total_volume_pressure_and_temperature(self) -> None: + chamber = AmesimPnch012( + "chamber_1", + self.medium, + cvol0=0.015, + vol1=0.010, + p0=200000.0, + T0=300.0, + ) + props = chamber.properties() + + self.assertAlmostEqual(props.p, 200000.0, delta=1.0e-8) + self.assertAlmostEqual(props.T, 300.0) + self.assertAlmostEqual(chamber.port_1.p, props.p) + self.assertAlmostEqual(chamber.port_4.p, props.p) + + def test_pressure_flow_residuals_bind_all_ports_to_chamber_state(self) -> None: + chamber = AmesimPnch012("chamber_1", self.medium) + chamber.properties() + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in chamber.pressure_flow_equation_residuals() + } + + self.assertEqual( + set(residuals), + { + "port_1_pressure_state", + "port_2_pressure_state", + "port_3_pressure_state", + "port_4_pressure_state", + }, + ) + self.assertTrue(all(abs(residual.value) < 1.0e-9 for residual in residuals.values())) + + def test_derivative_sums_four_mass_flows_and_boundary_work(self) -> None: + chamber = AmesimPnch012( + "chamber_1", + self.medium, + dvol1=1.0e-6, + kth=2.0, + sth=0.5, + extemp=310.0, + ) + props = chamber.properties() + chamber.port_1.m_flow = 0.2 + chamber.port_2.m_flow = -0.1 + chamber.port_3.m_flow = 0.05 + chamber.port_4.m_flow = 0.0 + + derivative = chamber.state_derivative_from_ports( + { + "port_1": props.h + 1000.0, + "port_2": props.h - 1000.0, + "port_3": props.h + 500.0, + "port_4": props.h, + } + ) + + self.assertAlmostEqual(derivative[0], 0.15) + self.assertLess(derivative[1], 0.2 * (props.h + 1000.0) + 0.05 * (props.h + 500.0)) + + def test_results_include_total_volume_and_volume_rate(self) -> None: + chamber = AmesimPnch012("chamber_1", self.medium, vol1=0.001, dvol1=1.0e-6) + + values = chamber.component_result_values() + + self.assertIn("vol", values) + self.assertIn("dvol", values) + self.assertAlmostEqual(values["vol"], 0.016) + self.assertAlmostEqual(values["dvol"], 1.0e-6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnch012_xml.py b/tests/test_amesim_pnch012_xml.py new file mode 100644 index 0000000..a6b55d8 --- /dev/null +++ b/tests/test_amesim_pnch012_xml.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def _pnpl_node(index: int) -> object: + return component_node( + f"boundary_{index}", + "amesim_pnpl01", + [physical_port("port_1", "bidirectional", "left")], + {}, + ) + + +def amesim_pnch012_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnch012-smoke", + nodes=[ + component_node( + "chamber_1", + "amesim_pnch012", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + physical_port("port_3", "bidirectional", "left"), + physical_port("port_4", "bidirectional", "right"), + ], + { + "cvol0": 0.015, + "kth": 0.0, + "sth": 0.7, + "extemp": 300.0, + "gi": 1.0, + "p0": 200000.0, + "T0": 300.0, + "vol1": 0.001, + "vol2": 0.0, + "vol3": 0.0, + "vol4": 0.0, + "dvol1": 0.0, + "dvol2": 0.0, + "dvol3": 0.0, + "dvol4": 0.0, + }, + ), + _pnpl_node(1), + _pnpl_node(2), + _pnpl_node(3), + _pnpl_node(4), + ], + edges=[ + physical_edge("edge-1", "chamber_1", "port_1", "boundary_1", "port_1"), + physical_edge("edge-2", "chamber_1", "port_2", "boundary_2", "port_1"), + physical_edge("edge-3", "chamber_1", "port_3", "boundary_3", "port_1"), + physical_edge("edge-4", "chamber_1", "port_4", "boundary_4", "port_1"), + ], + simulation={"t_start": 0.0, "t_stop": 0.002, "step": 0.001, "max_step": 0.001, "method": "BDF"}, + ) + + +class AmesimPnch012XmlTests(unittest.TestCase): + def test_pnch012_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnch012_project()) + + self.assertIn("chamber_1", network.components) + self.assertEqual(network.components["chamber_1"].model_type, "amesim_pnch012") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnch012_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnch012_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("chamber_1.port_1.m_flow", result["series"]) + self.assertIn("chamber_1.vol", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl0001_component.py b/tests/test_amesim_pnl0001_component.py new file mode 100644 index 0000000..e08eb75 --- /dev/null +++ b/tests/test_amesim_pnl0001_component.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.flow.pipes import AmesimPnl0001 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnl0001ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_amesim_parameter_contract(self) -> None: + pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create( + "pnl_1", + self.medium, + {}, + ) + + self.assertIsInstance(pipe, AmesimPnl0001) + self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) + self.assertEqual( + set(pipe.parameter_values), + { + "diam", + "le", + "rr", + "k", + "kth", + "extemp", + "gi", + "mode", + "p0", + "T0", + }, + ) + self.assertEqual(len(pipe.get_state_vector()), 2) + self.assertAlmostEqual(pipe.volume, 7.853981633974483e-5) + + def test_rejects_fractional_integer_parameters(self) -> None: + with self.assertRaisesRegex(ValueError, "gi must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create( + "pnl_1", + self.medium, + {"gi": 1.5}, + ) + with self.assertRaisesRegex(ValueError, "mode must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create( + "pnl_1", + self.medium, + {"mode": 1.5}, + ) + + def test_initial_state_uses_pipe_volume_pressure_and_temperature(self) -> None: + pipe = AmesimPnl0001( + "pnl_1", + self.medium, + diam=0.014, + le=1.0, + rr=0.045 / 14.0, + p0=15.3e6, + T0=293.15, + ) + props = pipe.properties() + + self.assertAlmostEqual(pipe.volume, 1.539380400258999e-4) + self.assertAlmostEqual(props.p, 15.3e6, delta=1.0e-5) + self.assertAlmostEqual(props.T, 293.15) + self.assertAlmostEqual(pipe.port_2.p, props.p) + + def test_resistance_flow_follows_pressure_gradient(self) -> None: + pipe = AmesimPnl0001( + "pnl_1", + self.medium, + diam=0.014, + le=1.0, + rr=0.045 / 14.0, + p0=15.3e6, + T0=293.15, + ) + props = pipe.properties() + + forward = pipe.mass_flow(15.31e6, props.p, props.T) + reverse = pipe.mass_flow(15.29e6, props.p, props.T) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.02) + + def test_pressure_flow_residuals_do_not_force_storage_mass_balance(self) -> None: + pipe = AmesimPnl0001("pnl_1", self.medium) + pipe.port_1.p = 101000.0 + pipe.port_2.m_flow = -1.0e-4 + props = pipe.properties() + pipe.port_1.m_flow = pipe.mass_flow(pipe.port_1.p, pipe.port_2.p, props.T) + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in pipe.pressure_flow_equation_residuals() + } + + self.assertEqual( + set(residuals), + {"port_2_pressure_state", "port_1_pressure_flow_relation"}, + ) + self.assertAlmostEqual(residuals["port_2_pressure_state"].value, 0.0) + self.assertAlmostEqual(residuals["port_1_pressure_flow_relation"].value, 0.0) + + def test_connection_derivative_preserves_two_port_accumulation(self) -> None: + pipe = AmesimPnl0001("pnl_1", self.medium, kth=2.0, extemp=310.0) + props = pipe.properties() + pipe.port_1.m_flow = 0.2 + pipe.port_2.m_flow = -0.1 + + derivative = pipe.state_derivative_from_ports( + { + "port_1": props.h + 1000.0, + "port_2": props.h - 1000.0, + } + ) + + self.assertAlmostEqual(derivative[0], 0.1) + self.assertGreater(derivative[1], 0.0) + + def test_results_include_thermodynamic_and_pipe_diagnostics(self) -> None: + pipe = AmesimPnl0001("pnl_1", self.medium) + pipe.port_1.p = 101000.0 + + values = pipe.component_result_values() + + self.assertEqual( + set(values), + {"m", "U", "p", "T", "rho", "u", "h", "re", "cm", "v", "ff"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl0001_xml.py b/tests/test_amesim_pnl0001_xml.py new file mode 100644 index 0000000..4b1d654 --- /dev/null +++ b/tests/test_amesim_pnl0001_xml.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def amesim_pnl0001_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnl0001-smoke", + nodes=[ + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 500000.0, "T0": 300.0}, + ), + component_node( + "pnl_1", + "amesim_pnl0001", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + { + "diam": 0.01, + "le": 1.0, + "rr": 1.0e-5, + "k": 1.35, + "kth": 0.0, + "extemp": 300.0, + "gi": 1.0, + "mode": 2.0, + "p0": 300000.0, + "T0": 300.0, + }, + ), + component_node( + "resistance_1", + "amesim_pnl00r", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + {"diam": 0.01, "le": 0.5, "rr": 1.0e-5, "gi": 1.0}, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "pnl_1", "port_1"), + physical_edge("edge-2", "pnl_1", "port_2", "resistance_1", "port_1"), + physical_edge("edge-3", "resistance_1", "port_2", "tank_1", "port_a"), + ], + simulation={ + "t_start": 0.0, + "t_stop": 0.002, + "step": 0.001, + "max_step": 0.001, + "method": "BDF", + }, + ) + + +class AmesimPnl0001XmlTests(unittest.TestCase): + def test_pnl0001_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnl0001_project()) + + self.assertIn("pnl_1", network.components) + self.assertEqual(network.components["pnl_1"].model_type, "amesim_pnl0001") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnl0001_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnl0001_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("pnl_1.port_1.m_flow", result["series"]) + self.assertIn("pnl_1.re", result["series"]) + self.assertIn("pnl_1.m", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl0002_pnl0003_component.py b/tests/test_amesim_pnl0002_pnl0003_component.py new file mode 100644 index 0000000..c7be92d --- /dev/null +++ b/tests/test_amesim_pnl0002_pnl0003_component.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.flow.pipes import AmesimPnl0002, AmesimPnl0003 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY + + +class AmesimPnl0002ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_contract(self) -> None: + pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0002"].create("pnl_2", self.medium, {}) + + self.assertIsInstance(pipe, AmesimPnl0002) + self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) + self.assertEqual(len(pipe.get_state_vector()), 2) + self.assertAlmostEqual(pipe.resistance_length, pipe.le / 2.0) + + def test_center_compliance_initial_state(self) -> None: + pipe = AmesimPnl0002( + "pnl_2", + self.medium, + diam=0.02, + le=2.0, + rr=0.045 / 20.0, + p0=100000.0, + T0=293.15, + ) + props = pipe.properties() + + self.assertAlmostEqual(pipe.volume, 6.283185307179586e-4) + self.assertAlmostEqual(props.p, 100000.0, delta=1.0e-6) + self.assertAlmostEqual(props.T, 293.15) + + def test_port_flows_enter_center_from_higher_external_pressure(self) -> None: + pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15) + center = pipe.properties() + + forward = pipe.port_mass_flow(101000.0, center.p, center.T) + reverse = pipe.port_mass_flow(99000.0, center.p, center.T) + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + + def test_pressure_flow_residuals_use_two_port_resistances(self) -> None: + pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15) + center = pipe.properties() + pipe.port_1.p = 101000.0 + pipe.port_2.p = 99000.0 + pipe.port_1.m_flow = pipe.port_mass_flow(pipe.port_1.p, center.p, center.T) + pipe.port_2.m_flow = pipe.port_mass_flow(pipe.port_2.p, center.p, center.T) + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in pipe.pressure_flow_equation_residuals() + } + + self.assertEqual( + set(residuals), + {"port_1_pressure_flow_relation", "port_2_pressure_flow_relation"}, + ) + self.assertAlmostEqual(residuals["port_1_pressure_flow_relation"].value, 0.0) + self.assertAlmostEqual(residuals["port_2_pressure_flow_relation"].value, 0.0) + + def test_connection_derivative_accumulates_two_external_flows(self) -> None: + pipe = AmesimPnl0002("pnl_2", self.medium) + props = pipe.properties() + pipe.port_1.m_flow = 0.2 + pipe.port_2.m_flow = -0.1 + + derivative = pipe.state_derivative_from_ports( + {"port_1": props.h + 1000.0, "port_2": props.h - 1000.0} + ) + + self.assertAlmostEqual(derivative[0], 0.1) + + +class AmesimPnl0003ComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_default_create_preserves_contract(self) -> None: + pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0003"].create("pnl_3", self.medium, {}) + + self.assertIsInstance(pipe, AmesimPnl0003) + self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) + self.assertEqual(len(pipe.get_state_vector()), 4) + + def test_initial_state_uses_two_half_volume_compliances(self) -> None: + pipe = AmesimPnl0003( + "pnl_3", + self.medium, + diam=0.02, + le=0.3, + rr=0.045 / 20.0, + p1_0=15.3e6, + T1_0=293.15, + p2_0=15.3e6, + T2_0=293.15, + ) + port_1 = pipe.properties_1() + port_2 = pipe.properties_2() + + self.assertAlmostEqual(pipe.volume, 9.424777960769381e-5) + self.assertAlmostEqual(pipe.compliance_volume, pipe.volume / 2.0) + self.assertAlmostEqual(port_1.p, 15.3e6, delta=1.0e-5) + self.assertAlmostEqual(port_2.p, 15.3e6, delta=1.0e-5) + + def test_center_resistance_flow_follows_end_pressure_gradient(self) -> None: + pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0) + forward = pipe.resistance_mass_flow() + pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=100000.0, p2_0=101000.0) + reverse = pipe.resistance_mass_flow() + + self.assertGreater(forward, 0.0) + self.assertLess(reverse, 0.0) + + def test_pressure_flow_residuals_bind_both_port_pressures_to_states(self) -> None: + pipe = AmesimPnl0003("pnl_3", self.medium) + pipe.properties_1() + pipe.properties_2() + + residuals = { + residual.id.rsplit(":", 1)[1]: residual + for residual in pipe.pressure_flow_equation_residuals() + } + + self.assertEqual(set(residuals), {"port_1_pressure_state", "port_2_pressure_state"}) + self.assertAlmostEqual(residuals["port_1_pressure_state"].value, 0.0) + self.assertAlmostEqual(residuals["port_2_pressure_state"].value, 0.0) + + def test_connection_derivative_conserves_external_and_center_flows(self) -> None: + pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0) + port_1 = pipe.properties_1() + port_2 = pipe.properties_2() + pipe.port_1.m_flow = 0.2 + pipe.port_2.m_flow = -0.1 + + derivative = pipe.state_derivative_from_ports( + {"port_1": port_1.h + 1000.0, "port_2": port_2.h - 1000.0} + ) + + self.assertAlmostEqual(derivative[0] + derivative[2], 0.1) + + def test_results_include_two_thermodynamic_sides_and_center_flow(self) -> None: + pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0) + + values = pipe.component_result_values() + + self.assertIn("m1", values) + self.assertIn("m2", values) + self.assertIn("dmctr", values) + self.assertIn("re", values) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnl0002_pnl0003_xml.py b/tests/test_amesim_pnl0002_pnl0003_xml.py new file mode 100644 index 0000000..32ba243 --- /dev/null +++ b/tests/test_amesim_pnl0002_pnl0003_xml.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def _storage_nodes(*, source_pressure: float = 500000.0, sink_pressure: float = 100000.0) -> list: + return [ + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": source_pressure, "T0": 300.0}, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": sink_pressure, "T0": 300.0}, + ), + ] + + +def _dynamic_pipe_params() -> dict[str, float]: + return { + "diam": 0.01, + "le": 1.0, + "rr": 1.0e-5, + "k": 1.35, + "kth": 0.0, + "extemp": 300.0, + "gi": 1.0, + "mode": 2.0, + "p0": 300000.0, + "T0": 300.0, + } + + +def _resistance_node(name: str) -> object: + return component_node( + name, + "amesim_pnl00r", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + {"diam": 0.01, "le": 0.5, "rr": 1.0e-5, "gi": 1.0}, + ) + + +def amesim_pnl0002_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnl0002-smoke", + nodes=[ + _storage_nodes()[0], + component_node( + "pnl_2", + "amesim_pnl0002", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + _dynamic_pipe_params(), + ), + _storage_nodes()[1], + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "pnl_2", "port_1"), + physical_edge("edge-2", "pnl_2", "port_2", "tank_1", "port_a"), + ], + simulation={"t_start": 0.0, "t_stop": 0.002, "step": 0.001, "max_step": 0.001, "method": "BDF"}, + ) + + +def amesim_pnl0003_project() -> ReactFlowProjectPayload: + params = { + key: value + for key, value in _dynamic_pipe_params().items() + if key not in {"p0", "T0"} + } + params.update({"p1_0": 300000.0, "T1_0": 300.0, "p2_0": 300000.0, "T2_0": 300.0}) + return ReactFlowProjectPayload( + name="amesim-pnl0003-smoke", + nodes=[ + _storage_nodes(source_pressure=300000.0, sink_pressure=300000.0)[0], + _resistance_node("resistance_1"), + component_node( + "pnl_3", + "amesim_pnl0003", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + params, + ), + _resistance_node("resistance_2"), + _storage_nodes(source_pressure=300000.0, sink_pressure=300000.0)[1], + ], + edges=[ + physical_edge("edge-1", "cylinder_1", "port_b", "resistance_1", "port_1"), + physical_edge("edge-2", "resistance_1", "port_2", "pnl_3", "port_1"), + physical_edge("edge-3", "pnl_3", "port_2", "resistance_2", "port_1"), + physical_edge("edge-4", "resistance_2", "port_2", "tank_1", "port_a"), + ], + simulation={"t_start": 0.0, "t_stop": 0.002, "step": 0.001, "max_step": 0.001, "method": "BDF"}, + ) + + +class AmesimPnl0002Pnl0003XmlTests(unittest.TestCase): + def test_pnl0002_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnl0002_project()) + + self.assertIn("pnl_2", network.components) + self.assertEqual(network.components["pnl_2"].model_type, "amesim_pnl0002") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnl0002_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnl0002_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("pnl_2.port_1.m_flow", result["series"]) + self.assertIn("pnl_2.m", result["series"]) + + def test_pnl0003_reactflow_project_compiles(self) -> None: + network = compile_reactflow_network(amesim_pnl0003_project()) + + self.assertIn("pnl_3", network.components) + self.assertEqual(network.components["pnl_3"].model_type, "amesim_pnl0003") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_pnl0003_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnl0003_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertIn("pnl_3.port_1.m_flow", result["series"]) + self.assertIn("pnl_3.dmctr", result["series"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnvo001_signal_xml.py b/tests/test_amesim_pnvo001_signal_xml.py new file mode 100644 index 0000000..451bbbe --- /dev/null +++ b/tests/test_amesim_pnvo001_signal_xml.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def signal_port(name: str, role: str, side: str) -> dict[str, str | None]: + return { + "name": name, + "kind": "signal", + "domain": "signal", + "nominalRole": role, + "positiveFlowDirection": None, + "side": side, + } + + +def signal_edge(edge_id: str, source: str, source_port: str, target: str, target_port: str) -> dict[str, str]: + return { + "id": edge_id, + "source": source, + "sourceHandle": source_port, + "target": target, + "targetHandle": target_port, + } + + +def amesim_pnvo001_signal_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnvo001-signal-smoke", + nodes=[ + component_node( + "step_1", + "amesim_step0", + [signal_port("out", "output", "right")], + {"initial": 1.0, "final": 1.0, "time": 0.0}, + ), + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 300000.0, "T0": 300.0}, + ), + component_node( + "valve_1", + "amesim_pnvo001", + [ + signal_port("res", "input", "left"), + physical_port("port_2", "bidirectional", "left"), + physical_port("port_3", "bidirectional", "right"), + ], + { + "cq": 0.72, + "area0": 5.0e-6, + "Cv": 0.5, + "Kv": 0.4, + "gi": 1.0, + "flowset": 1.0, + "opening0": 0.0, + }, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + signal_edge("signal-1", "step_1", "out", "valve_1", "res"), + physical_edge("edge-1", "cylinder_1", "port_b", "valve_1", "port_2"), + physical_edge("edge-2", "valve_1", "port_3", "tank_1", "port_a"), + ], + simulation={"t_start": 0.0, "t_stop": 0.002, "step": 0.001, "max_step": 0.001, "method": "BDF"}, + ) + + +class AmesimPnvo001SignalXmlTests(unittest.TestCase): + def test_signal_project_compiles_with_signal_connection(self) -> None: + network = compile_reactflow_network(amesim_pnvo001_signal_project()) + + self.assertIn("step_1", network.components) + self.assertIn("valve_1", network.components) + self.assertEqual(network.components["valve_1"].model_type, "amesim_pnvo001") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_signal_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_pnvo001_signal_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertEqual(result["series"]["step_1.out.signal"], [1.0, 1.0, 1.0]) + self.assertEqual(result["series"]["valve_1.res.signal"], [1.0, 1.0, 1.0]) + self.assertIn("valve_1.xv", result["series"]) + self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_signal_components.py b/tests/test_amesim_signal_components.py new file mode 100644 index 0000000..93b3110 --- /dev/null +++ b/tests/test_amesim_signal_components.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import unittest + +from app.simulation.components.amesim.flow.orifices import AmesimPnvo001SignalOpening +from app.simulation.components.amesim.signals.sources import AmesimStep0, AmesimUd00 +from app.simulation.core.medium import IdealGasMedium +from app.simulation.registry import COMPONENT_MODEL_REGISTRY +from app.simulation.solvers.signal import SignalResolver +from app.simulation.systems.network import SimulationNetwork + + +class AmesimSignalComponentTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def test_step0_output_switches_at_step_time(self) -> None: + step = AmesimStep0("step_1", self.medium, initial=0.2, final=0.8, time=0.5) + + self.assertEqual(step.output_at(0.49), 0.2) + self.assertEqual(step.output_at(0.5), 0.8) + self.assertEqual(step.signal_output_values(0.5), {"out": 0.8}) + + + def test_ud00_output_interpolates_piecewise_signal(self) -> None: + signal = AmesimUd00( + "piecewise_1", + self.medium, + tstart=0.5, + starts=(0.0, 10.0, 20.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ends=(10.0, 20.0, 30.0, 0.0, 0.0, 0.0, 0.0, 0.0), + durations=(1.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0), + nstages=3, + ) + + self.assertAlmostEqual(signal.output_at(0.0), 0.0) + self.assertAlmostEqual(signal.output_at(1.0), 5.0) + self.assertAlmostEqual(signal.output_at(2.5), 15.0) + self.assertAlmostEqual(signal.output_at(5.0), 35.0) + self.assertEqual(signal.signal_output_values(2.5), {"out": 15.0}) + + def test_ud00_can_cycle_active_stages(self) -> None: + signal = AmesimUd00( + "piecewise_1", + self.medium, + starts=(0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ends=(10.0, 20.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + durations=(1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + nstages=2, + iscyclic=True, + ) + + self.assertAlmostEqual(signal.output_at(0.25), 2.5) + self.assertAlmostEqual(signal.output_at(1.25), 12.5) + self.assertAlmostEqual(signal.output_at(2.25), 2.5) + + def test_ud00_registry_rejects_fractional_stage_controls(self) -> None: + with self.assertRaisesRegex(ValueError, "nstages must be an integer"): + COMPONENT_MODEL_REGISTRY["amesim_ud00"].create( + "piecewise_1", + self.medium, + {"nstages": 1.5}, + ) + + def test_pnvo001_signal_opening_reads_res_port(self) -> None: + valve = COMPONENT_MODEL_REGISTRY["amesim_pnvo001"].create( + "valve_1", + self.medium, + {"opening0": 0.25}, + ) + + self.assertIsInstance(valve, AmesimPnvo001SignalOpening) + self.assertEqual(set(valve.ports), {"res", "port_2", "port_3"}) + self.assertAlmostEqual(valve.opening, 0.25) + valve.res.signal = 1.5 + self.assertAlmostEqual(valve.opening, 1.0) + valve.res.signal = -0.5 + self.assertAlmostEqual(valve.opening, 0.0) + + def test_signal_resolver_propagates_step_to_valve_input(self) -> None: + network = SimulationNetwork("signal-smoke") + step = AmesimStep0("step_1", self.medium, initial=0.0, final=0.75, time=0.1) + valve = AmesimPnvo001SignalOpening("valve_1", self.medium, opening0=0.0) + network.add_component(step) + network.add_component(valve) + network.connect("step_1", "out", "valve_1", "res", connection_id="signal-1") + + resolver = SignalResolver(network) + resolver.solve(0.2) + + self.assertAlmostEqual(step.out.signal, 0.75) + self.assertAlmostEqual(valve.res.signal, 0.75) + self.assertAlmostEqual(valve.opening, 0.75) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_ud00_xml.py b/tests/test_amesim_ud00_xml.py new file mode 100644 index 0000000..51fa88b --- /dev/null +++ b/tests/test_amesim_ud00_xml.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_amesim_pnvo001_signal_xml import signal_edge, signal_port +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def amesim_ud00_signal_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-ud00-signal-smoke", + nodes=[ + component_node( + "piecewise_1", + "amesim_ud00", + [signal_port("out", "output", "right")], + { + "tstart": 0.0, + "start1": 1.0, + "end1": 1.0, + "t1": 1.0, + "start2": 1.0, + "end2": 1.0, + "t2": 0.0, + "start3": 1.0, + "end3": 1.0, + "t3": 0.0, + "start4": 1.0, + "end4": 1.0, + "t4": 0.0, + "start5": 1.0, + "end5": 1.0, + "t5": 0.0, + "start6": 1.0, + "end6": 1.0, + "t6": 0.0, + "start7": 1.0, + "end7": 1.0, + "t7": 0.0, + "start8": 1.0, + "end8": 1.0, + "t8": 0.0, + "nstages": 1.0, + "iscyclic": 0.0, + }, + ), + component_node( + "cylinder_1", + "cylinder", + [physical_port("port_b", "outlet", "right")], + {"volume": 0.01, "p0": 300000.0, "T0": 300.0}, + ), + component_node( + "valve_1", + "amesim_pnvo001", + [ + signal_port("res", "input", "left"), + physical_port("port_2", "bidirectional", "left"), + physical_port("port_3", "bidirectional", "right"), + ], + { + "cq": 0.72, + "area0": 5.0e-6, + "Cv": 0.5, + "Kv": 0.4, + "gi": 1.0, + "flowset": 1.0, + "opening0": 0.0, + }, + ), + component_node( + "tank_1", + "tank", + [physical_port("port_a", "inlet", "left")], + {"volume": 0.1, "p0": 100000.0, "T0": 300.0}, + ), + ], + edges=[ + signal_edge("signal-1", "piecewise_1", "out", "valve_1", "res"), + physical_edge("edge-1", "cylinder_1", "port_b", "valve_1", "port_2"), + physical_edge("edge-2", "valve_1", "port_3", "tank_1", "port_a"), + ], + simulation={"t_start": 0.0, "t_stop": 0.002, "step": 0.001, "max_step": 0.001, "method": "BDF"}, + ) + + +class AmesimUd00XmlTests(unittest.TestCase): + def test_signal_project_compiles_with_signal_connection(self) -> None: + network = compile_reactflow_network(amesim_ud00_signal_project()) + + self.assertIn("piecewise_1", network.components) + self.assertEqual(network.components["piecewise_1"].model_type, "amesim_ud00") + self.assertEqual(network.components["valve_1"].model_type, "amesim_pnvo001") + self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) + + def test_signal_system_xml_validates_and_simulates(self) -> None: + xml = build_reactflow_system_xml(amesim_ud00_signal_project()) + report = validate_system_xml_document(xml) + + self.assertTrue(report.valid, report.as_dict()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["series"]["time"], [0.0, 0.001, 0.002]) + self.assertEqual(result["series"]["piecewise_1.out.signal"], [1.0, 1.0, 1.0]) + self.assertEqual(result["series"]["valve_1.res.signal"], [1.0, 1.0, 1.0]) + self.assertIn("valve_1.xv", result["series"]) + self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_component_catalog.py b/tests/test_component_catalog.py index 7279141..494ab3b 100644 --- a/tests/test_component_catalog.py +++ b/tests/test_component_catalog.py @@ -47,8 +47,8 @@ class ComponentCatalogTests(unittest.TestCase): } self.assertEqual(library["label"], "AMESim 组件库") - self.assertEqual([category["id"] for category in library["categories"]], ["storage", "flow", "junctions", "boundary"]) - self.assertEqual(set(components), {"amesim_pnpl01", "amesim_pnch023", "amesim_pnor001", "amesim_pnvo001_fixed", "amesim_pnl00r", "amesim_pn3node2", "amesim_p4node2"}) + self.assertEqual([category["id"] for category in library["categories"]], ["storage", "flow", "junctions", "boundary", "signals", "mechanical"]) + self.assertEqual(set(components), {"amesim_pnpl01", "amesim_step0", "amesim_ud00", "amesim_f000", "amesim_forc", "amesim_mecmas21", "amesim_pnch023", "amesim_pnch012", "amesim_pnor001", "amesim_pnvo001_fixed", "amesim_pnvo001", "amesim_pnl00r", "amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003", "amesim_pn3node2", "amesim_p4node2"}) self.assertEqual( [port["name"] for port in components["amesim_p4node2"]["ports"]], ["port_1", "port_2", "port_3", "port_4"], @@ -64,6 +64,15 @@ class ComponentCatalogTests(unittest.TestCase): self.assertEqual(pnch_parameters["cvol"]["quantity"], "volume") self.assertEqual(pnch_parameters["kth"]["quantity"], "heat_transfer_coefficient") + pnch012_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnch012"]["parameters"] + } + self.assertEqual(components["amesim_pnch012"]["category"]["id"], "storage") + self.assertEqual([port["name"] for port in components["amesim_pnch012"]["ports"]], ["port_1", "port_2", "port_3", "port_4"]) + self.assertEqual(pnch012_parameters["cvol0"]["quantity"], "volume") + self.assertEqual(pnch012_parameters["dvol1"]["unit"], "m3/s") + pnor_parameters = { parameter["name"]: parameter for parameter in components["amesim_pnor001"]["parameters"] @@ -79,6 +88,33 @@ class ComponentCatalogTests(unittest.TestCase): self.assertEqual(pnvo_parameters["opening"]["maximum"], 1.0) self.assertEqual([port["name"] for port in components["amesim_pnvo001_fixed"]["ports"]], ["port_2", "port_3"]) + self.assertEqual(components["amesim_step0"]["category"]["id"], "signals") + self.assertEqual([port["name"] for port in components["amesim_step0"]["ports"]], ["out"]) + ud00_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_ud00"]["parameters"] + } + self.assertEqual(components["amesim_ud00"]["category"]["id"], "signals") + self.assertEqual([port["name"] for port in components["amesim_ud00"]["ports"]], ["out"]) + self.assertEqual(ud00_parameters["tstart"]["quantity"], "time") + self.assertEqual(ud00_parameters["nstages"]["maximum"], 8.0) + self.assertEqual(components["amesim_f000"]["category"]["id"], "mechanical") + self.assertEqual([port["name"] for port in components["amesim_f000"]["ports"]], ["port_1"]) + self.assertEqual(components["amesim_f000"]["ports"][0]["domain"], "mechanical") + self.assertEqual(components["amesim_forc"]["category"]["id"], "mechanical") + self.assertEqual([port["name"] for port in components["amesim_forc"]["ports"]], ["res", "port_2"]) + self.assertEqual(components["amesim_forc"]["ports"][1]["domain"], "mechanical") + mecmas_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_mecmas21"]["parameters"] + } + self.assertEqual(components["amesim_mecmas21"]["category"]["id"], "mechanical") + self.assertEqual([port["name"] for port in components["amesim_mecmas21"]["ports"]], ["port_1", "port_2"]) + self.assertEqual(mecmas_parameters["mass"]["unit"], "kg") + self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m") + self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow") + self.assertEqual([port["name"] for port in components["amesim_pnvo001"]["ports"]], ["res", "port_2", "port_3"]) + pnl_parameters = { parameter["name"]: parameter for parameter in components["amesim_pnl00r"]["parameters"] @@ -87,6 +123,23 @@ class ComponentCatalogTests(unittest.TestCase): self.assertEqual(pnl_parameters["diam"]["quantity"], "length") self.assertEqual(pnl_parameters["le"]["unit"], "m") + pnl0001_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnl0001"]["parameters"] + } + self.assertEqual(components["amesim_pnl0001"]["category"]["id"], "flow") + self.assertEqual(pnl0001_parameters["kth"]["quantity"], "heat_transfer_coefficient") + self.assertEqual(pnl0001_parameters["p0"]["unit"], "Pa") + + self.assertEqual(components["amesim_pnl0002"]["category"]["id"], "flow") + self.assertEqual(components["amesim_pnl0003"]["category"]["id"], "flow") + pnl0003_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnl0003"]["parameters"] + } + self.assertEqual(pnl0003_parameters["p1_0"]["unit"], "Pa") + self.assertEqual(pnl0003_parameters["T2_0"]["quantity"], "temperature") + def test_every_registered_component_is_in_the_catalog(self) -> None: self.assertEqual( set(self.components), diff --git a/tests/test_component_registry.py b/tests/test_component_registry.py index 40dfc88..3c506ff 100644 --- a/tests/test_component_registry.py +++ b/tests/test_component_registry.py @@ -31,10 +31,20 @@ class ComponentRegistryTests(unittest.TestCase): "orifice", "tee", "amesim_pnpl01", + "amesim_step0", + "amesim_ud00", + "amesim_f000", + "amesim_forc", + "amesim_mecmas21", "amesim_pnch023", + "amesim_pnch012", "amesim_pnor001", "amesim_pnvo001_fixed", + "amesim_pnvo001", "amesim_pnl00r", + "amesim_pnl0001", + "amesim_pnl0002", + "amesim_pnl0003", "amesim_pn3node2", "amesim_p4node2", ), @@ -51,10 +61,20 @@ class ComponentRegistryTests(unittest.TestCase): self.assertIn("amesim", libraries) self.assertEqual(libraries["amesim"], AMESIM_LIBRARY) self.assertEqual(models["amesim_pnpl01"].library.id, "amesim") + self.assertEqual(models["amesim_step0"].library.id, "amesim") + self.assertEqual(models["amesim_ud00"].library.id, "amesim") + self.assertEqual(models["amesim_f000"].library.id, "amesim") + self.assertEqual(models["amesim_forc"].library.id, "amesim") + self.assertEqual(models["amesim_mecmas21"].library.id, "amesim") self.assertEqual(models["amesim_pnch023"].library.id, "amesim") + self.assertEqual(models["amesim_pnch012"].library.id, "amesim") self.assertEqual(models["amesim_pnor001"].library.id, "amesim") self.assertEqual(models["amesim_pnvo001_fixed"].library.id, "amesim") + self.assertEqual(models["amesim_pnvo001"].library.id, "amesim") self.assertEqual(models["amesim_pnl00r"].library.id, "amesim") + self.assertEqual(models["amesim_pnl0001"].library.id, "amesim") + self.assertEqual(models["amesim_pnl0002"].library.id, "amesim") + self.assertEqual(models["amesim_pnl0003"].library.id, "amesim") self.assertEqual(models["amesim_pn3node2"].library.id, "amesim") self.assertEqual(models["amesim_p4node2"].library.id, "amesim")