from __future__ import annotations import unittest from xml.etree import ElementTree as ET from app.main import ( ReactFlowProjectPayload, build_reactflow_system_xml, compile_reactflow_network, reactflow_project_storage_data, 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 LSTP00A_DEFAULTS = { "na": 10.0, "gap0": 0.0, "kcont": 1000.0, "G": 8.57e10, "sdiam": 0.02, "wdiam": 0.002, "rcont": 0.0, "Pdis": 1.0e-7, "stiffmode": 1.0, "discContactOption": 1.0, } 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 mecmas_parameter_encoding_project( use_friction: float, strib: float, *, encoding_version: int | None, ) -> ReactFlowProjectPayload: parameters = { **MECMAS21_DEFAULTS, "useFriction": use_friction, "strib": strib, } version = ( {"amesimParameterEncodingVersion": encoding_version} if encoding_version is not None else {} ) return ReactFlowProjectPayload( name="amesim-mecmas21-parameter-encoding", nodes=[ component_node( "mass_1", "amesim_mecmas21", [ mechanical_port("port_1", "left"), mechanical_port("port_2", "right"), ], parameters, ), ], edges=[], simulation={ "t_start": 0.0, "t_stop": 0.02, "step": 0.01, "max_step": 0.01, "method": "BDF", }, **version, ) def mecmas_xml_parameter_values(xml: bytes) -> tuple[ET.Element, dict[str, float]]: root = ET.fromstring(xml) component = root.find("./Components/Component[@id='mass_1']") assert component is not None return root, { str(parameter.get("name")): float(str(parameter.get("value"))) for parameter in component.findall("Parameter") } def zero_force_mass_project() -> ReactFlowProjectPayload: return ReactFlowProjectPayload( name="amesim-mechanical-zero-force-smoke", amesimParameterEncodingVersion=1, 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", amesimParameterEncodingVersion=1, 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"}, ) def elastic_contact_project() -> ReactFlowProjectPayload: left_parameters = dict(MECMAS21_DEFAULTS) left_parameters["x0"] = 0.001 right_parameters = dict(MECMAS21_DEFAULTS) right_parameters["x0"] = 0.0 return ReactFlowProjectPayload( name="amesim-mechanical-elastic-contact-smoke", amesimParameterEncodingVersion=1, nodes=[ component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]), component_node( "mass_left", "amesim_mecmas21", [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], left_parameters, ), component_node( "contact_1", "amesim_lstp00a", [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], LSTP00A_DEFAULTS, ), component_node( "mass_right", "amesim_mecmas21", [mechanical_port("port_1", "left"), mechanical_port("port_2", "right")], right_parameters, ), component_node("zero_right", "amesim_f000", [mechanical_port("port_1", "left")]), ], edges=[ physical_edge("edge-1", "zero_left", "port_1", "mass_left", "port_1"), physical_edge("edge-2", "mass_left", "port_2", "contact_1", "port_1"), physical_edge("edge-3", "contact_1", "port_2", "mass_right", "port_1"), physical_edge("edge-4", "mass_right", "port_2", "zero_right", "port_1"), ], simulation={"t_start": 0.0, "t_stop": 0.02, "step": 0.01, "max_step": 0.005, "method": "BDF"}, ) def force_node_mass_project() -> ReactFlowProjectPayload: node_ports = [mechanical_port(f"port_{index}", "left") for index in range(1, 9)] node_ports.append(mechanical_port("port_9", "right")) zero_nodes = [ component_node(f"zero_{index}", "amesim_f000", [mechanical_port("port_1", "right")]) for index in range(2, 9) ] zero_edges = [ physical_edge(f"edge-zero-{index}", f"zero_{index}", "port_1", "node_1", f"port_{index}") for index in range(2, 9) ] return ReactFlowProjectPayload( name="amesim-mechanical-node-smoke", amesimParameterEncodingVersion=1, nodes=[ component_node( "step_1", "amesim_step0", [signal_port("out", "output", "right")], {"initial": 10.0, "final": 10.0, "time": 0.0}, ), component_node( "force_1", "amesim_forc", [signal_port("res", "input", "left"), mechanical_port("port_2", "right")], ), component_node("node_1", "amesim_lmechn1", node_ports, {"v1": 8.0, "sum": 1.0}), *zero_nodes, 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=[ signal_edge("signal-1", "step_1", "out", "force_1", "res"), physical_edge("edge-force", "force_1", "port_2", "node_1", "port_1"), *zero_edges, physical_edge("edge-mass", "node_1", "port_9", "mass_1", "port_1"), physical_edge("edge-right", "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_sparse_legacy_reactflow_mecmas_defaults_are_canonicalized( self, ) -> None: project = mecmas_parameter_encoding_project( 1.0, 1.0, encoding_version=None, ) project.nodes[0].data.parameters.pop("useFriction") project.nodes[0].data.parameters.pop("strib") network = compile_reactflow_network(project) mass = network.components["mass_1"] self.assertEqual(mass.parameter_values["useFriction"], 2.0) self.assertEqual(mass.parameter_values["strib"], 2.0) self.assertTrue(mass.use_friction) xml = build_reactflow_system_xml(project) root, parameters = mecmas_xml_parameter_values(xml) self.assertEqual(root.get("amesimParameterEncodingVersion"), "1") self.assertEqual(parameters["useFriction"], 2.0) self.assertEqual(parameters["strib"], 2.0) report = validate_system_xml_document(xml) self.assertTrue(report.valid, report.as_dict()) assert report.document is not None normalized_project = ReactFlowProjectPayload( **report.document.as_project_data() ) normalized_mass = compile_reactflow_network(normalized_project).components[ "mass_1" ] self.assertEqual(normalized_mass.parameter_values["useFriction"], 2.0) self.assertEqual(normalized_mass.parameter_values["strib"], 2.0) self.assertTrue(normalized_mass.use_friction) stored_data = reactflow_project_storage_data(project) self.assertEqual(stored_data["amesimParameterEncodingVersion"], 1) stored_parameters = stored_data["nodes"][0]["data"]["parameters"] self.assertEqual(stored_parameters["useFriction"], 2.0) self.assertEqual(stored_parameters["strib"], 2.0) stored_mass = compile_reactflow_network( ReactFlowProjectPayload(**stored_data) ).components["mass_1"] self.assertTrue(stored_mass.use_friction) def test_legacy_reactflow_mecmas_codes_compile_and_export_as_canonical( self, ) -> None: for legacy_value, canonical_value in ((0.0, 1.0), (1.0, 2.0)): with self.subTest(legacy_value=legacy_value): project = mecmas_parameter_encoding_project( legacy_value, legacy_value, encoding_version=None, ) network = compile_reactflow_network(project) mass = network.components["mass_1"] self.assertEqual( mass.parameter_values["useFriction"], canonical_value, ) self.assertEqual( mass.parameter_values["strib"], canonical_value, ) self.assertEqual( mass.use_friction, canonical_value == 2.0, ) root, parameters = mecmas_xml_parameter_values( build_reactflow_system_xml(project) ) self.assertEqual( root.get("amesimParameterEncodingVersion"), "1", ) self.assertEqual( parameters["useFriction"], canonical_value, ) self.assertEqual(parameters["strib"], canonical_value) def test_versioned_reactflow_mecmas_codes_are_not_reinterpreted(self) -> None: project = mecmas_parameter_encoding_project( 1.0, 2.0, encoding_version=1, ) network = compile_reactflow_network(project) mass = network.components["mass_1"] self.assertEqual(mass.parameter_values["useFriction"], 1.0) self.assertEqual(mass.parameter_values["strib"], 2.0) def test_legacy_system_xml_mecmas_codes_are_migrated_with_warning(self) -> None: for legacy_value, canonical_value in ((0.0, 1.0), (1.0, 2.0)): with self.subTest(legacy_value=legacy_value): root, _ = mecmas_xml_parameter_values( build_reactflow_system_xml( mecmas_parameter_encoding_project( 1.0, 1.0, encoding_version=1, ) ) ) del root.attrib["amesimParameterEncodingVersion"] component = root.find("./Components/Component[@id='mass_1']") assert component is not None for parameter in component.findall("Parameter"): if parameter.get("name") in {"useFriction", "strib"}: parameter.set("value", str(legacy_value)) report = validate_system_xml_document( ET.tostring(root, encoding="utf-8", xml_declaration=True) ) self.assertTrue(report.valid, report.as_dict()) migration_issues = [ issue for issue in report.issues if issue.code == "AMESIM_PARAMETER_ENCODING_MIGRATED" ] self.assertEqual(len(migration_issues), 1) self.assertTrue( all(issue.severity == "warning" for issue in migration_issues) ) assert report.document is not None migrated = { parameter.name: parameter.value for parameter in report.document.components[0].parameters } self.assertEqual( migrated["useFriction"], canonical_value, ) self.assertEqual(migrated["strib"], canonical_value) self.assertEqual( report.document.amesim_parameter_encoding_version, "1", ) self.assertEqual( report.document.as_project_data()[ "amesimParameterEncodingVersion" ], 1, ) def test_sparse_legacy_system_xml_mecmas_defaults_are_migrated(self) -> None: root, _ = mecmas_xml_parameter_values( build_reactflow_system_xml( mecmas_parameter_encoding_project( 1.0, 1.0, encoding_version=1, ) ) ) del root.attrib["amesimParameterEncodingVersion"] component = root.find("./Components/Component[@id='mass_1']") assert component is not None for parameter in list(component.findall("Parameter")): if parameter.get("name") in {"useFriction", "strib"}: component.remove(parameter) report = validate_system_xml_document( ET.tostring(root, encoding="utf-8", xml_declaration=True) ) self.assertTrue(report.valid, report.as_dict()) migration_issues = [ issue for issue in report.issues if issue.code == "AMESIM_PARAMETER_ENCODING_MIGRATED" ] self.assertEqual(len(migration_issues), 1) self.assertEqual(migration_issues[0].severity, "warning") assert report.document is not None self.assertEqual(report.document.amesim_parameter_encoding_version, "1") parameters = { parameter.name: parameter.value for parameter in report.document.components[0].parameters } self.assertEqual(parameters["useFriction"], 2.0) self.assertEqual(parameters["strib"], 2.0) normalized_project = ReactFlowProjectPayload( **report.document.as_project_data() ) normalized_mass = compile_reactflow_network(normalized_project).components[ "mass_1" ] self.assertEqual(normalized_mass.parameter_values["useFriction"], 2.0) self.assertEqual(normalized_mass.parameter_values["strib"], 2.0) self.assertTrue(normalized_mass.use_friction) def test_versioned_system_xml_mecmas_codes_are_not_migrated(self) -> None: xml = build_reactflow_system_xml( mecmas_parameter_encoding_project( 1.0, 1.0, encoding_version=1, ) ) report = validate_system_xml_document(xml) self.assertTrue(report.valid, report.as_dict()) self.assertNotIn( "AMESIM_PARAMETER_ENCODING_MIGRATED", {issue.code for issue in report.issues}, ) assert report.document is not None parameters = { parameter.name: parameter.value for parameter in report.document.components[0].parameters } self.assertEqual(parameters["useFriction"], 1.0) self.assertEqual(parameters["strib"], 1.0) def test_force_node_mass_project_compiles_and_simulates(self) -> None: xml = build_reactflow_system_xml(force_node_mass_project()) report = validate_system_xml_document(xml) self.assertTrue(report.valid, report.as_dict()) network = compile_reactflow_network(force_node_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"]["step_1.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) def test_elastic_contact_project_compiles_and_simulates(self) -> None: xml = build_reactflow_system_xml(elastic_contact_project()) report = validate_system_xml_document(xml) self.assertTrue(report.valid, report.as_dict()) network = compile_reactflow_network(elastic_contact_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.assertAlmostEqual(result["series"]["contact_1.force"][0], 1.0) self.assertAlmostEqual(result["series"]["mass_left.a"][0], -0.5) self.assertAlmostEqual(result["series"]["mass_right.a"][0], 0.5) self.assertLess(result["series"]["mass_left.x"][-1], 0.001) self.assertGreater(result["series"]["mass_right.x"][-1], 0.0) 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()