diff --git a/PythonModels/reporting/test_mql_mechanical_observations.py b/PythonModels/reporting/test_mql_mechanical_observations.py new file mode 100644 index 0000000..e3221ca --- /dev/null +++ b/PythonModels/reporting/test_mql_mechanical_observations.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from PythonModels.reporting.amesim_results import AmesimResults +from PythonModels.reporting.test_mql_variables import ( + TestMqlVariableBinding, + TestMqlVariableCatalog, + build_test_mql_variable_catalog, +) +from PythonModels.systems.test_mql_mechanical import ( + TestMqlMechanicalAssembly, + build_test_mql_mechanical_assembly, +) + + +@dataclass(frozen=True) +class TestMqlPistonObservation: + time: float + chamber_volume_cm3: float + chamber_volume_rate_l_min: float + chamber_length_mm: float + force_port_2_n: float + force_port_3_n: float + displacement_port_2_m: float + velocity_port_2_m_s: float + displacement_port_3_m: float + velocity_port_3_m_s: float + + +@dataclass(frozen=True) +class TestMqlMassEndstopObservation: + time: float + displacement_m: float + velocity_m_s: float + acceleration_m_s2: float + lower_contact_force_n: float + upper_contact_force_n: float + viscous_friction_force_n: float + dry_friction_force_n: float + stick_flag: float + + +@dataclass(frozen=True) +class TestMqlElasticEndstopObservation: + time: float + force_n: float + duplicate_force_n: float + gap_mm: float + stiffness_n_m: float + + +@dataclass(frozen=True) +class TestMqlPistonObservationBinding: + alias: str + volume_path: str + volume_rate_path: str + length_path: str + force_port_2_path: str + force_port_3_path: str + displacement_port_2_path: str + velocity_port_2_path: str + displacement_port_3_path: str + velocity_port_3_path: str + + def observation_at(self, results: AmesimResults, index: int) -> TestMqlPistonObservation: + return TestMqlPistonObservation( + time=results.times[index], + chamber_volume_cm3=results.series(self.volume_path)[index], + chamber_volume_rate_l_min=results.series(self.volume_rate_path)[index], + chamber_length_mm=results.series(self.length_path)[index], + force_port_2_n=results.series(self.force_port_2_path)[index], + force_port_3_n=results.series(self.force_port_3_path)[index], + displacement_port_2_m=results.series(self.displacement_port_2_path)[index], + velocity_port_2_m_s=results.series(self.velocity_port_2_path)[index], + displacement_port_3_m=results.series(self.displacement_port_3_path)[index], + velocity_port_3_m_s=results.series(self.velocity_port_3_path)[index], + ) + + +@dataclass(frozen=True) +class TestMqlMassEndstopObservationBinding: + alias: str + displacement_path: str + velocity_path: str + acceleration_path: str + displacement_duplicate_path: str + velocity_duplicate_path: str + acceleration_duplicate_path: str + lower_contact_force_path: str + upper_contact_force_path: str + viscous_friction_force_path: str + dry_friction_force_path: str + stick_flag_path: str + + def observation_at(self, results: AmesimResults, index: int) -> TestMqlMassEndstopObservation: + return TestMqlMassEndstopObservation( + time=results.times[index], + displacement_m=results.series(self.displacement_path)[index], + velocity_m_s=results.series(self.velocity_path)[index], + acceleration_m_s2=results.series(self.acceleration_path)[index], + lower_contact_force_n=results.series(self.lower_contact_force_path)[index], + upper_contact_force_n=results.series(self.upper_contact_force_path)[index], + viscous_friction_force_n=results.series(self.viscous_friction_force_path)[index], + dry_friction_force_n=results.series(self.dry_friction_force_path)[index], + stick_flag=results.series(self.stick_flag_path)[index], + ) + + +@dataclass(frozen=True) +class TestMqlElasticEndstopObservationBinding: + alias: str + force_path: str + duplicate_force_path: str + gap_path: str + stiffness_path: str + + def observation_at(self, results: AmesimResults, index: int) -> TestMqlElasticEndstopObservation: + return TestMqlElasticEndstopObservation( + time=results.times[index], + force_n=results.series(self.force_path)[index], + duplicate_force_n=results.series(self.duplicate_force_path)[index], + gap_mm=results.series(self.gap_path)[index], + stiffness_n_m=results.series(self.stiffness_path)[index], + ) + + +@dataclass(frozen=True) +class TestMqlMechanicalObservationCatalog: + pistons: dict[str, TestMqlPistonObservationBinding] + masses: dict[str, TestMqlMassEndstopObservationBinding] + elastic_endstops: dict[str, TestMqlElasticEndstopObservationBinding] + + @property + def binding_count(self) -> int: + return len(self.pistons) + len(self.masses) + len(self.elastic_endstops) + + +def build_test_mql_mechanical_observation_catalog( + results: AmesimResults, + *, + variable_catalog: TestMqlVariableCatalog | None = None, + mechanical_assembly: TestMqlMechanicalAssembly | None = None, +) -> TestMqlMechanicalObservationCatalog: + variable_catalog = variable_catalog or build_test_mql_variable_catalog(results) + mechanical_assembly = mechanical_assembly or build_test_mql_mechanical_assembly( + amesim_results=results, + variable_catalog=variable_catalog, + ) + return TestMqlMechanicalObservationCatalog( + pistons={ + alias: _build_piston_binding(alias, variable_catalog) + for alias in mechanical_assembly.pistons + }, + masses={ + alias: _build_mass_binding(alias, variable_catalog) + for alias in mechanical_assembly.masses + }, + elastic_endstops={ + alias: _build_elastic_endstop_binding(alias, variable_catalog) + for alias in mechanical_assembly.elastic_endstops + }, + ) + + +def _build_piston_binding( + alias: str, + variable_catalog: TestMqlVariableCatalog, +) -> TestMqlPistonObservationBinding: + variables = _owner_variables(variable_catalog, alias) + return TestMqlPistonObservationBinding( + alias=alias, + volume_path=_required_path(variables, "vol1", expected_units="cm**3"), + volume_rate_path=_required_path(variables, "vvol1", expected_units="L/min"), + length_path=_required_path(variables, "length", expected_units="mm"), + force_port_2_path=_required_path(variables, "f2", expected_units="N"), + force_port_3_path=_required_path(variables, "f3", expected_units="N"), + displacement_port_2_path=_required_path(variables, "x5", expected_units="m"), + velocity_port_2_path=_required_path(variables, "v5", expected_units="m/s"), + displacement_port_3_path=_required_path(variables, "x4", expected_units="m"), + velocity_port_3_path=_required_path(variables, "v4", expected_units="m/s"), + ) + + +def _build_mass_binding( + alias: str, + variable_catalog: TestMqlVariableCatalog, +) -> TestMqlMassEndstopObservationBinding: + variables = _owner_variables(variable_catalog, alias) + return TestMqlMassEndstopObservationBinding( + alias=alias, + displacement_path=_required_path(variables, "x1", expected_units="m"), + velocity_path=_required_path(variables, "v1", expected_units="m/s"), + acceleration_path=_required_path(variables, "acc1", expected_units="m/s/s"), + displacement_duplicate_path=_required_path(variables, "x1dup", expected_units="m"), + velocity_duplicate_path=_required_path(variables, "v1dup", expected_units="m/s"), + acceleration_duplicate_path=_required_path(variables, "acc1dup", expected_units="m/s/s"), + lower_contact_force_path=_required_path(variables, "Fmin", expected_units="N"), + upper_contact_force_path=_required_path(variables, "Fmax", expected_units="N"), + viscous_friction_force_path=_required_path(variables, "Fvisc", expected_units="N"), + dry_friction_force_path=_required_path(variables, "Ffric", expected_units="N"), + stick_flag_path=_required_path(variables, "stick", expected_units=None), + ) + + +def _build_elastic_endstop_binding( + alias: str, + variable_catalog: TestMqlVariableCatalog, +) -> TestMqlElasticEndstopObservationBinding: + variables = _owner_variables(variable_catalog, alias) + return TestMqlElasticEndstopObservationBinding( + alias=alias, + force_path=_required_path(variables, "f1", expected_units="N"), + duplicate_force_path=_required_path(variables, "f2", expected_units="N"), + gap_path=_required_path(variables, "gap", expected_units="mm"), + stiffness_path=_required_path(variables, "kval", expected_units="N/m"), + ) + + +def _owner_variables( + variable_catalog: TestMqlVariableCatalog, + alias: str, +) -> tuple[TestMqlVariableBinding, ...]: + return tuple( + variable + for variable in variable_catalog.variables + if variable.owner_alias == alias + ) + + +def _required_path( + variables: tuple[TestMqlVariableBinding, ...], + signal_name: str, + *, + expected_units: str | None, +) -> str: + variable = _single( + tuple(variable for variable in variables if variable.signal_name == signal_name), + signal_name, + ) + _assert_units(variable, expected_units) + return variable.data_path + + +def _single( + matches: tuple[TestMqlVariableBinding, ...], + description: str, +) -> TestMqlVariableBinding: + if len(matches) != 1: + raise ValueError(f"Expected one {description} variable, found {len(matches)}.") + return matches[0] + + +def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None: + if variable.units != expected_units: + raise ValueError( + f"Unexpected units for {variable.data_path}: " + f"{variable.units!r}, expected {expected_units!r}." + ) diff --git a/tests/test_test_mql_mechanical_observations.py b/tests/test_test_mql_mechanical_observations.py new file mode 100644 index 0000000..06a8c62 --- /dev/null +++ b/tests/test_test_mql_mechanical_observations.py @@ -0,0 +1,124 @@ +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_mechanical_observations import ( + build_test_mql_mechanical_observation_catalog, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" + + +class TestMqlMechanicalObservationCatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) + cls.catalog = build_test_mql_mechanical_observation_catalog(cls.amesim_results) + + def test_builds_expected_mechanical_observation_counts(self) -> None: + self.assertEqual(len(self.catalog.pistons), 8) + self.assertEqual(len(self.catalog.masses), 10) + self.assertEqual(len(self.catalog.elastic_endstops), 8) + self.assertEqual(self.catalog.binding_count, 26) + + def test_piston_binding_exposes_amesim_data_paths(self) -> None: + binding = self.catalog.pistons["pn_brp2_8"] + + self.assertEqual(binding.volume_path, "vol1@pn_brp2_8") + self.assertEqual(binding.volume_rate_path, "vvol1@pn_brp2_8") + self.assertEqual(binding.length_path, "length@pn_brp2_8") + self.assertEqual(binding.force_port_2_path, "f2@pn_brp2_8") + self.assertEqual(binding.force_port_3_path, "f3@pn_brp2_8") + self.assertEqual(binding.displacement_port_2_path, "x5@pn_brp2_8") + self.assertEqual(binding.velocity_port_2_path, "v5@pn_brp2_8") + self.assertEqual(binding.displacement_port_3_path, "x4@pn_brp2_8") + self.assertEqual(binding.velocity_port_3_path, "v4@pn_brp2_8") + + def test_piston_observation_matches_amesim_final_sample(self) -> None: + observation = self.catalog.pistons["pn_brp2_8"].observation_at( + self.amesim_results, + -1, + ) + + self.assertAlmostEqual(observation.time, self.amesim_results.times[-1]) + self.assertAlmostEqual(observation.chamber_volume_cm3, 34242.54636512914) + self.assertAlmostEqual(observation.chamber_volume_rate_l_min, 8.360141002531034e-07) + self.assertAlmostEqual(observation.chamber_length_mm, 1090.0013536465842) + self.assertAlmostEqual(observation.displacement_port_2_m, 0.3700013536465843) + self.assertAlmostEqual(observation.velocity_port_2_m_s, 4.435303434246023e-10) + self.assertAlmostEqual(observation.displacement_port_3_m, -0.72) + self.assertAlmostEqual(observation.velocity_port_3_m_s, 0.0) + + def test_mass_binding_exposes_primary_and_reversed_duplicate_paths(self) -> None: + binding = self.catalog.masses["mass_friction_endstops_10"] + + self.assertEqual(binding.displacement_path, "x1@mass_friction_endstops_10") + self.assertEqual(binding.velocity_path, "v1@mass_friction_endstops_10") + self.assertEqual(binding.acceleration_path, "acc1@mass_friction_endstops_10") + self.assertEqual(binding.displacement_duplicate_path, "x1dup@mass_friction_endstops_10") + self.assertEqual(binding.velocity_duplicate_path, "v1dup@mass_friction_endstops_10") + self.assertEqual(binding.acceleration_duplicate_path, "acc1dup@mass_friction_endstops_10") + self.assertEqual(binding.lower_contact_force_path, "Fmin@mass_friction_endstops_10") + self.assertEqual(binding.upper_contact_force_path, "Fmax@mass_friction_endstops_10") + self.assertEqual(binding.viscous_friction_force_path, "Fvisc@mass_friction_endstops_10") + self.assertEqual(binding.dry_friction_force_path, "Ffric@mass_friction_endstops_10") + self.assertEqual(binding.stick_flag_path, "stick@mass_friction_endstops_10") + + def test_mass_observation_matches_amesim_final_sample(self) -> None: + observation = self.catalog.masses["mass_friction_endstops_10"].observation_at( + self.amesim_results, + -1, + ) + + self.assertAlmostEqual(observation.displacement_m, 0.3700013536465843) + self.assertAlmostEqual(observation.velocity_m_s, 4.435303434246023e-10) + self.assertAlmostEqual(observation.acceleration_m_s2, -3.5662378650158644e-07) + self.assertAlmostEqual(observation.lower_contact_force_n, 0.0) + self.assertAlmostEqual(observation.upper_contact_force_n, 0.0) + self.assertAlmostEqual(observation.viscous_friction_force_n, 0.0) + self.assertAlmostEqual(observation.dry_friction_force_n, 0.0) + self.assertAlmostEqual(observation.stick_flag, 0.0) + + def test_mass_duplicate_series_are_sign_reversed(self) -> None: + for binding in self.catalog.masses.values(): + with self.subTest(alias=binding.alias): + for primary_path, duplicate_path in ( + (binding.displacement_path, binding.displacement_duplicate_path), + (binding.velocity_path, binding.velocity_duplicate_path), + (binding.acceleration_path, binding.acceleration_duplicate_path), + ): + primary = self.amesim_results.series(primary_path) + duplicate = self.amesim_results.series(duplicate_path) + self.assertLess( + max(abs(a + b) for a, b in zip(primary, duplicate)), + 1.0e-12, + ) + + def test_elastic_endstop_observation_matches_amesim_final_sample(self) -> None: + observation = self.catalog.elastic_endstops["elasticendstop_8"].observation_at( + self.amesim_results, + -1, + ) + + self.assertAlmostEqual(observation.force_n, 135409.0113969468) + self.assertAlmostEqual(observation.duplicate_force_n, 135409.0113969468) + self.assertAlmostEqual(observation.gap_mm, -0.0013536465842123313) + self.assertAlmostEqual(observation.stiffness_n_m, 100000000000.0) + + def test_elastic_endstop_duplicate_force_matches_primary_series(self) -> None: + for binding in self.catalog.elastic_endstops.values(): + force = self.amesim_results.series(binding.force_path) + duplicate = self.amesim_results.series(binding.duplicate_force_path) + with self.subTest(alias=binding.alias): + self.assertLess( + max(abs(a - b) for a, b in zip(force, duplicate)), + 1.0e-9, + ) + + +if __name__ == "__main__": + unittest.main()