diff --git a/app/simulation/components/amesim/library.py b/app/simulation/components/amesim/library.py index 69d7b9e..43f2c7f 100644 --- a/app/simulation/components/amesim/library.py +++ b/app/simulation/components/amesim/library.py @@ -9,7 +9,7 @@ from app.simulation.core.catalog import ( LIBRARY = ComponentLibrarySpec( id="amesim", label="AMESim 组件库", - version="0.2.0", + version="0.3.0", source_package="app.simulation.components.amesim", temporary=True, order=200, @@ -33,6 +33,7 @@ LIBRARY = ComponentLibrarySpec( "app.simulation.components.amesim.mechanical.translational:AmesimMecmas21", "app.simulation.components.amesim.mechanical.translational:AmesimLstp00a", "app.simulation.components.amesim.mechanical.translational:AmesimLmechn1", + "app.simulation.components.amesim.mechanical.pistons:AmesimPnrp17", "app.simulation.components.amesim.storage.chambers:AmesimPnch023", "app.simulation.components.amesim.storage.chambers:AmesimPnch012", "app.simulation.components.amesim.flow.orifices:AmesimPnor001", diff --git a/app/simulation/components/amesim/mechanical/pistons.py b/app/simulation/components/amesim/mechanical/pistons.py new file mode 100644 index 0000000..848cf99 --- /dev/null +++ b/app/simulation/components/amesim/mechanical/pistons.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from collections.abc import Mapping +from math import pi + +from app.simulation.components.amesim.gases import ( + AMESIM_GAS_INDEX_PARAMETER, + normalize_amesim_gas_index, +) +from app.simulation.core.base import AlgebraicComponent +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 GasMedium +from app.simulation.core.ports import PortDefinition + + +AMESIM_REFERENCE_PRESSURE_PA = 101300.0 + + +class AmesimPnrp17(AlgebraicComponent): + """AMESim PNRP17 pneumatic piston with two mechanical faces. + + Mechanical ports 2/5 share the piston-side motion and ports 3/4 share the + cylinder-side motion. The pneumatic port contributes its swept volume and + volume rate to the connected variable-volume chamber. + """ + + MODEL_TYPE = "amesim_pnrp17" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.mechanical_translational("port_2"), + PortDefinition.mechanical_translational("port_3"), + PortDefinition.mechanical_translational("port_4"), + PortDefinition.mechanical_translational("port_5"), + ) + PARAMETERS = ( + AMESIM_GAS_INDEX_PARAMETER, + ParameterDefinition( + "dp", + 0.2, + label="活塞直径", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + description="活塞外径;与活塞杆直径共同确定有效受压面积。", + ), + ParameterDefinition( + "dr", + 0.001, + label="活塞杆直径", + quantity="length", + unit="m", + minimum=0.0, + description="穿过气室一侧的活塞杆直径,必须不大于活塞直径。", + ), + ParameterDefinition( + "x0", + 0.0, + label="初始腔长", + quantity="length", + unit="m", + description="机械端位移均为零时的气动腔长度。", + ), + ) + RESULT_VARIABLES = ( + ResultVariableDefinition("volume", "扫掠容积", "volume", "m3", "derived", 10), + ResultVariableDefinition( + "volume_flow", + "扫掠容积变化率", + "volume_flow", + "m3/s", + "derived", + 20, + ), + ResultVariableDefinition("length", "气动腔长度", "length", "m", "derived", 30), + ResultVariableDefinition( + "pressure_force", + "气压力", + "force", + "N", + "derived", + 40, + ), + ) + DISPLAY = ComponentDisplaySpec( + label="PNRP17 气动活塞", + library_id="amesim", + category_id="mechanical", + symbol="amesim_pnrp17", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_3", "left", order=20), + PortDisplaySpec("port_4", "left", order=30), + PortDisplaySpec("port_2", "right", order=40), + PortDisplaySpec("port_5", "right", order=50), + ), + order=60, + ) + + def __init__( + self, + name: str, + medium: GasMedium, + *, + gi: float = 0.0, + dp: float = 0.2, + dr: float = 0.001, + x0: float = 0.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values({"gi": gi, "dp": dp, "dr": dr, "x0": x0}) + self.medium = medium + self.gi = normalize_amesim_gas_index(gi) + self.dp = float(dp) + self.dr = float(dr) + self.x0 = float(x0) + if self.dr > self.dp: + raise ValueError("PNRP17 rod diameter dr must not exceed piston diameter dp.") + for definition in self.PORTS: + port = self.register_declared_port(definition.name) + setattr(self, definition.name, port) + self.port_1.h_outflow = medium.specific_enthalpy(medium.T_ref) + + @classmethod + def create( + cls, + *, + name: str, + medium: GasMedium, + parameters: Mapping[str, float], + ) -> "AmesimPnrp17": + return cls(name=name, medium=medium, **dict(parameters)) + + @property + def effective_area(self) -> float: + return pi * (self.dp * self.dp - self.dr * self.dr) / 4.0 + + @property + def chamber_length(self) -> float: + return self.x0 + self.port_5.x - self.port_4.x + + @property + def chamber_volume(self) -> float: + return self.effective_area * self.chamber_length + + @property + def chamber_volume_flow(self) -> float: + return self.effective_area * (self.port_5.v - self.port_4.v) + + @property + def pressure_force(self) -> float: + return (self.port_1.p - AMESIM_REFERENCE_PRESSURE_PA) * self.effective_area + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + effort_pairs = (("port_2", "port_5"), ("port_3", "port_4")) + residuals: list[EquationResidual] = [ + EquationResidual( + id=f"{self.name}:pneumatic_zero_mass_flow", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=(f"{self.name}.port_1.m_flow",), + role="flow", + value=self.port_1.m_flow, + ) + ] + for first_name, second_name in effort_pairs: + first = self.get_port(first_name) + second = self.get_port(second_name) + for variable in ("x", "v"): + residuals.append( + EquationResidual( + id=f"{self.name}:{first_name}_{second_name}_{variable}_equal", + owner="component", + owner_id=self.name, + relation="equal", + variables=( + f"{self.name}.{first_name}.{variable}", + f"{self.name}.{second_name}.{variable}", + ), + role="effort", + value=getattr(first, variable) - getattr(second, variable), + ) + ) + force = self.pressure_force + residuals.extend( + ( + EquationResidual( + id=f"{self.name}:piston_side_force_balance", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=(f"{self.name}.port_2.f", f"{self.name}.port_5.f", f"{self.name}.port_1.p"), + role="flow", + value=self.port_2.f + self.port_5.f + force, + ), + EquationResidual( + id=f"{self.name}:cylinder_side_force_balance", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=(f"{self.name}.port_3.f", f"{self.name}.port_4.f", f"{self.name}.port_1.p"), + role="flow", + value=self.port_3.f + self.port_4.f - force, + ), + ) + ) + return tuple(residuals) + + def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]: + return {"port_1": (self.chamber_volume, self.chamber_volume_flow)} + + def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + self.port_1.h_outflow = connected_h.get( + "port_1", + self.medium.specific_enthalpy(self.medium.T_ref), + ) + + def component_result_values(self) -> Mapping[str, float]: + return { + "volume": self.chamber_volume, + "volume_flow": self.chamber_volume_flow, + "length": self.chamber_length, + "pressure_force": self.pressure_force, + } diff --git a/app/simulation/components/amesim/storage/chambers.py b/app/simulation/components/amesim/storage/chambers.py index b77f069..1bcf5c7 100644 --- a/app/simulation/components/amesim/storage/chambers.py +++ b/app/simulation/components/amesim/storage/chambers.py @@ -249,10 +249,9 @@ 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. + chamber ports. Fixed/prescribed contributions remain available as SI + parameters, while connected moving-boundary components can now add live + volume and volume-rate values through the pneumatic connector contract. """ MODEL_TYPE = "amesim_pnch012" @@ -433,14 +432,29 @@ class AmesimPnch012(ThermodynamicVolumeComponent): ) -> "AmesimPnch012": return cls(name=name, medium=medium, **dict(parameters)) + def connected_external_volume(self) -> float: + return sum( + getattr(getattr(self, port_name, None), "volume", 0.0) + for port_name in self.external_volumes + ) + + def connected_external_volume_rate(self) -> float: + return sum( + getattr(getattr(self, port_name, None), "volume_flow", 0.0) + for port_name in self.external_volume_rates + ) + def total_volume(self) -> float: minimum_volume = self.cvol0 / 100.0 - return max(self.cvol0 + sum(self.external_volumes.values()), minimum_volume) + return max( + self.cvol0 + sum(self.external_volumes.values()) + self.connected_external_volume(), + 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()) + return sum(self.external_volume_rates.values()) + self.connected_external_volume_rate() def get_state_vector(self) -> list[float]: return self.state.as_vector() diff --git a/app/simulation/core/base.py b/app/simulation/core/base.py index 8c3f712..37dab30 100644 --- a/app/simulation/core/base.py +++ b/app/simulation/core/base.py @@ -204,6 +204,16 @@ class Component(ABC): return None + def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]: + """Return directed ``volume``/``volume_flow`` values by pneumatic port. + + Most pneumatic components contribute no external chamber volume. Moving + boundaries such as PNRP17 override this hook; the network resolver then + propagates the pair to the component connected at the same physical port. + """ + + return {} + class DynamicComponent(Component): state_size = 2 diff --git a/app/simulation/core/ports.py b/app/simulation/core/ports.py index 1ac3bac..65bfc76 100644 --- a/app/simulation/core/ports.py +++ b/app/simulation/core/ports.py @@ -87,6 +87,26 @@ class PortDefinition: unit="J/kg", order=30, ), + PortVariableDefinition( + "volume", + "signal", + "directed", + label="外部容积", + quantity="volume", + unit="m3", + result_visible=False, + order=40, + ), + PortVariableDefinition( + "volume_flow", + "signal", + "directed", + label="外部容积变化率", + quantity="volume_flow", + unit="m3/s", + result_visible=False, + order=50, + ), ), ) @@ -178,6 +198,8 @@ class PortState: p: float = 0.0 m_flow: float = 0.0 h_outflow: float = 0.0 + volume: float = 0.0 + volume_flow: float = 0.0 signal: float = 0.0 x: float = 0.0 v: float = 0.0 diff --git a/app/simulation/solvers/pneumatic_volume.py b/app/simulation/solvers/pneumatic_volume.py new file mode 100644 index 0000000..b2d0d22 --- /dev/null +++ b/app/simulation/solvers/pneumatic_volume.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite + +from app.simulation.systems.network import Endpoint, SimulationNetwork + + +@dataclass(frozen=True) +class PneumaticVolumeDiagnostics: + propagated: int + output_ports: tuple[str, ...] + + def as_dict(self) -> dict[str, object]: + return { + "propagated": self.propagated, + "outputPorts": list(self.output_ports), + } + + +class PneumaticVolumeResolver: + """Propagate AMESim pneumatic external-volume connector variables.""" + + def __init__(self, network: SimulationNetwork) -> None: + self.network = network + self._connected_endpoint = self._build_connection_map() + self.last_diagnostics: PneumaticVolumeDiagnostics | None = None + + def _build_connection_map(self) -> dict[Endpoint, Endpoint]: + result: dict[Endpoint, Endpoint] = {} + for connection in self.network.connections: + if connection.kind != "physical" or connection.domain != "pneumatic": + continue + first, second = connection.endpoints + result[first] = second + result[second] = first + return result + + def solve(self) -> PneumaticVolumeDiagnostics: + for component in self.network.components.values(): + for definition in component.port_definitions: + if definition.kind == "physical" and definition.domain == "pneumatic": + port = component.get_port(definition.name) + port.volume = 0.0 + port.volume_flow = 0.0 + + outputs: dict[Endpoint, tuple[float, float]] = {} + for component in self.network.components.values(): + for port_name, raw_values in component.pneumatic_volume_outputs().items(): + port = component.get_port(port_name) + definition = port.definition + if ( + definition is None + or definition.kind != "physical" + or definition.domain != "pneumatic" + ): + raise ValueError( + f"Component {component.name} declares pneumatic volume output " + f"on non-pneumatic port {port_name}." + ) + volume, volume_flow = (float(raw_values[0]), float(raw_values[1])) + if not isfinite(volume) or not isfinite(volume_flow): + raise ValueError( + f"Component {component.name}.{port_name} produced a non-finite " + "pneumatic volume value." + ) + endpoint = Endpoint(component.name, port_name) + outputs[endpoint] = (volume, volume_flow) + port.volume = volume + port.volume_flow = volume_flow + + propagated = 0 + for endpoint, values in outputs.items(): + connected = self._connected_endpoint.get(endpoint) + if connected is None: + continue + if connected in outputs: + raise ValueError( + "A pneumatic connection cannot contain two external-volume " + f"sources: {endpoint} and {connected}." + ) + connected_port = self.network.components[connected.component].get_port( + connected.port + ) + connected_port.volume, connected_port.volume_flow = values + propagated += 1 + + diagnostics = PneumaticVolumeDiagnostics( + propagated=propagated, + output_ports=tuple(sorted(str(endpoint) for endpoint in outputs)), + ) + self.last_diagnostics = diagnostics + return diagnostics diff --git a/app/simulation/systems/generic.py b/app/simulation/systems/generic.py index dda580f..02bd55c 100644 --- a/app/simulation/systems/generic.py +++ b/app/simulation/systems/generic.py @@ -8,6 +8,7 @@ from typing import Literal 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.pneumatic_volume import PneumaticVolumeResolver from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode from app.simulation.solvers.signal import SignalResolver from app.simulation.solvers.stream import StreamResolver @@ -233,6 +234,7 @@ class GenericFluidSystem: self.network = network self.dynamic_components = network.dynamic_components() self.pressure_flow_solver = PressureFlowSolver(network) + self.pneumatic_volume_resolver = PneumaticVolumeResolver(network) self.signal_resolver = SignalResolver(network) self.stream_resolver = StreamResolver(network) self.algebraic_solve_count = 0 @@ -240,6 +242,7 @@ class GenericFluidSystem: self.max_algebraic_evaluations = 0 self.max_stream_iterations = 0 self.signal_propagation_count = 0 + self.pneumatic_volume_propagation_count = 0 def initial_state_vector(self) -> list[float]: return self.network.initial_state_vector() @@ -253,8 +256,14 @@ class GenericFluidSystem: for component in self.dynamic_components: component.refresh_thermodynamic_ports() algebraic = self.pressure_flow_solver.solve() + pneumatic_volume = self.pneumatic_volume_resolver.solve() + self.pneumatic_volume_propagation_count += pneumatic_volume.propagated + if pneumatic_volume.propagated: + for component in self.dynamic_components: + component.refresh_thermodynamic_ports() + algebraic = self.pressure_flow_solver.solve() stream, connected_h = self.stream_resolver.solve() - self.algebraic_solve_count += 1 + self.algebraic_solve_count += 1 + int(bool(pneumatic_volume.propagated)) self.max_algebraic_residual = max( self.max_algebraic_residual, algebraic.max_scaled_residual, @@ -440,6 +449,14 @@ class GenericFluidSystem: else None ), }, + "pneumaticVolume": { + "propagations": self.pneumatic_volume_propagation_count, + "last": ( + self.pneumatic_volume_resolver.last_diagnostics.as_dict() + if self.pneumatic_volume_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 7ab5770..9c8b441 100644 --- a/docs/amesim-component-migration-matrix.md +++ b/docs/amesim-component-migration-matrix.md @@ -1,6 +1,6 @@ # AMESim 子模型公开组件迁移矩阵 -状态:21 个模型已在临时 AMESim 库完成第一版公开,持续校准中 +状态:22 个模型已在临时 AMESim 库完成第一版公开,持续校准中 适用模型:`AmesimModels/test_mql.ame` / `app.simulation.examples.test_mql.system` @@ -14,7 +14,7 @@ - 不把现有固定拓扑 `TestMqlSystem` 整体注册为公开拖拽组件。 - 第一版公开只表示模型契约、目录、XML 编译和最小仿真链路已接通,不表示 AMESim 精确语义已完成复刻。 -- 气动、标量信号和一维机械模型按当前求解链路能力公开;事件与跨域耦合继续保留明确限制。 +- 气动、标量信号和一维机械模型按当前求解链路能力公开;PNRP17 已打通首个实时气动—机械跨域闭合,事件和更一般跨域 DAE 仍保留明确限制。 - 固定算例专用的校准原语留在 `examples/test_mql/`,不与公共组件目录混为同一实现。 ## 当前求解器能力边界 @@ -22,18 +22,18 @@ `app.simulation` 当前支持: - 物理域:`pneumatic`、标量 `signal` 和一维 `mechanical`。 -- 端口变量:气动 `p/m_flow/h_outflow`、信号 `signal`、机械 `x/v/f`。 +- 端口变量:气动 `p/m_flow/h_outflow` 及定向 `volume/volume_flow`、信号 `signal`、机械 `x/v/f`。 - 流量符号:`m_flow > 0` 表示流入当前组件。 -- 网络方程:气动连接压力相等、流量和为零、组件压力-流量残差与 stream 焓传播;标量信号传播;机械 `x/v` 等值和 `f` 平衡。 +- 网络方程:气动连接压力相等、流量和为零、组件压力-流量残差、stream 焓传播和移动边界容积传播;标量信号传播;机械 `x/v` 等值和 `f` 平衡。 - 动态:半显式 ODE,动态组件提供质量和内能状态导数。 -- 暂不支持:完整事件系统、一般高指数 DAE、气动-机械实时跨域组件,以及从 PNGD00 自动批量导入其他气体。公共编译链已经支持“介质 + 物性计算方法”定义元件:`gi=0` 使用内置空气理想气体,`gi=1..99` 引用画布中的显式介质物性实例;介质定义内部通过 `property_model` 下拉接口选择计算方法。氦气已提供 Peng-Robinson 首版。 +- 暂不支持:完整事件系统、一般高指数 DAE、任意多域连接变量,以及从 PNGD00 自动批量导入其他气体。公共编译链已经支持“介质 + 物性计算方法”定义元件:`gi=0` 使用内置空气理想气体,`gi=1..99` 引用画布中的显式介质物性实例;介质定义内部通过 `property_model` 下拉接口选择计算方法。氦气已提供 Peng-Robinson 首版。 ## 子模型族矩阵 | AMESim 子模型 | 数量 | AMESim 角色 | 当前公开状态 | 建议目标 | 先决条件 / 限制 | | --- | ---: | --- | --- | --- | --- | | `PNCH023` | 4 | 固定容积气室,带换热 | 第一版公开 | `amesim_pnch023`,`storage` | 已基于 `ThermodynamicVolumeComponent` 接入公开契约;仍需按 AMESim `cvol/extemp/kth/sth/gi` 复核质量、能量、换热方程。 | -| `PNCH012` | 8 | 变容气室,带换热 | 参数化第一版公开 | `amesim_pnch012`,`storage` | 已将 AMESim `vol1..4/dvol1..4` 外部体积输入映射为 SI 参数,支持固定/预设体积场景;实时机械耦合仍需跨域组件契约和闭合支持。 | +| `PNCH012` | 8 | 变容气室,带换热 | 实时变容第一版公开 | `amesim_pnch012`,`storage` | 保留 `vol1..4/dvol1..4` SI 参数,并可从 PNRP17 经气动端口接收实时外部容积与变化率;已完成代表性联合仿真,仍需完整 `test_mql` baseline 校准。 | | `PNOR001` | 8 | 常系数气动孔口 | 第一版公开 | `amesim_pnor001`,`flow` | 已接入代数组件公开契约;仍需按 AMESim `cq/area/Cv/Kv/flowset/gi` 复核双向流、零压差正则化和单位换算。 | | `PNVO001` | 8 | 信号调制气动孔口 | 第一版公开 | `amesim_pnvo001`,`flow` | 已接入名为 `res` 的标量信号输入端口,可由 `amesim_step0` 驱动开度;精确事件语义和 AMESim baseline 仍留后续修模。固定开度变体 `amesim_pnvo001_fixed` 继续保留。 | | `PN3NODE2` | 8 | 三端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_pn3node2`,`junctions` | 已接入三端等压、流量守恒基础版;AMESim port 2 参考温压语义和 stream 混合仍需单独测试。 | @@ -44,7 +44,7 @@ | `PNL0003` | 8 | C-R-C 动态管路 | 第一版公开 | `amesim_pnl0003`,`flow` | 已按公开契约接入两端四状态容积 + 中心摩擦流 + mode 2 换热项;大压差动态闭合和 AMESim baseline 误差仍留后续修模。 | | `PNPL01` | 16 | 零气动流源 | 第一版公开 | `amesim_pnpl01`,`boundary` | 当前实现一端零流边界,只约束端口质量流量为 0;压力源/外部边界语义留后续扩展。 | | `PNGD00` | 1 | 氦气气体定义 | 氦气 Peng-Robinson 首版公开 | `amesim_helium_medium`,`media` | 已映射源模型 `fluidType=12/eosType=6`;密度和压力使用 PR EOS,热量学暂用手册参考点的定比热闭合。完整压力相关残余焓、比热和真实气体临界流仍待状态相关物性接口。 | -| `PNRP17` | 8 | 气动活塞与移动体耦合 | 暂不公开 | 内部固定算例;后续跨域组件 | 需要跨域组件契约、实时气动-机械耦合,以及对应的事件、状态和闭合规则。 | +| `PNRP17` | 8 | 气动活塞与移动体耦合 | 第一版公开 | `amesim_pnrp17`,`mechanical` | 已接入 1 个气动端口和 4 个一维机械端口,按 `dp/dr/x0/gi` 计算环形有效面积、扫掠容积、容积变化率和表压作用力;已与 PNCH012、双质量块跑通 System XML 联合仿真,完整事件语义和 AMESim baseline 仍待校准。 | | `MECMAS21` | 10 | 一维平动质量 | 第一版公开 | `amesim_mecmas21`,`mechanical` | 已接入一维机械端口 `x/v/f`、双端质量状态和基本摩擦/限位项,并跑通零力源与信号力源最小 System XML;完整 AMESim 接触/事件语义仍留后续对齐。 | | `LMECHN1` | 2 | 动态线性机械节点 | 第一版公开 | `amesim_lmechn1`,`mechanical` | 已接入 9 端一维机械节点、端口位移/速度等值和节点力平衡,并跑通 `FORC -> LMECHN1 -> MECMAS21` 最小 System XML;端口朝向/符号细节留后续 AMESim baseline 对齐。 | | `LSTP00A` | 8 | 弹性接触/端止动 | 第一版公开 | `amesim_lstp00a`,`mechanical` | 已接入两端一维机械端口、相对位移/速度接触力和最小 System XML 仿真;当前是连续罚函数基础版,完整 AMESim 事件/非光滑接触语义留后续 baseline 对齐。 | @@ -82,13 +82,13 @@ - `PNVO001`:已第一版公开,保留固定开度变体。 - `UD00`:已第一版公开。 - `MECMAS21`、`F000`、`FORC`、`LSTP00A`、`LMECHN1`:已第一版公开。 -- `PNRP17` +- `PNRP17`:已第一版公开,并完成 PNCH012 + 双质量块跨域闭合测试。 -当前已具备一对一标量信号传播、一维机械端口基础闭合、弹性接触基础件和 9 端机械节点;剩余跨域组件仍需要继续设计气动-机械耦合、XML 协议、前端连线兼容和最小闭合系统测试。 +当前已具备一对一标量信号传播、一维机械端口基础闭合、弹性接触基础件、9 端机械节点,以及针对移动边界的气动容积定向传播与二次代数闭合。下一阶段重点是完整 `test_mql` 拓扑和 AMESim baseline 校准。 ## 下一步执行建议 -1. 以 `app.simulation.components.amesim.library` 的 21 个模型为公开清单唯一来源。 +1. 以 `app.simulation.components.amesim.library` 的 22 个模型为公开清单唯一来源。 2. 优先校准 `PNCH023 / PNOR001 / PN3NODE2 / P4NODE2 / PNL00R` 与动态管路的 AMESim baseline 误差。 3. 每次调整模型都同步补充目录校验、参数边界、System XML 编译和最小仿真测试。 -4. 跨域能力下一步聚焦 `PNRP17` 或等价实时气动-机械耦合;在事件和闭合能力完成前保持暂不公开。 +4. 用完整或代表性的 `test_mql` 画布校准 `PNRP17 + PNCH012` 的压力、力、位移和容积轨迹,并补齐事件边界语义。 diff --git a/frontend/src/ComponentSymbol.tsx b/frontend/src/ComponentSymbol.tsx index da1867c..d507477 100644 --- a/frontend/src/ComponentSymbol.tsx +++ b/frontend/src/ComponentSymbol.tsx @@ -3,6 +3,7 @@ import { AmesimF000Symbol, AmesimForcSymbol, AmesimLmechn1Symbol, + AmesimPnrp17Symbol, AmesimLstp00aSymbol, AmesimMecmas21Symbol, } from "./componentSymbols/mechanical"; @@ -194,6 +195,7 @@ const symbolRegistry = { amesim_mecmas21: { presentation: "framed", render: AmesimMecmas21Symbol }, amesim_lstp00a: { presentation: "framed", render: AmesimLstp00aSymbol }, amesim_lmechn1: { presentation: "framed", render: AmesimLmechn1Symbol }, + amesim_pnrp17: { presentation: "framed", render: AmesimPnrp17Symbol }, amesim_ideal_air_medium: { presentation: "bare", render: AmesimIdealAirMediumSymbol, @@ -321,6 +323,14 @@ const symbolPortAnchors: Partial< left: [7, 12, 17, 22, 27, 32, 37, 42].map((y) => ({ x: 5, y })), right: [{ x: 59, y: 24 }], }, + amesim_pnrp17: { + left: [ + { x: 2, y: 24 }, + { x: 2, y: 10 }, + { x: 2, y: 38 }, + ], + right: [{ x: 62, y: 10 }, { x: 62, y: 38 }], + }, }; function isRegisteredComponentSymbol(symbol: string): symbol is RegisteredComponentSymbol { diff --git a/frontend/src/componentSymbols/mechanical.tsx b/frontend/src/componentSymbols/mechanical.tsx index ffff848..f6167a2 100644 --- a/frontend/src/componentSymbols/mechanical.tsx +++ b/frontend/src/componentSymbols/mechanical.tsx @@ -215,3 +215,55 @@ export function AmesimLmechn1Symbol() { ); } + +export function AmesimPnrp17Symbol() { + return ( + + + + + + + + + P + + + ); +} diff --git a/frontend/tests/e2e/component-symbols.spec.ts b/frontend/tests/e2e/component-symbols.spec.ts index 9c5cea1..ff47b4e 100644 --- a/frontend/tests/e2e/component-symbols.spec.ts +++ b/frontend/tests/e2e/component-symbols.spec.ts @@ -28,6 +28,7 @@ const symbolCases: SymbolCase[] = [ { category: "mechanical", domain: "mechanical", modelType: "amesim_mecmas21" }, { category: "mechanical", domain: "mechanical", modelType: "amesim_lstp00a" }, { category: "mechanical", domain: "mechanical", modelType: "amesim_lmechn1" }, + { category: "mechanical", domain: "mechanical", modelType: "amesim_pnrp17" }, ]; function physicalPort( diff --git a/tests/test_amesim_component_migration_matrix.py b/tests/test_amesim_component_migration_matrix.py index 2dd1e61..79fc5c2 100644 --- a/tests/test_amesim_component_migration_matrix.py +++ b/tests/test_amesim_component_migration_matrix.py @@ -18,16 +18,15 @@ class AmesimComponentMigrationMatrixTests(unittest.TestCase): self.assertEqual(missing, []) - def test_unsupported_domains_are_not_marked_as_public_candidates(self) -> None: + def test_pnrp17_is_marked_as_the_first_public_cross_domain_component(self) -> None: text = MATRIX_PATH.read_text(encoding="utf-8") - unsupported = { - "PNRP17", - } + row = next( + line for line in text.splitlines() if "| `PNRP17` |" in line + ) - for family in unsupported: - with self.subTest(family=family): - row = next(line for line in text.splitlines() if f"`{family}`" in line) - self.assertIn("暂不公开", row) + self.assertIn("第一版公开", row) + self.assertIn("`amesim_pnrp17`", row) + self.assertNotIn("暂不公开", row) if __name__ == "__main__": diff --git a/tests/test_amesim_gas_registry.py b/tests/test_amesim_gas_registry.py index 16be7a8..ca97765 100644 --- a/tests/test_amesim_gas_registry.py +++ b/tests/test_amesim_gas_registry.py @@ -27,6 +27,7 @@ from tests.test_system_xml_protocol import physical_port GAS_INDEXED_MODEL_TYPES = ( "amesim_pnch023", "amesim_pnch012", + "amesim_pnrp17", "amesim_pnor001", "amesim_pnvo001_fixed", "amesim_pnvo001", diff --git a/tests/test_amesim_helium_medium.py b/tests/test_amesim_helium_medium.py index ff8f45f..0770bf8 100644 --- a/tests/test_amesim_helium_medium.py +++ b/tests/test_amesim_helium_medium.py @@ -69,7 +69,7 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase): def test_definition_maps_local_selector_to_amesim_fluid_and_eos_codes( self, ) -> None: - self.assertEqual(LIBRARY.version, "0.2.0") + self.assertEqual(LIBRARY.version, "0.3.0") self.assertEqual(AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL, 0) self.assertEqual(AMESIM_HELIUM_PROPERTY_MODELS[0].value, 0) self.assertEqual(AMESIM_HELIUM_PROPERTY_MODELS[0].eos_type, 6) diff --git a/tests/test_amesim_pnrp17_xml.py b/tests/test_amesim_pnrp17_xml.py new file mode 100644 index 0000000..d00efb5 --- /dev/null +++ b/tests/test_amesim_pnrp17_xml.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from math import pi +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.simulation.components.amesim.mechanical.pistons import AmesimPnrp17 +from app.simulation.core.medium import IdealGasMedium +from app.system_xml import validate_system_xml_document +from tests.test_amesim_mechanical_xml import MECMAS21_DEFAULTS, mechanical_port +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def _pneumatic_port(name: str, side: str) -> dict[str, str]: + return physical_port(name, "bidirectional", side) + + +def _zero_force_node(name: str, side: str) -> dict[str, object]: + return component_node( + name, + "amesim_f000", + [mechanical_port("port_1", side)], + ) + + +def _mass_node(name: str) -> dict[str, object]: + parameters = dict(MECMAS21_DEFAULTS) + parameters["mass"] = 2.0 + return component_node( + name, + "amesim_mecmas21", + [ + mechanical_port("port_1", "left"), + mechanical_port("port_2", "right"), + ], + parameters, + ) + + +def pnrp17_coupled_project() -> ReactFlowProjectPayload: + return ReactFlowProjectPayload( + name="amesim-pnrp17-coupled-smoke", + nodes=[ + component_node( + "chamber_1", + "amesim_pnch012", + [ + _pneumatic_port("port_1", "left"), + _pneumatic_port("port_2", "right"), + _pneumatic_port("port_3", "left"), + _pneumatic_port("port_4", "right"), + ], + { + "gi": 0.0, + "cvol0": 0.01, + "kth": 0.0, + "sth": 0.1, + "extemp": 300.0, + "p0": 200000.0, + "T0": 300.0, + "vol1": 0.0, + "vol2": 0.0, + "vol3": 0.0, + "vol4": 0.0, + "dvol1": 0.0, + "dvol2": 0.0, + "dvol3": 0.0, + "dvol4": 0.0, + }, + ), + component_node( + "piston_1", + "amesim_pnrp17", + [ + _pneumatic_port("port_1", "left"), + mechanical_port("port_2", "right"), + mechanical_port("port_3", "left"), + mechanical_port("port_4", "left"), + mechanical_port("port_5", "right"), + ], + {"gi": 0.0, "dp": 0.1, "dr": 0.02, "x0": 0.0}, + ), + _mass_node("piston_mass"), + _mass_node("cylinder_mass"), + _zero_force_node("piston_free", "left"), + _zero_force_node("cylinder_free", "right"), + _zero_force_node("piston_mass_free", "left"), + _zero_force_node("cylinder_mass_free", "right"), + component_node( + "boundary_1", + "amesim_pnpl01", + [_pneumatic_port("port_1", "left")], + ), + component_node( + "boundary_2", + "amesim_pnpl01", + [_pneumatic_port("port_1", "left")], + ), + component_node( + "boundary_4", + "amesim_pnpl01", + [_pneumatic_port("port_1", "left")], + ), + ], + edges=[ + physical_edge("edge-pneumatic", "chamber_1", "port_3", "piston_1", "port_1"), + physical_edge("edge-boundary-1", "chamber_1", "port_1", "boundary_1", "port_1"), + physical_edge("edge-boundary-2", "chamber_1", "port_2", "boundary_2", "port_1"), + physical_edge("edge-boundary-4", "chamber_1", "port_4", "boundary_4", "port_1"), + physical_edge("edge-piston-mass", "piston_1", "port_2", "piston_mass", "port_1"), + physical_edge("edge-piston-free", "piston_1", "port_5", "piston_free", "port_1"), + physical_edge("edge-piston-mass-free", "piston_mass", "port_2", "piston_mass_free", "port_1"), + physical_edge("edge-cylinder-mass", "piston_1", "port_3", "cylinder_mass", "port_1"), + physical_edge("edge-cylinder-free", "piston_1", "port_4", "cylinder_free", "port_1"), + physical_edge("edge-cylinder-mass-free", "cylinder_mass", "port_2", "cylinder_mass_free", "port_1"), + ], + simulation={ + "t_start": 0.0, + "t_stop": 0.005, + "step": 0.0025, + "max_step": 0.0005, + "method": "BDF", + }, + ) + + +class AmesimPnrp17Tests(unittest.TestCase): + def test_component_equations_match_pnrp17_geometry_and_signs(self) -> None: + medium = IdealGasMedium() + piston = AmesimPnrp17( + "piston_1", + medium, + gi=0.0, + dp=0.2, + dr=0.1, + x0=0.05, + ) + piston.port_1.p = 300000.0 + piston.port_2.x = piston.port_5.x = 0.3 + piston.port_2.v = piston.port_5.v = 0.4 + piston.port_3.x = piston.port_4.x = 0.1 + piston.port_3.v = piston.port_4.v = -0.2 + pressure_force = piston.pressure_force + piston.port_5.f = 10.0 + piston.port_2.f = -pressure_force - piston.port_5.f + piston.port_4.f = 20.0 + piston.port_3.f = pressure_force - piston.port_4.f + + expected_area = pi * (0.2**2 - 0.1**2) / 4.0 + self.assertAlmostEqual(piston.effective_area, expected_area) + self.assertAlmostEqual(piston.chamber_length, 0.25) + self.assertAlmostEqual(piston.chamber_volume, expected_area * 0.25) + self.assertAlmostEqual(piston.chamber_volume_flow, expected_area * 0.6) + for residual in piston.pressure_flow_equation_residuals(): + self.assertAlmostEqual(residual.value, 0.0, msg=residual.id) + + self.assertEqual( + piston.pneumatic_volume_outputs(), + {"port_1": (piston.chamber_volume, piston.chamber_volume_flow)}, + ) + + def test_rod_diameter_cannot_exceed_piston_diameter(self) -> None: + with self.assertRaisesRegex(ValueError, "dr must not exceed"): + AmesimPnrp17( + "piston_1", + IdealGasMedium(), + dp=0.05, + dr=0.06, + ) + + def test_cross_domain_system_xml_compiles_and_simulates(self) -> None: + project = pnrp17_coupled_project() + xml = build_reactflow_system_xml(project) + report = validate_system_xml_document(xml) + self.assertTrue(report.valid, report.as_dict()) + + network = compile_reactflow_network(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.0025, 0.005]) + piston_volume = result["series"]["piston_1.volume"] + chamber_volume = result["series"]["chamber_1.vol"] + self.assertAlmostEqual(piston_volume[0], 0.0, places=12) + self.assertGreater(piston_volume[-1], piston_volume[0]) + for chamber_value, piston_value in zip(chamber_volume, piston_volume): + self.assertAlmostEqual(chamber_value, 0.01 + piston_value, places=10) + self.assertGreater(result["series"]["piston_mass.x"][-1], 0.0) + self.assertLess(result["series"]["cylinder_mass.x"][-1], 0.0) + self.assertGreater(result["diagnostics"]["pneumaticVolume"]["propagations"], 0) + self.assertEqual( + result["diagnostics"]["pneumaticVolume"]["last"]["outputPorts"], + ["piston_1.port_1"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_component_catalog.py b/tests/test_component_catalog.py index 67407e3..c8e95c6 100644 --- a/tests/test_component_catalog.py +++ b/tests/test_component_catalog.py @@ -49,7 +49,7 @@ class ComponentCatalogTests(unittest.TestCase): self.assertEqual(library["label"], "AMESim 组件库") self.assertEqual([category["id"] for category in library["categories"]], ["media", "storage", "flow", "junctions", "boundary", "signals", "mechanical"]) - self.assertEqual(set(components), {"amesim_ideal_air_medium", "amesim_helium_medium", "amesim_pnpl01", "amesim_step0", "amesim_ud00", "amesim_f000", "amesim_forc", "amesim_mecmas21", "amesim_lstp00a", "amesim_lmechn1", "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(set(components), {"amesim_ideal_air_medium", "amesim_helium_medium", "amesim_pnpl01", "amesim_step0", "amesim_ud00", "amesim_f000", "amesim_forc", "amesim_mecmas21", "amesim_lstp00a", "amesim_lmechn1", "amesim_pnrp17", "amesim_pnch023", "amesim_pnch012", "amesim_pnor001", "amesim_pnvo001_fixed", "amesim_pnvo001", "amesim_pnl00r", "amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003", "amesim_pn3node2", "amesim_p4node2"}) for model_type, component in components.items(): with self.subTest(model_type=model_type): self.assertEqual(component["symbol"], model_type) @@ -180,6 +180,13 @@ class ComponentCatalogTests(unittest.TestCase): } self.assertEqual(components["amesim_lmechn1"]["category"]["id"], "mechanical") self.assertEqual([port["name"] for port in components["amesim_lmechn1"]["ports"]], [f"port_{index}" for index in range(1, 10)]) + pnrp_parameters = { + parameter["name"]: parameter + for parameter in components["amesim_pnrp17"]["parameters"] + } + self.assertEqual(components["amesim_pnrp17"]["category"]["id"], "mechanical") + self.assertEqual([port["name"] for port in components["amesim_pnrp17"]["ports"]], ["port_1", "port_3", "port_4", "port_2", "port_5"]) + self.assertEqual(pnrp_parameters["dp"]["unit"], "m") self.assertEqual(lmechn_parameters["v1"]["maximum"], 8.0) self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow") self.assertEqual([port["name"] for port in components["amesim_pnvo001"]["ports"]], ["res", "port_2", "port_3"]) diff --git a/tests/test_component_interfaces.py b/tests/test_component_interfaces.py index 63e344b..4115fe1 100644 --- a/tests/test_component_interfaces.py +++ b/tests/test_component_interfaces.py @@ -28,6 +28,8 @@ class ComponentInterfaceTests(unittest.TestCase): ("p", "effort", "equal"), ("m_flow", "flow", "sumToZero"), ("h_outflow", "stream", "streamMix"), + ("volume", "signal", "directed"), + ("volume_flow", "signal", "directed"), ], ) diff --git a/tests/test_component_registry.py b/tests/test_component_registry.py index aa70597..da18397 100644 --- a/tests/test_component_registry.py +++ b/tests/test_component_registry.py @@ -41,6 +41,7 @@ class ComponentRegistryTests(unittest.TestCase): "amesim_mecmas21", "amesim_lstp00a", "amesim_lmechn1", + "amesim_pnrp17", "amesim_pnch023", "amesim_pnch012", "amesim_pnor001", @@ -78,6 +79,7 @@ class ComponentRegistryTests(unittest.TestCase): self.assertEqual(models["amesim_mecmas21"].library.id, "amesim") self.assertEqual(models["amesim_lstp00a"].library.id, "amesim") self.assertEqual(models["amesim_lmechn1"].library.id, "amesim") + self.assertEqual(models["amesim_pnrp17"].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")