diff --git a/AmesimModels/test_mql/README.md b/AmesimModels/test_mql/README.md index 04ae6dd..30bd77a 100644 --- a/AmesimModels/test_mql/README.md +++ b/AmesimModels/test_mql/README.md @@ -86,6 +86,14 @@ - `tests/test_test_mql_lines.py` - 保护管路数量、拓扑端口、管路模式、典型 `Data_Path` 和子模型分组。 +- `PythonModels/systems/test_mql_mechanical.py` + - 把 AMESim 配置中的 `PNRP17`、`MECMAS21`、`LSTP00A`、`LMECHN1` 等机械相关组件装配成 Python 参数目录。 + - 当前覆盖 8 个气动活塞、10 个质量/端止动、8 个弹性端止动、2 个机械节点、16 个零力源和 2 个力连接器。 + - 保留 AMESim alias 和对应 `Data_Path`,并把直径、刚度、阻尼、穿透距离等参数换算到 SI 单位。 + +- `tests/test_test_mql_mechanical.py` + - 保护机械组件数量、活塞面积、质量端止动参数、载荷质量行程限制、弹性端止动参数、机械节点端口数和单位换算。 + ## 物性约定 AMESim 模型中 `test_mql` 使用氦气,Python 侧当前通过 `HELIUM_PR` 使用 Peng-Robinson 状态方程计算气体压缩因子和密度。当前物性层先覆盖状态方程相关量,完整焓/内能偏差函数后续在接气室能量方程时再补。 @@ -132,12 +140,12 @@ AMESim 模型中 `test_mql` 使用氦气,Python 侧当前通过 `HELIUM_PR` ## 验证方式 ```bash -python3 -m py_compile PythonModels/components/amesim_pneumatic.py PythonModels/core/peng_robinson.py PythonModels/reporting/amesim_results.py PythonModels/reporting/test_mql_comparison.py PythonModels/reporting/test_mql_variables.py PythonModels/systems/test_mql.py PythonModels/systems/test_mql_config.py PythonModels/systems/test_mql_lines.py PythonModels/systems/test_mql_pneumatic.py PythonModels/scripts/run_test_mql.py +python3 -m py_compile PythonModels/components/amesim_pneumatic.py PythonModels/core/peng_robinson.py PythonModels/reporting/amesim_results.py PythonModels/reporting/test_mql_comparison.py PythonModels/reporting/test_mql_variables.py PythonModels/systems/test_mql.py PythonModels/systems/test_mql_config.py PythonModels/systems/test_mql_lines.py PythonModels/systems/test_mql_mechanical.py PythonModels/systems/test_mql_pneumatic.py PythonModels/scripts/run_test_mql.py python3 -m PythonModels.scripts.run_test_mql python3 -m unittest discover -s tests -t . ``` -当前测试覆盖的是结构、配置解析、气动原语、气动件装配、管路连接装配、AMESim 结果读取、变量目录和 AMESim/Python 时序对齐工具,不代表已经完成 AMESim 物理结果复刻。 +当前测试覆盖的是结构、配置解析、气动原语、气动件装配、管路连接装配、机械装配、AMESim 结果读取、变量目录和 AMESim/Python 时序对齐工具,不代表已经完成 AMESim 物理结果复刻。 ## 后续方向 diff --git a/PythonModels/systems/test_mql_mechanical.py b/PythonModels/systems/test_mql_mechanical.py new file mode 100644 index 0000000..b555931 --- /dev/null +++ b/PythonModels/systems/test_mql_mechanical.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import pi + +from PythonModels.reporting.amesim_results import AmesimResults +from PythonModels.reporting.test_mql_variables import ( + TestMqlVariableCatalog, + build_test_mql_variable_catalog, +) +from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent + + +MM_TO_M = 1.0e-3 +N_PER_MM_TO_N_PER_M = 1.0e3 +N_PER_MM_PER_S_TO_N_PER_M_PER_S = 1.0e3 + + +@dataclass(frozen=True) +class TestMqlPistonSpec: + alias: str + piston_diameter_m: float + rod_diameter_m: float + zero_displacement_m: float + piston_area_m2: float + rod_area_m2: float + annulus_area_m2: float + data_paths: tuple[str, ...] + + +@dataclass(frozen=True) +class TestMqlMassEndstopSpec: + alias: str + mass_kg: float + xmin_m: float + xmax_m: float + min_stiffness_n_per_m: float + max_stiffness_n_per_m: float + min_damping_n_per_m_per_s: float + max_damping_n_per_m_per_s: float + min_penetration_m: float + max_penetration_m: float + rest_coeff: float + data_paths: tuple[str, ...] + + +@dataclass(frozen=True) +class TestMqlElasticEndstopSpec: + alias: str + gap_m: float + contact_stiffness_n_per_m: float + contact_damping_n_per_m_per_s: float + spring_diameter_m: float + wire_diameter_m: float + data_paths: tuple[str, ...] + + +@dataclass(frozen=True) +class TestMqlMechanicalNodeSpec: + alias: str + port_count: int + sum_mode: int + data_paths: tuple[str, ...] + + +@dataclass(frozen=True) +class TestMqlMechanicalAssembly: + pistons: dict[str, TestMqlPistonSpec] + masses: dict[str, TestMqlMassEndstopSpec] + elastic_endstops: dict[str, TestMqlElasticEndstopSpec] + mechanical_nodes: dict[str, TestMqlMechanicalNodeSpec] + zero_force_sources: tuple[str, ...] + force_connectors: tuple[str, ...] + + @property + def component_count(self) -> int: + return ( + len(self.pistons) + + len(self.masses) + + len(self.elastic_endstops) + + len(self.mechanical_nodes) + + len(self.zero_force_sources) + + len(self.force_connectors) + ) + + @property + def aliases(self) -> tuple[str, ...]: + return tuple( + [ + *self.pistons, + *self.masses, + *self.elastic_endstops, + *self.mechanical_nodes, + *self.zero_force_sources, + *self.force_connectors, + ] + ) + + +def build_test_mql_mechanical_assembly( + config: TestMqlConfig | None = None, + amesim_results: AmesimResults | None = None, + variable_catalog: TestMqlVariableCatalog | None = None, +) -> TestMqlMechanicalAssembly: + config = config or TestMqlConfig.from_amesim_specs() + if variable_catalog is None and amesim_results is not None: + variable_catalog = build_test_mql_variable_catalog(amesim_results) + + pistons = { + component.alias: _build_piston(component, variable_catalog) + for component in config.components_by_submodel("PNRP17") + } + masses = { + component.alias: _build_mass(component, variable_catalog) + for component in config.components_by_submodel("MECMAS21") + } + elastic_endstops = { + component.alias: _build_elastic_endstop(component, variable_catalog) + for component in config.components_by_submodel("LSTP00A") + } + mechanical_nodes = { + component.alias: _build_mechanical_node(component, variable_catalog) + for component in config.components_by_submodel("LMECHN1") + } + zero_force_sources = tuple(component.alias for component in config.components_by_submodel("F000")) + force_connectors = tuple(component.alias for component in config.components_by_submodel("FORC")) + return TestMqlMechanicalAssembly( + pistons=pistons, + masses=masses, + elastic_endstops=elastic_endstops, + mechanical_nodes=mechanical_nodes, + zero_force_sources=zero_force_sources, + force_connectors=force_connectors, + ) + + +def _build_piston( + component: TestMqlResolvedComponent, + variable_catalog: TestMqlVariableCatalog | None, +) -> TestMqlPistonSpec: + piston_diameter_m = mm_to_m(component.parameter_value("dp")) + rod_diameter_m = mm_to_m(component.parameter_value("dr")) + piston_area_m2 = circular_area(piston_diameter_m) + rod_area_m2 = circular_area(rod_diameter_m) + return TestMqlPistonSpec( + alias=component.alias, + piston_diameter_m=piston_diameter_m, + rod_diameter_m=rod_diameter_m, + zero_displacement_m=mm_to_m(component.parameter_value("x0")), + piston_area_m2=piston_area_m2, + rod_area_m2=rod_area_m2, + annulus_area_m2=piston_area_m2 - rod_area_m2, + data_paths=_data_paths(variable_catalog, component.alias), + ) + + +def _build_mass( + component: TestMqlResolvedComponent, + variable_catalog: TestMqlVariableCatalog | None, +) -> TestMqlMassEndstopSpec: + return TestMqlMassEndstopSpec( + alias=component.alias, + mass_kg=component.parameter_value("mass"), + xmin_m=component.parameter_value("xmin"), + xmax_m=component.parameter_value("xmax"), + min_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmin")), + max_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmax")), + min_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmin")), + max_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmax")), + min_penetration_m=mm_to_m(component.parameter_value("Pdmin")), + max_penetration_m=mm_to_m(component.parameter_value("Pdmax")), + rest_coeff=component.parameter_value("restcoeff"), + data_paths=_data_paths(variable_catalog, component.alias), + ) + + +def _build_elastic_endstop( + component: TestMqlResolvedComponent, + variable_catalog: TestMqlVariableCatalog | None, +) -> TestMqlElasticEndstopSpec: + return TestMqlElasticEndstopSpec( + alias=component.alias, + gap_m=mm_to_m(component.parameter_value("gap0")), + contact_stiffness_n_per_m=component.parameter_value("kcont"), + contact_damping_n_per_m_per_s=component.parameter_value("rcont"), + spring_diameter_m=mm_to_m(component.parameter_value("sdiam")), + wire_diameter_m=mm_to_m(component.parameter_value("wdiam")), + data_paths=_data_paths(variable_catalog, component.alias), + ) + + +def _build_mechanical_node( + component: TestMqlResolvedComponent, + variable_catalog: TestMqlVariableCatalog | None, +) -> TestMqlMechanicalNodeSpec: + return TestMqlMechanicalNodeSpec( + alias=component.alias, + port_count=int(component.parameter_value("v1")), + sum_mode=int(component.parameter_value("sum")), + data_paths=_data_paths(variable_catalog, component.alias), + ) + + +def circular_area(diameter_m: float) -> float: + return pi * diameter_m * diameter_m / 4.0 + + +def mm_to_m(value: float) -> float: + return value * MM_TO_M + + +def n_per_mm_to_n_per_m(value: float) -> float: + return value * N_PER_MM_TO_N_PER_M + + +def n_per_mm_per_s_to_n_per_m_per_s(value: float) -> float: + return value * N_PER_MM_PER_S_TO_N_PER_M_PER_S + + +def _data_paths( + variable_catalog: TestMqlVariableCatalog | None, + alias: str, +) -> tuple[str, ...]: + if variable_catalog is None: + return () + return variable_catalog.data_paths_for_owner(alias) diff --git a/tests/test_test_mql_mechanical.py b/tests/test_test_mql_mechanical.py new file mode 100644 index 0000000..ec52ec8 --- /dev/null +++ b/tests/test_test_mql_mechanical.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from PythonModels.reporting.amesim_results import load_test_mql_amesim_results +from PythonModels.reporting.test_mql_variables import build_test_mql_variable_catalog +from PythonModels.systems.test_mql_mechanical import ( + build_test_mql_mechanical_assembly, + circular_area, + mm_to_m, + n_per_mm_per_s_to_n_per_m_per_s, + n_per_mm_to_n_per_m, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlMechanicalAssemblyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.variable_catalog = build_test_mql_variable_catalog(cls.amesim_results) + cls.assembly = build_test_mql_mechanical_assembly( + amesim_results=cls.amesim_results, + variable_catalog=cls.variable_catalog, + ) + + def test_assembles_expected_mechanical_component_counts(self) -> None: + self.assertEqual(len(self.assembly.pistons), 8) + self.assertEqual(len(self.assembly.masses), 10) + self.assertEqual(len(self.assembly.elastic_endstops), 8) + self.assertEqual(len(self.assembly.mechanical_nodes), 2) + self.assertEqual(len(self.assembly.zero_force_sources), 16) + self.assertEqual(len(self.assembly.force_connectors), 2) + self.assertEqual(self.assembly.component_count, 46) + + def test_piston_geometry_is_converted_to_si_units(self) -> None: + piston = self.assembly.pistons["pn_brp2_8"] + + self.assertAlmostEqual(piston.piston_diameter_m, 0.2) + self.assertAlmostEqual(piston.rod_diameter_m, 0.001) + self.assertAlmostEqual(piston.zero_displacement_m, 0.0) + self.assertAlmostEqual(piston.piston_area_m2, circular_area(0.2)) + self.assertAlmostEqual(piston.rod_area_m2, circular_area(0.001)) + self.assertAlmostEqual(piston.annulus_area_m2, piston.piston_area_m2 - piston.rod_area_m2) + self.assertIn("vvol1@pn_brp2_8", piston.data_paths) + self.assertIn("f2@pn_brp2_8", piston.data_paths) + + def test_mass_endstop_parameters_are_converted_to_si_units(self) -> None: + mass = self.assembly.masses["mass_friction_endstops_10"] + + self.assertEqual(mass.mass_kg, 50.0) + self.assertEqual(mass.xmin_m, -1.0) + self.assertEqual(mass.xmax_m, 0.8) + self.assertEqual(mass.min_stiffness_n_per_m, 1.0e9) + self.assertEqual(mass.max_stiffness_n_per_m, 1.0e9) + self.assertEqual(mass.min_damping_n_per_m_per_s, 1.0e4) + self.assertEqual(mass.max_damping_n_per_m_per_s, 1.0e4) + self.assertAlmostEqual(mass.min_penetration_m, 1.0e-4) + self.assertAlmostEqual(mass.max_penetration_m, 1.0e-4) + self.assertEqual(mass.rest_coeff, 0.65) + self.assertIn("v1@mass_friction_endstops_10", mass.data_paths) + self.assertIn("x1@mass_friction_endstops_10", mass.data_paths) + + def test_large_load_masses_keep_distinct_limits(self) -> None: + front = self.assembly.masses["mass_friction_endstops_18"] + rear = self.assembly.masses["mass_friction_endstops_19"] + + self.assertEqual(front.mass_kg, 170000.0) + self.assertEqual(front.xmin_m, 0.0) + self.assertEqual(front.xmax_m, 0.37) + self.assertEqual(rear.mass_kg, 90000.0) + self.assertEqual(rear.xmin_m, -0.72) + self.assertEqual(rear.xmax_m, 0.0) + + def test_elastic_endstop_parameters_are_converted_to_si_units(self) -> None: + endstop = self.assembly.elastic_endstops["elasticendstop_8"] + + self.assertEqual(endstop.gap_m, 0.0) + self.assertEqual(endstop.contact_stiffness_n_per_m, 1.0e11) + self.assertEqual(endstop.contact_damping_n_per_m_per_s, 1.0e11) + self.assertAlmostEqual(endstop.spring_diameter_m, 0.02) + self.assertAlmostEqual(endstop.wire_diameter_m, 0.002) + + def test_mechanical_nodes_keep_port_count(self) -> None: + node = self.assembly.mechanical_nodes["dynamic_mechanical_node_alternative_2"] + + self.assertEqual(node.port_count, 8) + self.assertEqual(node.sum_mode, 1) + self.assertIn("dynamic_mechanical_node_alternative_2", self.assembly.aliases) + + def test_unit_helpers(self) -> None: + self.assertAlmostEqual(mm_to_m(20.0), 0.02) + self.assertAlmostEqual(n_per_mm_to_n_per_m(1.0e6), 1.0e9) + self.assertAlmostEqual(n_per_mm_per_s_to_n_per_m_per_s(10.0), 1.0e4) + + +if __name__ == "__main__": + unittest.main()