diff --git a/PythonModels/README.md b/PythonModels/README.md index e661d2b..14b9650 100644 --- a/PythonModels/README.md +++ b/PythonModels/README.md @@ -276,7 +276,7 @@ print(result.used_modelica_reference) - 结构目录:组件清单、连接拓扑、全局参数、仿真设置和变量目录。 - 结果读取:可直接解析 `.ame` 包内 `test_mql_.var` 和 `test_mql_.results`,并按 AMESim `Data_Path` 索引时序数据。 - 物性:`test_mql` 使用氦气,物性库已补 Peng-Robinson 状态方程入口。 -- 气动装配:已装配 `PNCH023 / PNCH012 / PNOR001 / PNVO001` 参数目录和首版气动原语。 +- 气动装配:已装配 `PNCH023 / PNCH012 / PNOR001 / PNVO001` 参数目录和首版气动原语;气室内部使用绝对压力计算氦气 PR 密度,对外按 AMESim `press` 表压对齐。 - 管路装配:已整理 `PNL0001 / PNL0002 / PNL0003 / PNL00R` 连接目录,保留源/目标组件和端口。 - 机械装配:已整理 `PNRP17 / MECMAS21 / LSTP00A / LMECHN1 / F000 / FORC` 参数目录。 - 机械原语:已补 `AmesimPistonGeometry`、`AmesimElasticEndstop`、`AmesimMassFrictionEndstops`,并用 AMESim 结果校验活塞几何、弹性端止动力和质量端口观测方向。 @@ -285,6 +285,8 @@ print(result.used_modelica_reference) - `PNRP17` 活塞腔体积使用环形有效面积 `piston_area - rod_area`。 - `LSTP00A` 的 `gap` 观测单位是 mm,计算接触力前必须转换为 m。 +- `PNCH023` 固定气室初始压力来自 `P0=153 bar` 的绝对压力;AMESim `press` 输出为相对 `101300 Pa` 的表压。 +- `PNCH012` 变容腔初始压力对齐 AMESim 的 `1 bar` 绝对压力,`vol` 输出单位为 cm3,且末端体积等于基础死容积加对应活塞 `vol1`。 - `MECMAS21` 的 `x1dup / v1dup / acc1dup` 是第二机械端口观测,相对 `x1 / v1 / acc1` 为反号,不是重复同值。 - 本算例中 `MECMAS21` 的 `Fmin / Fmax / Fvisc / Ffric` 在 AMESim 结果里为零;当前只把这一工况能验证的部分写入测试,没有硬猜未激活碰撞/摩擦状态机。 diff --git a/PythonModels/components/amesim_pneumatic.py b/PythonModels/components/amesim_pneumatic.py index e49741e..7e9cc99 100644 --- a/PythonModels/components/amesim_pneumatic.py +++ b/PythonModels/components/amesim_pneumatic.py @@ -51,6 +51,18 @@ def liters_to_m3(value: float) -> float: return value * 1.0e-3 +def m3_to_cm3(value: float) -> float: + return value * 1.0e6 + + +def cm3_to_m3(value: float) -> float: + return value * 1.0e-6 + + +def kg_to_g(value: float) -> float: + return value * 1.0e3 + + def mm2_to_m2(value: float) -> float: return value * 1.0e-6 @@ -99,6 +111,15 @@ class AmesimPneumaticVolume(DynamicComponent): def set_state_vector(self, values: list[float]) -> None: self.state = VolumeState.from_vector(values) + def volume_cm3(self) -> float: + return m3_to_cm3(self.volume) + + def gas_mass_g(self) -> float: + return kg_to_g(self.state.m) + + def pressure_gauge_pa(self, reference_pressure_pa: float = 101_300.0) -> float: + return self.properties().p - reference_pressure_pa + def properties(self) -> ThermodynamicProperties: if self.state.m <= 0.0: raise ValueError("volume mass must stay positive.") @@ -115,6 +136,58 @@ class AmesimPneumaticVolume(DynamicComponent): return VolumeState(m=m_flow, U=m_flow * inlet_h) +class AmesimVariablePneumaticVolume(AmesimPneumaticVolume): + """PNCH012-style volume with a dead volume plus an external moving volume.""" + + def __init__( + self, + name: str, + dead_volume: float, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + p0: float = 101_325.0, + T0: float = 293.15, + external_volume: float = 0.0, + ) -> None: + if dead_volume <= 0.0: + raise ValueError("dead_volume must be positive.") + if external_volume < 0.0: + raise ValueError("external_volume must be non-negative.") + self.dead_volume = dead_volume + self.external_volume = external_volume + super().__init__( + name=name, + volume=dead_volume + external_volume, + gas=gas, + p0=p0, + T0=T0, + ) + + @classmethod + def from_liters( + cls, + name: str, + dead_volume_liters: float, + gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, + p0: float = 101_325.0, + T0: float = 293.15, + external_volume_liters: float = 0.0, + ) -> "AmesimVariablePneumaticVolume": + return cls( + name=name, + dead_volume=liters_to_m3(dead_volume_liters), + gas=gas, + p0=p0, + T0=T0, + external_volume=liters_to_m3(external_volume_liters), + ) + + def set_external_volume_m3(self, external_volume: float) -> None: + if external_volume < 0.0: + raise ValueError("external_volume must be non-negative.") + self.external_volume = external_volume + self.volume = self.dead_volume + self.external_volume + + class AmesimPneumaticOrifice(AlgebraicComponent): """First-pass PNOR001/PNVO001-style compressible helium orifice. diff --git a/PythonModels/systems/test_mql_pneumatic.py b/PythonModels/systems/test_mql_pneumatic.py index bc35412..36b05e1 100644 --- a/PythonModels/systems/test_mql_pneumatic.py +++ b/PythonModels/systems/test_mql_pneumatic.py @@ -7,22 +7,37 @@ from PythonModels.components.amesim_pneumatic import ( AmesimPneumaticGas, AmesimPneumaticOrifice, AmesimPneumaticVolume, + AmesimVariablePneumaticVolume, ) from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent -ATMOSPHERIC_PRESSURE_BAR = 1.013 +AMESIM_REFERENCE_PRESSURE_PA = 101_300.0 BAR_TO_PA = 1.0e5 DEFAULT_TEST_MQL_TEMPERATURE_K = 293.15 +DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR = 1.0 @dataclass(frozen=True) class TestMqlPneumaticAssembly: fixed_chambers: dict[str, AmesimPneumaticVolume] - variable_chambers: dict[str, AmesimPneumaticVolume] + variable_chambers: dict[str, AmesimVariablePneumaticVolume] fixed_orifices: dict[str, AmesimPneumaticOrifice] variable_orifices: dict[str, AmesimPneumaticOrifice] - initial_pressure_pa: float + fixed_initial_absolute_pressure_pa: float + variable_initial_absolute_pressure_pa: float + + @property + def initial_pressure_pa(self) -> float: + return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa) + + @property + def fixed_initial_gauge_pressure_pa(self) -> float: + return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa) + + @property + def variable_initial_gauge_pressure_pa(self) -> float: + return pressure_to_amesim_gauge_pa(self.variable_initial_absolute_pressure_pa) @property def chamber_count(self) -> int: @@ -53,13 +68,18 @@ def build_test_mql_pneumatic_assembly( gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, ) -> TestMqlPneumaticAssembly: config = config or TestMqlConfig.from_amesim_specs() - initial_pressure_pa = pressure_from_amesim_bar_parameter(config.global_parameters["P0"]) + fixed_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter( + config.global_parameters["P0"] + ) + variable_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter( + DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR + ) fixed_chambers = { component.alias: _build_chamber( component, volume_parameter="cvol", gas=gas, - initial_pressure_pa=initial_pressure_pa, + initial_pressure_pa=fixed_initial_absolute_pressure_pa, ) for component in config.components_by_submodel("PNCH023") } @@ -68,7 +88,7 @@ def build_test_mql_pneumatic_assembly( component, volume_parameter="cvol0", gas=gas, - initial_pressure_pa=initial_pressure_pa, + initial_pressure_pa=variable_initial_absolute_pressure_pa, ) for component in config.components_by_submodel("PNCH012") } @@ -95,12 +115,21 @@ def build_test_mql_pneumatic_assembly( variable_chambers=variable_chambers, fixed_orifices=fixed_orifices, variable_orifices=variable_orifices, - initial_pressure_pa=initial_pressure_pa, + fixed_initial_absolute_pressure_pa=fixed_initial_absolute_pressure_pa, + variable_initial_absolute_pressure_pa=variable_initial_absolute_pressure_pa, ) +def absolute_pressure_from_amesim_bar_parameter(pressure_bar: float) -> float: + return pressure_bar * BAR_TO_PA + + +def pressure_to_amesim_gauge_pa(absolute_pressure_pa: float) -> float: + return absolute_pressure_pa - AMESIM_REFERENCE_PRESSURE_PA + + def pressure_from_amesim_bar_parameter(pressure_bar: float) -> float: - return (pressure_bar - ATMOSPHERIC_PRESSURE_BAR) * BAR_TO_PA + return pressure_to_amesim_gauge_pa(absolute_pressure_from_amesim_bar_parameter(pressure_bar)) def _build_chamber( @@ -110,6 +139,14 @@ def _build_chamber( gas: AmesimPneumaticGas, initial_pressure_pa: float, ) -> AmesimPneumaticVolume: + if volume_parameter == "cvol0": + return AmesimVariablePneumaticVolume.from_liters( + name=component.alias, + dead_volume_liters=component.parameter_value(volume_parameter), + gas=gas, + p0=initial_pressure_pa, + T0=_component_temperature(component), + ) return AmesimPneumaticVolume.from_liters( name=component.alias, volume_liters=component.parameter_value(volume_parameter), diff --git a/tests/test_amesim_pneumatic_components.py b/tests/test_amesim_pneumatic_components.py index 5c9243f..5f6ae5d 100644 --- a/tests/test_amesim_pneumatic_components.py +++ b/tests/test_amesim_pneumatic_components.py @@ -6,9 +6,13 @@ from PythonModels.components.amesim_pneumatic import ( HELIUM_PNEUMATIC_GAS, AmesimPneumaticOrifice, AmesimPneumaticVolume, + AmesimVariablePneumaticVolume, + cm3_to_m3, compressible_orifice_mass_flow, diameter_mm_to_area_m2, + kg_to_g, liters_to_m3, + m3_to_cm3, mm2_to_m2, ) @@ -16,6 +20,9 @@ from PythonModels.components.amesim_pneumatic import ( class AmesimPneumaticComponentsTest(unittest.TestCase): def test_unit_conversions(self) -> None: self.assertAlmostEqual(liters_to_m3(15.0), 0.015) + self.assertAlmostEqual(m3_to_cm3(0.015), 15000.0) + self.assertAlmostEqual(cm3_to_m3(15000.0), 0.015) + self.assertAlmostEqual(kg_to_g(1.25), 1250.0) self.assertAlmostEqual(mm2_to_m2(78.5), 78.5e-6) self.assertAlmostEqual(diameter_mm_to_area_m2(10.0), 7.853981633974483e-5) @@ -33,6 +40,33 @@ class AmesimPneumaticComponentsTest(unittest.TestCase): self.assertGreater(props.rho, 20.0) self.assertLess(props.rho, 30.0) + def test_volume_reports_amesim_style_observables(self) -> None: + volume = AmesimPneumaticVolume.from_liters( + name="pn_general_chamber", + volume_liters=57.0, + p0=15.3e6, + T0=293.15, + ) + + self.assertAlmostEqual(volume.volume_cm3(), 57000.0) + self.assertAlmostEqual( + volume.pressure_gauge_pa(reference_pressure_pa=101_300.0), + 15198700.0, + ) + self.assertGreater(volume.gas_mass_g(), 1300.0) + + def test_variable_volume_tracks_external_volume(self) -> None: + volume = AmesimVariablePneumaticVolume.from_liters( + name="pn_c1_8", + dead_volume_liters=15.0, + p0=100000.0, + T0=293.15, + ) + + self.assertAlmostEqual(volume.volume_cm3(), 15000.0) + volume.set_external_volume_m3(cm3_to_m3(34242.54636512914)) + self.assertAlmostEqual(volume.volume_cm3(), 49242.54636512914) + def test_orifice_returns_signed_mass_flow(self) -> None: orifice = AmesimPneumaticOrifice.from_mm2( name="pn_orifice_18", diff --git a/tests/test_test_mql_pneumatic.py b/tests/test_test_mql_pneumatic.py index 501335b..b779891 100644 --- a/tests/test_test_mql_pneumatic.py +++ b/tests/test_test_mql_pneumatic.py @@ -1,15 +1,27 @@ from __future__ import annotations import unittest +from pathlib import Path -from PythonModels.components.amesim_pneumatic import HELIUM_PNEUMATIC_GAS +from PythonModels.components.amesim_pneumatic import HELIUM_PNEUMATIC_GAS, cm3_to_m3 +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results from PythonModels.systems.test_mql_pneumatic import ( + absolute_pressure_from_amesim_bar_parameter, build_test_mql_pneumatic_assembly, pressure_from_amesim_bar_parameter, + pressure_to_amesim_gauge_pa, ) +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + class TestMqlPneumaticAssemblyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + def test_assembles_expected_amesim_pneumatic_component_counts(self) -> None: assembly = build_test_mql_pneumatic_assembly() @@ -23,13 +35,16 @@ class TestMqlPneumaticAssemblyTests(unittest.TestCase): self.assertIn("pn_orifice_18", assembly.aliases) self.assertIn("pn_morifice_11", assembly.aliases) - def test_uses_amesim_global_initial_pressure_convention(self) -> None: + def test_uses_amesim_pressure_conventions(self) -> None: assembly = build_test_mql_pneumatic_assembly() - self.assertAlmostEqual( - pressure_from_amesim_bar_parameter(153.0), - 15198700.0, - ) + self.assertAlmostEqual(absolute_pressure_from_amesim_bar_parameter(153.0), 15300000.0) + self.assertAlmostEqual(pressure_from_amesim_bar_parameter(153.0), 15198700.0) + self.assertAlmostEqual(pressure_to_amesim_gauge_pa(100000.0), -1300.0) + self.assertAlmostEqual(assembly.fixed_initial_absolute_pressure_pa, 15300000.0) + self.assertAlmostEqual(assembly.fixed_initial_gauge_pressure_pa, 15198700.0) + self.assertAlmostEqual(assembly.variable_initial_absolute_pressure_pa, 100000.0) + self.assertAlmostEqual(assembly.variable_initial_gauge_pressure_pa, -1300.0) self.assertAlmostEqual(assembly.initial_pressure_pa, 15198700.0) def test_chamber_parameters_are_resolved_in_si_units(self) -> None: @@ -38,6 +53,7 @@ class TestMqlPneumaticAssemblyTests(unittest.TestCase): variable_chamber = assembly.variable_chambers["pn_c1_8"] self.assertAlmostEqual(fixed_chamber.volume, 0.057) + self.assertAlmostEqual(variable_chamber.dead_volume, 0.015) self.assertAlmostEqual(variable_chamber.volume, 0.015) self.assertIs(fixed_chamber.gas, HELIUM_PNEUMATIC_GAS) self.assertIs(variable_chamber.gas, HELIUM_PNEUMATIC_GAS) @@ -55,6 +71,44 @@ class TestMqlPneumaticAssemblyTests(unittest.TestCase): self.assertAlmostEqual(variable_orifice.flow_coefficient, 0.45) self.assertAlmostEqual(variable_orifice.opening, 1.0) + def test_chamber_initial_observables_match_amesim_results(self) -> None: + assembly = build_test_mql_pneumatic_assembly() + fixed_chamber = assembly.fixed_chambers["pn_general_chamber"] + variable_chamber = assembly.variable_chambers["pn_c1_8"] + + self.assertAlmostEqual( + fixed_chamber.pressure_gauge_pa(), + self.amesim_results.series("press@pn_general_chamber")[0], + delta=1.0e-8, + ) + self.assertAlmostEqual( + variable_chamber.pressure_gauge_pa(), + self.amesim_results.series("press@pn_c1_8")[0], + delta=1.0e-8, + ) + self.assertAlmostEqual( + variable_chamber.volume_cm3(), + self.amesim_results.series("vol@pn_c1_8")[0], + ) + self.assertAlmostEqual( + variable_chamber.gas_mass_g(), + self.amesim_results.series("mgas1@pn_c1_8")[0], + delta=1.0e-4, + ) + + def test_variable_chamber_volume_matches_piston_volume_observable(self) -> None: + assembly = build_test_mql_pneumatic_assembly() + for chamber_alias, chamber in assembly.variable_chambers.items(): + piston_alias = chamber_alias.replace("pn_c1", "pn_brp2") + with self.subTest(alias=chamber_alias): + piston_volume_cm3 = self.amesim_results.series(f"vol1@{piston_alias}")[-1] + chamber.set_external_volume_m3(cm3_to_m3(piston_volume_cm3)) + self.assertAlmostEqual( + chamber.volume_cm3(), + self.amesim_results.series(f"vol@{chamber_alias}")[-1], + delta=1.0e-6, + ) + def test_assembled_orifice_can_compute_helium_mass_flow(self) -> None: assembly = build_test_mql_pneumatic_assembly() fixed_orifice = assembly.fixed_orifices["pn_orifice_18"]