498 lines
18 KiB
Python
498 lines
18 KiB
Python
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.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_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.assertNotIn("node_1.port_4.x", result["series"])
|
|
self.assertNotIn("node_1.port_21.f", result["series"])
|
|
self.assertAlmostEqual(result["series"]["mass_1.a"][0], 5.0)
|
|
|
|
def test_force_node_rejects_connection_to_inactive_reserved_port(self) -> None:
|
|
project = force_node_mass_project(2)
|
|
edge = next(
|
|
edge
|
|
for edge in project.edges
|
|
if edge.id == "edge-mass"
|
|
)
|
|
edge.sourceHandle = "port_9"
|
|
|
|
with self.assertRaisesRegex(ValueError, "missing endpoint node_1.port_9"):
|
|
build_reactflow_system_xml(project)
|
|
with self.assertRaisesRegex(ValueError, "port_9 is not active"):
|
|
compile_reactflow_network(project)
|
|
|
|
xml = build_reactflow_system_xml(force_node_mass_project(2))
|
|
root = ET.fromstring(xml)
|
|
endpoint = root.find("./Connections/Connection[@id='edge-mass']/Endpoint[@component='node_1']")
|
|
assert endpoint is not None
|
|
endpoint.set("port", "port_9")
|
|
report = validate_system_xml_document(ET.tostring(root, encoding="utf-8"))
|
|
|
|
self.assertFalse(report.valid)
|
|
self.assertIn("ENDPOINT_PORT_INACTIVE", {issue.code for issue in report.issues})
|
|
|
|
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()
|