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, compile_system_xml_network, run_system_xml_simulation, ) from app.simulation.systems.generic import GenericFluidSystem 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_project( use_friction: float, strib: float, ) -> ReactFlowProjectPayload: parameters = { **MECMAS21_DEFAULTS, "useFriction": use_friction, "strib": strib, } return ReactFlowProjectPayload( projectSchemaVersion=1, 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", }, ) 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( projectSchemaVersion=1, name="amesim-mechanical-zero-force-smoke", 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( projectSchemaVersion=1, name="amesim-mechanical-signal-force-smoke", 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( projectSchemaVersion=1, name="amesim-mechanical-elastic-contact-smoke", 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(right_port_count: int = 8) -> ReactFlowProjectPayload: node_ports = [mechanical_port(f"port_{index}", "right") for index in range(1, 21)] node_ports.append(mechanical_port("port_21", "left")) zero_nodes = [ component_node(f"zero_{index}", "amesim_f000", [mechanical_port("port_1", "right")]) for index in range(2, right_port_count + 1) ] zero_edges = [ physical_edge(f"edge-zero-{index}", f"zero_{index}", "port_1", "node_1", f"port_{index}") for index in range(2, right_port_count + 1) ] return ReactFlowProjectPayload( projectSchemaVersion=1, name="amesim-mechanical-node-smoke", 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": float(right_port_count), "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", f"port_{right_port_count + 1}", "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_mechanical_states_use_machine_scale_absolute_tolerances(self) -> None: system = GenericFluidSystem( compile_reactflow_network(zero_force_mass_project()) ) tolerances = system.mechanical_state_reducer.absolute_tolerances(1.0e-8) self.assertEqual(tolerances, [1.0e-12, 1.0e-12]) self.assertEqual(len(tolerances), len(system.initial_state_vector())) def test_sparse_reactflow_mecmas_parameters_use_registered_defaults(self) -> None: project = mecmas_project(1.0, 1.0) 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"], 1.0) self.assertTrue(mass.use_friction) xml = build_reactflow_system_xml(project) _, parameters = mecmas_xml_parameter_values(xml) self.assertEqual(parameters["useFriction"], 2.0) self.assertEqual(parameters["strib"], 1.0) report = validate_system_xml_document(xml) self.assertTrue(report.valid, report.as_dict()) assert report.document is not None normalized_mass = compile_system_xml_network(report.document).components["mass_1"] self.assertEqual(normalized_mass.parameter_values["useFriction"], 2.0) self.assertEqual(normalized_mass.parameter_values["strib"], 1.0) self.assertTrue(normalized_mass.use_friction) def test_removed_zero_based_mecmas_code_is_rejected(self) -> None: project = mecmas_project(0.0, 0.0) with self.assertRaisesRegex(ValueError, "must be one of 1, 2"): compile_reactflow_network(project) with self.assertRaisesRegex(ValueError, "must be one of 1, 2"): build_reactflow_system_xml(project) def test_current_mecmas_codes_are_not_reinterpreted(self) -> None: project = mecmas_project(1.0, 2.0) 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_v3_rejects_removed_system_xml_encoding_marker(self) -> None: root, _ = mecmas_xml_parameter_values( build_reactflow_system_xml(mecmas_project(1.0, 1.0)) ) root.set("amesimParameterEncodingVersion", "1") report = validate_system_xml_document( ET.tostring(root, encoding="utf-8", xml_declaration=True) ) self.assertFalse(report.valid) self.assertEqual( {issue.code for issue in report.issues}, {"XSD_VALIDATION_ERROR"}, ) def test_v3_requires_complete_mecmas_parameter_set(self) -> None: root, _ = mecmas_xml_parameter_values( build_reactflow_system_xml(mecmas_project(1.0, 1.0)) ) 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.assertFalse(report.valid) self.assertEqual( [issue.code for issue in report.issues].count( "PARAMETER_REQUIRED_MISSING" ), 2, ) def test_system_xml_mecmas_codes_are_preserved(self) -> None: xml = build_reactflow_system_xml(mecmas_project(1.0, 1.0)) report = validate_system_xml_document(xml) self.assertTrue(report.valid, report.as_dict()) 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_force_node_with_two_right_ports_ignores_reserved_catalog_ports(self) -> None: project = force_node_mass_project(2) 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.assertAlmostEqual(result["series"]["mass_1.a"][0], 5.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()