完善建模交互、组件图标与系统协议

This commit is contained in:
ljz committed 2026-08-15 17:40:18 +08:00
1 parent 456c29b3b6
commit 6572defaa4
66 files changed
+10067 -4163

No files matched your search

+23 -5
View File
@@ -6,6 +6,7 @@ from app.main import (
ReactFlowProjectPayload,
build_reactflow_system_xml,
compile_reactflow_network,
compile_system_xml_network,
)
from app.system_xml import validate_system_xml_document
from app.simulation.components.amesim.gases import (
@@ -176,6 +177,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
registry.resolve(2, component_name="pipe_1")
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="undefined-gas",
nodes=[pneumatic_pipe_node("pipe_1", gi=2.0)],
)
@@ -221,6 +223,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
)
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="project-air-slot",
nodes=[
pneumatic_pipe_node("pipe_1", gi=1.0),
@@ -267,6 +270,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_project_medium_definition_may_appear_after_its_references(self) -> None:
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="two-phase-medium-resolution",
nodes=[
pneumatic_chamber_node("chamber_1", gi=7.0),
@@ -285,6 +289,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
self,
) -> None:
original = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="medium-xml-round-trip",
nodes=[
ideal_air_medium_node(
@@ -302,7 +307,6 @@ class AmesimGasRegistryTests(unittest.TestCase):
self.assertTrue(report.valid, report.issues)
self.assertIsNotNone(report.document)
assert report.document is not None
self.assertEqual(report.document.medium_reference_version, "1")
components = {
component.id: component for component in report.document.components
}
@@ -310,6 +314,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
components["air_properties"].model_type,
"amesim_ideal_air_medium",
)
self.assertEqual(components["air_properties"].model_version, "0.2.0")
self.assertEqual(
{item.name: item.value for item in components["air_properties"].parameters},
{"gi": 4.0, "property_model": 0.0},
@@ -320,11 +325,17 @@ class AmesimGasRegistryTests(unittest.TestCase):
],
4.0,
)
parsed_project = ReactFlowProjectPayload(
**report.document.as_project_data()
model_components = {
component["id"]: component
for component in report.document.as_model_data()["components"]
}
self.assertEqual(
model_components["air_properties"]["parameters"],
{"gi": 4.0, "property_model": 0.0},
)
network = compile_reactflow_network(parsed_project)
self.assertEqual(model_components["pipe_1"]["parameters"]["gi"], 4.0)
network = compile_system_xml_network(report.document)
self.assertNotIn("air_properties", network.components)
self.assertIsInstance(
@@ -334,6 +345,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_duplicate_project_medium_indices_are_rejected(self) -> None:
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="duplicate-medium-index",
nodes=[
ideal_air_medium_node("air_properties_1", gi=1.0),
@@ -349,6 +361,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_medium_definition_cannot_claim_builtin_slot_zero(self) -> None:
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="reserved-medium-index",
nodes=[ideal_air_medium_node("air_properties", gi=0.0)],
)
@@ -358,6 +371,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_default_compile_uses_air_before_state_initialization(self) -> None:
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="default-air",
nodes=[pneumatic_chamber_node("chamber_1")],
)
@@ -374,6 +388,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_gi_is_resolved_before_dynamic_state_initialization(self) -> None:
registry, _, future_helium = two_slot_registry()
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="selected-medium-initialization",
nodes=[pneumatic_chamber_node("chamber_1", gi=2.0)],
)
@@ -399,6 +414,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
) -> None:
registry, _, future_helium = two_slot_registry()
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="future-helium-circuit",
nodes=[
pneumatic_pipe_node("pnl_1", gi=2.0),
@@ -425,6 +441,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_connected_circuit_rejects_conflicting_gas_indices(self) -> None:
registry, _, _ = two_slot_registry()
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="conflicting-gases",
nodes=[
pneumatic_pipe_node("air_pipe", gi=0.0),
@@ -453,6 +470,7 @@ class AmesimGasRegistryTests(unittest.TestCase):
def test_disconnected_circuits_may_use_different_gas_indices(self) -> None:
registry, air, future_helium = two_slot_registry()
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="separate-gases",
nodes=[
pneumatic_pipe_node("air_pipe", gi=0.0),
+1
View File
@@ -214,6 +214,7 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
temperature = 287.7322
volume = 0.01
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="helium-peng-robinson-compile",
nodes=[
component_node(
@@ -54,6 +54,7 @@ def helium_step_equalization_project() -> ReactFlowProjectPayload:
"""Programmatic form of the user XML that failed at the 3.04 s sample."""
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-helium-step-equalization-regression",
nodes=[
component_node(
@@ -43,14 +43,36 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
self.assertAlmostEqual(converter.output_force, 20.0)
self.assertAlmostEqual(residuals[0].value, 0.0)
self.assertEqual(converter.component_result_values(), {"force": 20.0})
self.assertEqual(converter.parameter_values, {"direction": 1.0})
converter.apply_layout_transform(rotation=180, mirrored=False)
def test_forc_negative_direction_reverses_applied_force(self) -> None:
converter = AmesimForc.create(
name="force_reverse",
medium=self.medium,
parameters={"direction": -1.0},
)
converter.res.signal = 20.0
converter.port_2.f = 20.0
self.assertAlmostEqual(
converter.pressure_flow_equation_residuals()[0].value,
0.0,
)
self.assertEqual(converter.component_result_values(), {"force": 20.0})
self.assertEqual(converter.parameter_values, {"direction": -1.0})
self.assertEqual(converter.component_result_values(), {"force": -20.0})
def test_forc_direction_only_accepts_positive_or_negative_one(self) -> None:
definition = AmesimForc.PARAMETERS[0]
self.assertEqual(AmesimForc.MODEL_VERSION, "0.2.0")
self.assertEqual(definition.name, "direction")
self.assertEqual(definition.default, 1.0)
self.assertEqual(
tuple((option.value, option.label) for option in definition.options),
((1.0, "正向"), (-1.0, "反向")),
)
with self.assertRaisesRegex(ValueError, "must be one of 1, -1"):
AmesimForc("invalid_force", direction=0.0)
def test_mecmas21_acceleration_uses_connected_port_forces(self) -> None:
mass = AmesimMecmas21(
@@ -113,6 +135,8 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
"strib": ((1.0, "否"), (2.0, "是")),
"frictionType": ((1.0, "简单"), (2.0, "高级")),
}
self.assertEqual(definitions["useFriction"].default, 2.0)
self.assertEqual(definitions["stoptype"].default, 1.0)
for name, options in expected_options.items():
with self.subTest(name=name):
@@ -200,19 +224,25 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
node.port_2.x = 0.2
node.port_2.v = 0.3
node.port_2.f = 6.0
node.port_9.x = 0.2
node.port_9.v = 0.3
node.port_9.f = -10.0
node.port_3.x = 0.2
node.port_3.v = 0.3
node.port_3.f = -10.0
residuals = node.pressure_flow_equation_residuals()
self.assertEqual(node.active_ports, ("port_1", "port_2", "port_9"))
self.assertEqual(node.active_ports, ("port_1", "port_2", "port_3"))
self.assertAlmostEqual(node.total_force, 10.0)
self.assertAlmostEqual(node.force_balance, 0.0)
self.assertEqual(len(residuals), 5)
self.assertEqual(len(residuals), 5 + 18 * 3)
self.assertTrue(all(abs(residual.value) <= 1.0e-12 for residual in residuals))
self.assertEqual(node.component_result_values(), {"tforce": 10.0})
def test_lmechn1_defaults_to_two_right_ports(self) -> None:
node = AmesimLmechn1("node_default", self.medium)
self.assertEqual(node.v1, 2)
self.assertEqual(node.active_ports, ("port_1", "port_2", "port_3"))
def test_lmechn1_registry_rejects_fractional_integer_options(self) -> None:
with self.assertRaisesRegex(ValueError, "v1 must be an integer"):
COMPONENT_MODEL_REGISTRY["amesim_lmechn1"].create(
@@ -221,6 +251,16 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
{"v1": 2.5},
)
def test_lmechn1_supports_twenty_right_ports(self) -> None:
node = AmesimLmechn1("node_20", self.medium, v1=20.0, sum=1.0)
self.assertEqual(
node.active_ports,
tuple(f"port_{index}" for index in range(1, 22)),
)
with self.assertRaisesRegex(ValueError, "must be at most 20"):
AmesimLmechn1("node_21", self.medium, v1=21.0, sum=1.0)
def test_lstp00a_generates_opposing_contact_forces(self) -> None:
contact = AmesimLstp00a(
"contact_1",
+79 -193
View File
@@ -7,7 +7,7 @@ from app.main import (
ReactFlowProjectPayload,
build_reactflow_system_xml,
compile_reactflow_network,
reactflow_project_storage_data,
compile_system_xml_network,
run_system_xml_simulation,
)
from app.simulation.systems.generic import GenericFluidSystem
@@ -64,23 +64,17 @@ def mechanical_port(name: str, side: str) -> dict[str, str]:
return physical_port(name, "bidirectional", side, domain="mechanical")
def mecmas_parameter_encoding_project(
def mecmas_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(
projectSchemaVersion=1,
name="amesim-mecmas21-parameter-encoding",
nodes=[
component_node(
@@ -101,7 +95,6 @@ def mecmas_parameter_encoding_project(
"max_step": 0.01,
"method": "BDF",
},
**version,
)
@@ -117,8 +110,8 @@ def mecmas_xml_parameter_values(xml: bytes) -> tuple[ET.Element, dict[str, float
def zero_force_mass_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-zero-force-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
component_node(
@@ -141,8 +134,8 @@ def signal_force_mass_project() -> ReactFlowProjectPayload:
parameters = dict(MECMAS21_DEFAULTS)
parameters["mass"] = 2.0
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-signal-force-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node(
"force_signal",
@@ -206,8 +199,8 @@ def elastic_contact_project() -> ReactFlowProjectPayload:
right_parameters = dict(MECMAS21_DEFAULTS)
right_parameters["x0"] = 0.0
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-elastic-contact-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node("zero_left", "amesim_f000", [mechanical_port("port_1", "right")]),
component_node(
@@ -240,20 +233,20 @@ def elastic_contact_project() -> ReactFlowProjectPayload:
)
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"))
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, 9)
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, 9)
for index in range(2, right_port_count + 1)
]
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-node-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node(
"step_1",
@@ -266,7 +259,12 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
"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}),
component_node(
"node_1",
"amesim_lmechn1",
node_ports,
{"v1": float(right_port_count), "sum": 1.0},
),
*zero_nodes,
component_node(
"mass_1",
@@ -280,7 +278,13 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
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-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"},
@@ -299,168 +303,66 @@ class AmesimMechanicalXmlTests(unittest.TestCase):
self.assertEqual(len(tolerances), len(system.initial_state_vector()))
def test_sparse_legacy_reactflow_mecmas_defaults_are_canonicalized(
self,
) -> None:
project = mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=None,
)
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"], 2.0)
self.assertEqual(mass.parameter_values["strib"], 1.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")
_, parameters = mecmas_xml_parameter_values(xml)
self.assertEqual(parameters["useFriction"], 2.0)
self.assertEqual(parameters["strib"], 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_project = ReactFlowProjectPayload(
**report.document.as_project_data()
)
normalized_mass = compile_reactflow_network(normalized_project).components[
"mass_1"
]
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"], 2.0)
self.assertEqual(normalized_mass.parameter_values["strib"], 1.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_removed_zero_based_mecmas_code_is_rejected(self) -> None:
project = mecmas_project(0.0, 0.0)
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,
)
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)
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,
)
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_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:
def test_v3_rejects_removed_system_xml_encoding_marker(self) -> None:
root, _ = mecmas_xml_parameter_values(
build_reactflow_system_xml(
mecmas_parameter_encoding_project(
1.0,
1.0,
encoding_version=1,
)
)
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))
)
del root.attrib["amesimParameterEncodingVersion"]
component = root.find("./Components/Component[@id='mass_1']")
assert component is not None
for parameter in list(component.findall("Parameter")):
@@ -471,49 +373,20 @@ class AmesimMechanicalXmlTests(unittest.TestCase):
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()
self.assertFalse(report.valid)
self.assertEqual(
[issue.code for issue in report.issues].count(
"PARAMETER_REQUIRED_MISSING"
),
2,
)
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,
)
)
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())
self.assertNotIn(
"AMESIM_PARAMETER_ENCODING_MIGRATED",
{issue.code for issue in report.issues},
)
assert report.document is not None
parameters = {
parameter.name: parameter.value
@@ -539,6 +412,19 @@ class AmesimMechanicalXmlTests(unittest.TestCase):
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)
+1
View File
@@ -24,6 +24,7 @@ def _pnpl_node(index: int) -> object:
def amesim_pnch012_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnch012-smoke",
nodes=[
component_node(
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnch023_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnch023-smoke",
nodes=[
component_node(
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pn3node_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pn3node-smoke",
nodes=[
component_node(
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnl0001_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnl0001-smoke",
nodes=[
component_node(
+2
View File
@@ -59,6 +59,7 @@ def _resistance_node(name: str) -> object:
def amesim_pnl0002_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnl0002-smoke",
nodes=[
_storage_nodes()[0],
@@ -89,6 +90,7 @@ def amesim_pnl0003_project() -> ReactFlowProjectPayload:
}
params.update({"p1_0": 300000.0, "T1_0": 300.0, "p2_0": 300000.0, "T2_0": 300.0})
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnl0003-smoke",
nodes=[
_storage_nodes(source_pressure=300000.0, sink_pressure=300000.0)[0],
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnl00r_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnl00r-smoke",
nodes=[
component_node(
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnor001_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnor001-smoke",
nodes=[
component_node(
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnpl01_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnpl01-smoke",
nodes=[
component_node(
+1 -1
View File
@@ -45,8 +45,8 @@ def _mass_node(name: str) -> dict[str, object]:
def pnrp17_coupled_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnrp17-coupled-smoke",
amesimParameterEncodingVersion=1,
nodes=[
component_node(
"chamber_1",
+1
View File
@@ -15,6 +15,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_pnvo001_fixed_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnvo001-fixed-smoke",
nodes=[
component_node(
+2
View File
@@ -36,6 +36,7 @@ def signal_edge(edge_id: str, source: str, source_port: str, target: str, target
def amesim_pnvo001_signal_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnvo001-signal-smoke",
nodes=[
component_node(
@@ -90,6 +91,7 @@ def high_pressure_helium_step_project() -> ReactFlowProjectPayload:
physical_port("port_2", "bidirectional", "right"),
]
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-pnvo001-helium-step-regression",
nodes=[
component_node(
+26
View File
@@ -116,6 +116,32 @@ class AmesimSignalComponentTests(unittest.TestCase):
self.assertAlmostEqual(valve.opening, 0.75)
self.assertEqual(resolver.event_times(0.0, 0.2), (0.1,))
def test_signal_output_can_fan_out_but_input_has_one_driver(self) -> None:
network = SimulationNetwork("signal-cardinality")
step_1 = AmesimStep0(
"step_1", self.medium, initial=0.0, final=0.75, time=0.1
)
step_2 = AmesimStep0(
"step_2", self.medium, initial=1.0, final=0.25, time=0.1
)
valve_1 = AmesimPnvo001SignalOpening(
"valve_1", self.medium, opening0=0.0
)
valve_2 = AmesimPnvo001SignalOpening(
"valve_2", self.medium, opening0=0.0
)
for component in (step_1, step_2, valve_1, valve_2):
network.add_component(component)
network.connect("step_1", "out", "valve_1", "res")
network.connect("step_1", "out", "valve_2", "res")
with self.assertRaisesRegex(ValueError, "already has a driver"):
network.connect("step_2", "out", "valve_1", "res")
SignalResolver(network).solve(0.2)
self.assertAlmostEqual(valve_1.res.signal, 0.75)
self.assertAlmostEqual(valve_2.res.signal, 0.75)
def test_signal_resolver_event_times_are_sorted_and_deduplicated(self) -> None:
network = SimulationNetwork("signal-events")
network.add_component(
+1
View File
@@ -16,6 +16,7 @@ from tests.test_system_xml_protocol import physical_port
def amesim_ud00_signal_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-ud00-signal-smoke",
nodes=[
component_node(
+10 -2
View File
@@ -208,6 +208,8 @@ class ComponentCatalogTests(unittest.TestCase):
self.assertNotIn("parameterGroups", components["amesim_f000"])
self.assertEqual(mecmas_parameters["mass"]["unit"], "kg")
self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m")
self.assertEqual(mecmas_parameters["useFriction"]["default"], 2.0)
self.assertEqual(mecmas_parameters["stoptype"]["default"], 1.0)
self.assertEqual(mecmas_parameters["theta"]["label"], "倾角(度)")
self.assertIn("+90°", mecmas_parameters["theta"]["description"])
self.assertIn("-90°", mecmas_parameters["theta"]["description"])
@@ -364,7 +366,7 @@ class ComponentCatalogTests(unittest.TestCase):
for parameter in components["amesim_lmechn1"]["parameters"]
}
self.assertEqual(components["amesim_lmechn1"]["category"]["id"], "mechanical")
self.assertEqual([port["name"] for port in components["amesim_lmechn1"]["ports"]], [f"port_{index}" for index in range(1, 10)])
self.assertEqual([port["name"] for port in components["amesim_lmechn1"]["ports"]], [f"port_{index}" for index in range(1, 22)])
pnrp_parameters = {
parameter["name"]: parameter
for parameter in components["amesim_pnrp17"]["parameters"]
@@ -372,7 +374,13 @@ class ComponentCatalogTests(unittest.TestCase):
self.assertEqual(components["amesim_pnrp17"]["category"]["id"], "mechanical")
self.assertEqual([port["name"] for port in components["amesim_pnrp17"]["ports"]], ["port_1", "port_3", "port_2", "port_4", "port_5"])
self.assertEqual(pnrp_parameters["dp"]["unit"], "m")
self.assertEqual(lmechn_parameters["v1"]["maximum"], 8.0)
self.assertEqual(lmechn_parameters["v1"]["maximum"], 20.0)
self.assertEqual(lmechn_parameters["v1"]["default"], 2.0)
self.assertEqual(lmechn_parameters["sum"]["editor"], "choice")
self.assertEqual(
lmechn_parameters["sum"]["options"],
[{"value": 1.0, "label": "各端口力代数和为零(标准节点)"}],
)
self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow")
self.assertEqual([port["name"] for port in components["amesim_pnvo001"]["ports"]], ["res", "port_2", "port_3"])
+5 -1
View File
@@ -24,6 +24,7 @@ from app.main import (
simulate_system_xml,
)
from app.simulation.components.experimental.flow.resistive_pipe import ResistivePipe
from app.simulation.registry import get_component_model_spec
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import (
GenericFluidSystem,
@@ -46,6 +47,7 @@ def component_node(
"label": component_id,
"componentType": model_type,
"modelType": model_type,
"modelVersion": get_component_model_spec(model_type).model_version,
"ports": ports,
"parameters": parameters or {},
},
@@ -101,6 +103,7 @@ def chain_project(*, reverse_edges: bool = False) -> ReactFlowProjectPayload:
for edge in edges
]
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="generic-chain",
nodes=[
component_node(
@@ -147,6 +150,7 @@ def chain_project(*, reverse_edges: bool = False) -> ReactFlowProjectPayload:
def branched_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="generic-branch",
nodes=[
component_node(
@@ -536,7 +540,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
def test_streaming_endpoint_returns_structured_validation_error(self) -> None:
events = [
json.loads(line)
for line in simulation_event_stream(b"<System schemaVersion='2'>")
for line in simulation_event_stream(b"<System schemaVersion='3'>")
]
self.assertEqual(events[-1]["event"], "error")
+55 -104
View File
@@ -129,35 +129,25 @@ CONNECTED_GAS_REFERENCE_SPEC = ComponentModelSpec(
def system_xml(
*,
gi: float | None,
medium_reference_version: str | None = None,
medium_gas_indices: tuple[float, ...] = (),
) -> bytes:
version_attribute = (
f' mediumReferenceVersion="{medium_reference_version}"'
if medium_reference_version is not None
else ""
)
reference_parameter = (
f'<Parameter name="gi" value="{gi}"/>' if gi is not None else ""
)
medium_components = "".join(
f"""
<Component id="medium_{index}" name="medium_{index}"
type="gas_medium_definition_fixture"
componentType="gas_medium_definition_fixture"
x="0" y="0">
<Component id="medium_{index}" type="gas_medium_definition_fixture"
modelVersion="1.0.0">
<Parameter name="gi" value="{gas_index}"/>
</Component>"""
for index, gas_index in enumerate(medium_gas_indices, start=1)
)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<System name="medium-contract" schemaVersion="2" unitSystem="SI"{version_attribute}>
<Simulation tStart="0" tStop="1" step="0.1" maxStep="0.01" method="BDF"/>
<System name="medium-contract" schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Components>
<Component id="consumer_1" name="consumer_1"
type="gas_reference_fixture"
componentType="gas_reference_fixture"
x="0" y="0">
<Component id="consumer_1" type="gas_reference_fixture"
modelVersion="1.0.0">
{reference_parameter}
</Component>{medium_components}
</Components>
@@ -174,40 +164,27 @@ def connected_system_xml(
) -> bytes:
medium_components = "".join(
f"""
<Component id="medium_{index}" name="medium_{index}"
type="gas_medium_definition_fixture"
componentType="gas_medium_definition_fixture"
x="0" y="0">
<Component id="medium_{index}" type="gas_medium_definition_fixture"
modelVersion="1.0.0">
<Parameter name="gi" value="{gas_index}"/>
</Component>"""
for index, gas_index in enumerate(medium_gas_indices, start=1)
)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<System name="connected-medium-contract" schemaVersion="2" unitSystem="SI"
mediumReferenceVersion="1">
<Simulation tStart="0" tStop="1" step="0.1" maxStep="0.01" method="BDF"/>
<System name="connected-medium-contract" schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Components>
<Component id="consumer_1" name="consumer_1"
type="connected_gas_reference_fixture"
componentType="connected_gas_reference_fixture"
x="0" y="0">
<Port name="port_1" kind="physical" domain="pneumatic"
nominalRole="bidirectional" positiveFlowDirection="intoComponent"
side="right"/>
<Component id="consumer_1" type="connected_gas_reference_fixture"
modelVersion="1.0.0">
<Parameter name="gi" value="{first_gi}"/>
</Component>
<Component id="consumer_2" name="consumer_2"
type="connected_gas_reference_fixture"
componentType="connected_gas_reference_fixture"
x="100" y="0">
<Port name="port_1" kind="physical" domain="pneumatic"
nominalRole="bidirectional" positiveFlowDirection="intoComponent"
side="left"/>
<Component id="consumer_2" type="connected_gas_reference_fixture"
modelVersion="1.0.0">
<Parameter name="gi" value="{second_gi}"/>
</Component>{medium_components}
</Components>
<Connections>
<Connection id="gas_connection" kind="physical" domain="pneumatic">
<Connection id="gas_connection">
<Endpoint component="consumer_1" port="port_1"/>
<Endpoint component="consumer_2" port="port_1"/>
</Connection>
@@ -223,14 +200,11 @@ def air_medium_system_xml(*, property_model: float | None) -> bytes:
else ""
)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<System name="air-medium-contract" schemaVersion="2" unitSystem="SI"
mediumReferenceVersion="1">
<Simulation tStart="0" tStop="1" step="0.1" maxStep="0.01" method="BDF"/>
<System name="air-medium-contract" schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Components>
<Component id="air_1" name="air_1"
type="amesim_ideal_air_medium"
componentType="amesim_ideal_air_medium"
x="0" y="0">
<Component id="air_1" type="amesim_ideal_air_medium"
modelVersion="0.2.0">
<Parameter name="gi" value="1"/>
{property_parameter}
</Component>
@@ -294,57 +268,41 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
self.registry_patch.start()
self.addCleanup(self.registry_patch.stop)
def test_legacy_gi_one_without_definition_maps_to_builtin_air(self) -> None:
def test_positive_reference_without_definition_is_not_reinterpreted(self) -> None:
report = validate_system_xml_document(system_xml(gi=1.0))
self.assertTrue(report.valid)
self.assertEqual(
{issue.code for issue in report.issues},
{"AMESIM_GI_LEGACY_AIR_MAPPED"},
)
assert report.document is not None
self.assertEqual(report.document.medium_reference_version, "1")
self.assertEqual(
report.document.as_project_data()["mediumReferenceVersion"],
1,
)
self.assertEqual(
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"],
0.0,
)
def test_legacy_missing_gi_injects_builtin_air(self) -> None:
report = validate_system_xml_document(system_xml(gi=None))
self.assertTrue(report.valid)
self.assertEqual(
{issue.code for issue in report.issues},
{"AMESIM_GI_DEFAULT_INJECTED"},
)
assert report.document is not None
self.assertEqual(
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"],
0.0,
)
def test_versioned_gi_one_is_not_reinterpreted(self) -> None:
report = validate_system_xml_document(
system_xml(gi=1.0, medium_reference_version="1")
)
self.assertFalse(report.valid)
self.assertEqual(
{issue.code for issue in report.issues},
{"AMESIM_GAS_REFERENCE_UNDEFINED"},
)
assert report.document is not None
self.assertEqual(report.document.medium_reference_version, "1")
self.assertEqual(
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"],
report.document.as_model_data()["components"][0]["parameters"]["gi"],
1.0,
)
def test_legacy_document_with_definition_is_not_reinterpreted(self) -> None:
def test_missing_gi_is_not_defaulted(self) -> None:
report = validate_system_xml_document(system_xml(gi=None))
self.assertFalse(report.valid)
self.assertEqual(
{issue.code for issue in report.issues},
{"PARAMETER_REQUIRED_MISSING"},
)
def test_builtin_air_reference_zero_is_valid_canonical_data(self) -> None:
report = validate_system_xml_document(system_xml(gi=0.0))
self.assertTrue(report.valid)
self.assertEqual(report.issues, ())
assert report.document is not None
self.assertEqual(
report.document.as_model_data()["components"][0]["parameters"]["gi"],
0.0,
)
def test_positive_reference_with_definition_is_preserved(self) -> None:
report = validate_system_xml_document(
system_xml(gi=1.0, medium_gas_indices=(1.0,))
)
@@ -353,15 +311,14 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
self.assertEqual(report.issues, ())
assert report.document is not None
self.assertEqual(
report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"],
report.document.as_model_data()["components"][0]["parameters"]["gi"],
1.0,
)
def test_versioned_positive_reference_requires_matching_definition(self) -> None:
def test_positive_reference_accepts_matching_definition(self) -> None:
report = validate_system_xml_document(
system_xml(
gi=2.0,
medium_reference_version="1",
medium_gas_indices=(2.0,),
)
)
@@ -369,22 +326,16 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
self.assertTrue(report.valid)
self.assertEqual(report.issues, ())
def test_missing_property_model_is_defaulted_for_versioned_medium(self) -> None:
def test_missing_property_model_is_not_defaulted(self) -> None:
report = validate_system_xml_document(
air_medium_system_xml(property_model=None)
)
self.assertTrue(report.valid)
self.assertFalse(report.valid)
self.assertEqual(
{issue.code for issue in report.issues},
{"AMESIM_GAS_PROPERTY_MODEL_DEFAULTED"},
{"PARAMETER_REQUIRED_MISSING"},
)
assert report.document is not None
parameters = report.document.as_project_data()["nodes"][0]["data"][
"parameters"
]
self.assertEqual(parameters["gi"], 1.0)
self.assertEqual(parameters["property_model"], 0.0)
def test_explicit_supported_property_model_round_trips_without_warning(self) -> None:
report = validate_system_xml_document(
@@ -411,14 +362,12 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
fractional = validate_system_xml_document(
system_xml(
gi=0.0,
medium_reference_version="1",
medium_gas_indices=(1.5,),
)
)
zero = validate_system_xml_document(
system_xml(
gi=0.0,
medium_reference_version="1",
medium_gas_indices=(0.0,),
)
)
@@ -436,7 +385,6 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
report = validate_system_xml_document(
system_xml(
gi=1.0,
medium_reference_version="1",
medium_gas_indices=(1.0, 1.0),
)
)
@@ -449,10 +397,10 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
def test_gas_reference_index_must_be_integer_in_range(self) -> None:
fractional = validate_system_xml_document(
system_xml(gi=1.5, medium_reference_version="1")
system_xml(gi=1.5)
)
out_of_range = validate_system_xml_document(
system_xml(gi=100.0, medium_reference_version="1")
system_xml(gi=100.0)
)
self.assertIn(
@@ -491,10 +439,13 @@ class MediumReferenceSystemXmlContractTests(unittest.TestCase):
self.assertTrue(report.valid)
self.assertEqual(report.issues, ())
def test_unknown_medium_reference_version_is_rejected_by_xsd(self) -> None:
report = validate_system_xml_document(
system_xml(gi=0.0, medium_reference_version="2")
def test_removed_medium_reference_version_is_rejected_by_xsd(self) -> None:
removed_marker_xml = system_xml(gi=0.0).replace(
b'<System name="medium-contract"',
b'<System name="medium-contract" mediumReferenceVersion="1"',
1,
)
report = validate_system_xml_document(removed_marker_xml)
self.assertFalse(report.valid)
self.assertIsNone(report.document)
@@ -1,104 +0,0 @@
from __future__ import annotations
import unittest
from app.main import (
ReactFlowProjectPayload,
build_reactflow_system_xml,
pydantic_to_jsonable,
)
class ReactFlowProjectMediumReferenceVersionTests(unittest.TestCase):
def test_version_one_is_accepted_and_serialized(self) -> None:
project = ReactFlowProjectPayload(
name="versioned-medium-references",
mediumReferenceVersion=1,
)
self.assertEqual(project.mediumReferenceVersion, 1)
self.assertEqual(
pydantic_to_jsonable(project)["mediumReferenceVersion"],
1,
)
def test_other_medium_reference_versions_are_rejected(self) -> None:
for version in (0, 2, -1, "1"):
with self.subTest(version=version):
with self.assertRaises(ValueError):
ReactFlowProjectPayload(
name="unsupported-medium-references",
mediumReferenceVersion=version,
)
def test_legacy_project_without_version_remains_compatible(self) -> None:
project = ReactFlowProjectPayload(name="legacy-project")
self.assertIsNone(project.mediumReferenceVersion)
self.assertIsNone(project.amesimParameterEncodingVersion)
self.assertNotIn(
"amesimParameterEncodingVersion",
pydantic_to_jsonable(project),
)
def test_xml_export_adds_default_property_model_to_legacy_medium_node(self) -> None:
project = ReactFlowProjectPayload(
name="legacy-medium-node",
mediumReferenceVersion=1,
nodes=[
{
"id": "air_1",
"position": {"x": 10, "y": 20},
"data": {
"label": "Air 1",
"componentType": "amesim_ideal_air_medium",
"modelType": "amesim_ideal_air_medium",
"ports": [],
"parameters": {"gi": 1},
},
}
],
)
xml = build_reactflow_system_xml(project).decode("utf-8")
self.assertIn('<Parameter name="gi" value="1"', xml)
self.assertIn('<Parameter name="property_model" value="0.0"', xml)
class ReactFlowPresentationLayoutVersionTests(unittest.TestCase):
def test_layout_version_is_preserved_but_not_written_to_xml(self) -> None:
project = ReactFlowProjectPayload(
name="versioned-presentation-layout",
presentationLayoutVersion=1,
)
self.assertEqual(project.presentationLayoutVersion, 1)
self.assertEqual(
pydantic_to_jsonable(project)["presentationLayoutVersion"],
1,
)
xml = build_reactflow_system_xml(project).decode("utf-8")
self.assertNotIn("presentationLayoutVersion", xml)
def test_layout_version_rejects_unsupported_values(self) -> None:
for version in (0, 2, -1, "1"):
with self.subTest(version=version):
with self.assertRaises(ValueError):
ReactFlowProjectPayload(
name="unsupported-presentation-layout",
presentationLayoutVersion=version,
)
def test_legacy_layout_version_remains_missing(self) -> None:
project = ReactFlowProjectPayload(name="legacy-presentation-layout")
self.assertIsNone(project.presentationLayoutVersion)
self.assertNotIn(
"presentationLayoutVersion",
pydantic_to_jsonable(project),
)
if __name__ == "__main__":
unittest.main()
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from xml.etree import ElementTree as ET
from fastapi import HTTPException
from app.main import (
ReactFlowParameterScientificNotation,
ReactFlowProjectPayload,
build_reactflow_system_xml,
compile_reactflow_network,
load_reactflow_project,
reactflow_project_storage_data,
save_reactflow_project,
)
def medium_project(**root_overrides: object) -> ReactFlowProjectPayload:
payload: dict[str, object] = {
"projectSchemaVersion": 1,
"name": "current-medium-project",
"nodes": [
{
"id": "air_1",
"position": {"x": 10, "y": 20},
"data": {
"label": "Air 1",
"componentType": "amesim_ideal_air_medium",
"modelType": "amesim_ideal_air_medium",
"modelVersion": "0.2.0",
"ports": [],
"parameters": {"gi": 1},
},
}
],
}
payload.update(root_overrides)
return ReactFlowProjectPayload(**payload)
class ReactFlowProjectSchemaTests(unittest.TestCase):
def test_current_project_version_is_accepted(self) -> None:
project = medium_project()
self.assertEqual(project.projectSchemaVersion, 1)
self.assertEqual(
project.model_dump(mode="json")["projectSchemaVersion"],
1,
)
self.assertEqual(project.nodes[0].data.modelVersion, "0.2.0")
def test_missing_node_model_version_can_be_loaded_for_inspection(self) -> None:
project = medium_project()
project.nodes[0].data.modelVersion = None
reloaded = ReactFlowProjectPayload.model_validate(
project.model_dump(mode="json", exclude_none=True)
)
self.assertIsNone(reloaded.nodes[0].data.modelVersion)
def test_execution_rejects_missing_or_mismatched_node_model_version(self) -> None:
missing = medium_project()
missing.nodes[0].data.modelVersion = None
for execute in (build_reactflow_system_xml, compile_reactflow_network):
with self.subTest(case="missing", execute=execute.__name__):
with self.assertRaisesRegex(
ValueError,
"COMPONENT_MODEL_VERSION_MISSING",
):
execute(missing)
mismatch = medium_project()
mismatch.nodes[0].data.modelVersion = "0.1.0"
for execute in (build_reactflow_system_xml, compile_reactflow_network):
with self.subTest(case="mismatch", execute=execute.__name__):
with self.assertRaisesRegex(
ValueError,
"COMPONENT_MODEL_VERSION_MISMATCH",
):
execute(mismatch)
def test_execution_rejects_divergent_component_and_model_types(self) -> None:
project = medium_project()
project.nodes[0].data.componentType = "amesim_helium_medium"
for execute in (build_reactflow_system_xml, compile_reactflow_network):
with self.subTest(execute=execute.__name__):
with self.assertRaisesRegex(
ValueError,
"COMPONENT_MODEL_TYPE_MISMATCH",
):
execute(project)
def test_unsupported_project_version_is_rejected(self) -> None:
for version in (0, 2, -1, "1"):
with self.subTest(version=version):
with self.assertRaises(ValueError):
medium_project(projectSchemaVersion=version)
def test_missing_project_version_is_rejected(self) -> None:
with self.assertRaises(ValueError):
ReactFlowProjectPayload(name="missing-project-version")
def test_removed_compatibility_markers_are_rejected(self) -> None:
for field in (
"mediumReferenceVersion",
"amesimParameterEncodingVersion",
"presentationLayoutVersion",
):
with self.subTest(field=field):
with self.assertRaises(ValueError):
medium_project(**{field: 1})
def test_string_port_definition_is_rejected(self) -> None:
with self.assertRaises(ValueError):
ReactFlowProjectPayload(
projectSchemaVersion=1,
name="invalid-string-port",
nodes=[
{
"id": "tank_1",
"data": {
"componentType": "tank",
"modelType": "tank",
"ports": ["port_a"],
},
}
],
)
def test_xml_export_fills_registered_medium_defaults(self) -> None:
root = ET.fromstring(build_reactflow_system_xml(medium_project()))
parameters = {
parameter.get("name"): float(str(parameter.get("value")))
for parameter in root.findall("./Components/Component/Parameter")
}
self.assertEqual(parameters["gi"], 1.0)
self.assertEqual(parameters["property_model"], 0.0)
self.assertIsNone(root.get("projectSchemaVersion"))
def test_storage_preserves_parameter_display_metadata(self) -> None:
project = medium_project()
project.nodes[0].data.parameterUnits = {
"gi": "",
"property_model": "",
}
project.nodes[0].data.parameterScientificNotation = {
"gi": ReactFlowParameterScientificNotation(text="1e0", unit="")
}
data = reactflow_project_storage_data(project)
stored_node = data["nodes"][0]["data"]
self.assertEqual(stored_node["modelVersion"], "0.2.0")
self.assertEqual(stored_node["parameterUnits"], project.nodes[0].data.parameterUnits)
self.assertEqual(
stored_node["parameterScientificNotation"],
{"gi": {"text": "1e0", "unit": ""}},
)
def test_project_storage_round_trip_preserves_current_contract(self) -> None:
project = medium_project()
project.nodes[0].data.parameterUnits = {"gi": ""}
project.nodes[0].data.parameterScientificNotation = {
"gi": ReactFlowParameterScientificNotation(text="1e0", unit="")
}
project = ReactFlowProjectPayload.model_validate(
{
**project.model_dump(mode="json"),
"edges": [
{
"id": "contact-edge",
"source": "air_1",
"target": "air_1",
"sourceHandle": "definition",
"targetHandle": "definition",
"data": {
"isContactEdge": True,
"futureDisplayMetadata": "preserved",
},
}
],
}
)
with tempfile.TemporaryDirectory() as directory:
storage = Path(directory)
with patch("app.main.PROJECT_STORAGE_DIR", storage):
result = save_reactflow_project("medium-project", project)
loaded = load_reactflow_project("medium-project")
self.assertEqual(result["id"], "medium-project")
self.assertEqual(loaded, reactflow_project_storage_data(project))
self.assertTrue(loaded["edges"][0]["data"]["isContactEdge"])
self.assertEqual(
loaded["edges"][0]["data"]["futureDisplayMetadata"],
"preserved",
)
def test_project_load_rejects_corrupt_or_unsupported_data(self) -> None:
with tempfile.TemporaryDirectory() as directory:
storage = Path(directory)
invalid_cases = {
"corrupt": "{not-json",
"future": json.dumps({"projectSchemaVersion": 2}),
}
with patch("app.main.PROJECT_STORAGE_DIR", storage):
for project_id, text in invalid_cases.items():
with self.subTest(project_id=project_id):
(storage / f"{project_id}.json").write_text(
text,
encoding="utf-8",
)
with self.assertRaises(HTTPException) as context:
load_reactflow_project(project_id)
self.assertEqual(context.exception.status_code, 422)
if __name__ == "__main__":
unittest.main()
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import unittest
from app.simulation.components.amesim.mechanical.translational import (
AmesimF000,
AmesimForc,
AmesimMecmas21,
)
from app.simulation.components.amesim.signals.sources import AmesimStep0
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import (
SimulationSampleTimeError,
simulation_preparation_issues,
simulation_sample_times,
)
from app.simulation.systems.network import SimulationNetwork
from app.system_xml import validate_system_xml_document
def _system_xml(
*,
t_start: str,
t_stop: str,
sample_step: str,
) -> str:
return f"""<System schemaVersion="3" unitSystem="SI">
<Simulation
tStart="{t_start}"
tStop="{t_stop}"
sampleStep="{sample_step}"
maxStep="0.01"
method="BDF"
/>
<Components>
<Component id="cylinder_1" type="cylinder" modelVersion="1.0.0">
<Parameter name="volume" value="0.01"/>
<Parameter name="p0" value="35000000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections/>
</System>"""
def _fanout_mechanical_network(
*,
second_island_has_storage: bool,
) -> SimulationNetwork:
medium = IdealGasMedium()
network = SimulationNetwork("signal-fanout-physical-islands")
command = AmesimStep0(
"command",
medium,
initial=0.0,
final=1.0,
time=0.0,
)
force_a = AmesimForc("force_a")
mass_a = AmesimMecmas21("mass_a", medium)
zero_a = AmesimF000("zero_a")
force_b = AmesimForc("force_b")
zero_b = AmesimF000("zero_b")
components = [command, force_a, mass_a, zero_a, force_b, zero_b]
mass_b = None
if second_island_has_storage:
mass_b = AmesimMecmas21("mass_b", medium)
components.append(mass_b)
for component in components:
network.add_component(component)
network.connect("command", "out", "force_a", "res")
network.connect("command", "out", "force_b", "res")
network.connect("force_a", "port_2", "mass_a", "port_1")
network.connect("mass_a", "port_2", "zero_a", "port_1")
if mass_b is None:
network.connect("force_b", "port_2", "zero_b", "port_1")
else:
network.connect("force_b", "port_2", "mass_b", "port_1")
network.connect("mass_b", "port_2", "zero_b", "port_1")
return network
class SimulationSampleTimeSafetyTests(unittest.TestCase):
def test_grid_contains_both_endpoints_and_is_strictly_increasing(self) -> None:
times = simulation_sample_times(
SolveIVPConfig(t_start=1.0, t_stop=2.0),
0.3,
)
self.assertEqual(times[0], 1.0)
self.assertEqual(times[-1], 2.0)
self.assertGreaterEqual(len(times), 2)
self.assertTrue(
all(first < second for first, second in zip(times, times[1:]))
)
def test_step_larger_than_duration_still_returns_two_endpoints(self) -> None:
self.assertEqual(
simulation_sample_times(
SolveIVPConfig(t_start=1.0, t_stop=1.25),
10.0,
),
[1.0, 1.25],
)
def test_exact_point_limit_is_allowed(self) -> None:
times = simulation_sample_times(
SolveIVPConfig(t_start=0.0, t_stop=1.0),
0.0001,
)
self.assertEqual(len(times), 10001)
self.assertEqual(times[0], 0.0)
self.assertEqual(times[-1], 1.0)
def test_tiny_step_is_rejected_before_an_oversized_grid_is_allocated(self) -> None:
with self.assertRaises(SimulationSampleTimeError) as caught:
simulation_sample_times(
SolveIVPConfig(t_start=0.0, t_stop=1.0),
1.0e-300,
)
self.assertEqual(caught.exception.code, "SIMULATION_SAMPLE_COUNT_EXCEEDED")
def test_non_finite_derived_duration_has_a_stable_error(self) -> None:
with self.assertRaises(SimulationSampleTimeError) as caught:
simulation_sample_times(
SolveIVPConfig(t_start=-1.0e308, t_stop=1.0e308),
1.0e308,
)
self.assertEqual(caught.exception.code, "SIMULATION_TIME_SPAN_NOT_FINITE")
def test_step_that_cannot_advance_absolute_time_is_rejected(self) -> None:
with self.assertRaises(SimulationSampleTimeError) as caught:
simulation_sample_times(
SolveIVPConfig(t_start=1.0e16, t_stop=1.0e16 + 4.0),
1.0,
)
self.assertEqual(
caught.exception.code,
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
)
def test_system_xml_reports_sample_grid_failures_during_semantic_validation(
self,
) -> None:
cases = (
(
_system_xml(t_start="0", t_stop="1", sample_step="1e-300"),
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
),
(
_system_xml(
t_start="-1e308",
t_stop="1e308",
sample_step="1e308",
),
"SIMULATION_TIME_SPAN_NOT_FINITE",
),
(
_system_xml(
t_start="10000000000000000",
t_stop="10000000000000004",
sample_step="1",
),
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
),
)
for xml, expected_code in cases:
with self.subTest(expected_code=expected_code):
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertIn(
expected_code,
{issue.code for issue in report.issues},
)
class PhysicalIslandSafetyTests(unittest.TestCase):
def test_signal_fanout_does_not_hide_an_unanchored_physical_island(self) -> None:
issues = simulation_preparation_issues(
_fanout_mechanical_network(second_island_has_storage=False)
)
island_issues = [
issue
for issue in issues
if issue.code == "ALGEBRAIC_ISLAND_HAS_NO_STORAGE"
]
self.assertEqual(len(island_issues), 1)
self.assertIn("force_b", island_issues[0].message)
self.assertIn("zero_b", island_issues[0].message)
self.assertNotIn("command", island_issues[0].message)
self.assertNotIn("mass_a", island_issues[0].message)
def test_signal_fanout_between_two_anchored_physical_islands_is_allowed(
self,
) -> None:
issues = simulation_preparation_issues(
_fanout_mechanical_network(second_island_has_storage=True)
)
self.assertNotIn(
"ALGEBRAIC_ISLAND_HAS_NO_STORAGE",
{issue.code for issue in issues},
)
if __name__ == "__main__":
unittest.main()
+70 -31
View File
@@ -2,16 +2,16 @@ from __future__ import annotations
import asyncio
import unittest
from xml.etree import ElementTree as ET
from fastapi import HTTPException, Request
from app.main import (
ReactFlowEdgePayload,
ReactFlowNodePayload,
ReactFlowProjectPayload,
build_reactflow_system_xml,
compile_system_xml_model,
compile_reactflow_network,
compile_system_xml_network,
parse_system_xml,
)
from app.system_xml import validate_system_xml_document
@@ -32,6 +32,38 @@ def issue_codes(xml: bytes | str) -> set[str]:
}
def with_parameter_value(
xml: bytes,
component_id: str,
parameter_name: str,
value: float,
) -> bytes:
root = ET.fromstring(xml)
parameter = root.find(
"./Components/Component[@id='{}']/Parameter[@name='{}']".format(
component_id,
parameter_name,
)
)
assert parameter is not None
parameter.set("value", str(value))
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def without_parameter(
xml: bytes,
component_id: str,
parameter_name: str,
) -> bytes:
root = ET.fromstring(xml)
component = root.find(f"./Components/Component[@id='{component_id}']")
assert component is not None
parameter = component.find(f"./Parameter[@name='{parameter_name}']")
assert parameter is not None
component.remove(parameter)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def xml_request(body: bytes) -> Request:
delivered = False
@@ -54,17 +86,17 @@ def xml_request(body: bytes) -> Request:
class SystemXmlParserTests(unittest.TestCase):
def test_valid_v2_xml_round_trips_and_compiles(self) -> None:
def test_valid_v3_xml_parses_and_compiles_directly(self) -> None:
report = validate_system_xml_document(valid_xml())
self.assertTrue(report.valid)
self.assertEqual(report.issues, ())
assert report.document is not None
project = ReactFlowProjectPayload(**report.document.as_project_data())
network = compile_reactflow_network(project)
network = compile_system_xml_network(report.document)
self.assertEqual(project.name, "protocol-test")
self.assertEqual(project.simulation.method, "BDF")
self.assertEqual(report.document.name, "protocol-test")
self.assertEqual(report.document.simulation.method, "BDF")
self.assertEqual(report.document.simulation.sample_step, 0.2)
self.assertEqual(set(network.components), {"cylinder_1", "tank_1"})
self.assertEqual(len(network.connections), 1)
self.assertEqual(network.connections[0].id, "edge-1")
@@ -76,8 +108,24 @@ class SystemXmlParserTests(unittest.TestCase):
self.assertEqual(response["name"], "protocol-test")
self.assertEqual(len(response["components"]), 2)
self.assertEqual(len(response["connections"]), 1)
self.assertEqual(response["simulation"]["sample_step"], 0.2)
self.assertTrue(response["validation"]["valid"])
def test_parse_api_returns_execution_model_not_editor_project(self) -> None:
response = asyncio.run(parse_system_xml(xml_request(valid_xml())))
self.assertTrue(response["success"])
self.assertNotIn("project", response)
model = response["model"]
self.assertEqual(model["schemaVersion"], "3")
self.assertEqual(model["unitSystem"], "SI")
self.assertEqual(model["name"], "protocol-test")
self.assertEqual(model["simulation"]["sample_step"], 0.2)
self.assertEqual(model["components"][0]["model_type"], "cylinder")
self.assertEqual(model["components"][0]["model_version"], "1.0.0")
self.assertNotIn("nodes", model)
self.assertNotIn("edges", model)
def test_parse_api_returns_structured_422_diagnostics(self) -> None:
with self.assertRaises(HTTPException) as caught:
asyncio.run(parse_system_xml(xml_request(b"<broken>")))
@@ -111,7 +159,7 @@ class SystemXmlParserTests(unittest.TestCase):
self.assertEqual(report.issues[0].code, "XML_DTD_NOT_ALLOWED")
def test_wrong_schema_version_reports_xsd_error(self) -> None:
xml = valid_xml().replace(b'schemaVersion="2"', b'schemaVersion="1"')
xml = valid_xml().replace(b'schemaVersion="3"', b'schemaVersion="2"')
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
@@ -121,11 +169,7 @@ class SystemXmlParserTests(unittest.TestCase):
self.assertIn("XSD_VALIDATION_ERROR", issue_codes(xml))
def test_parameter_range_is_checked_semantically(self) -> None:
xml = valid_xml().replace(
b'<Parameter name="volume" value="0.01"',
b'<Parameter name="volume" value="-1"',
1,
)
xml = with_parameter_value(valid_xml(), "cylinder_1", "volume", -1)
report = validate_system_xml_document(xml)
@@ -138,25 +182,21 @@ class SystemXmlParserTests(unittest.TestCase):
self.assertIn("cylinder_1.volume", issue.message)
def test_zero_initial_pressure_is_rejected_before_model_creation(self) -> None:
xml = valid_xml().replace(
b'<Parameter name="p0" value="35000000"',
b'<Parameter name="p0" value="0"',
1,
)
xml = with_parameter_value(valid_xml(), "cylinder_1", "p0", 0)
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertIn("PARAMETER_VALUE_INVALID", issue_codes(xml))
def test_registered_port_contract_is_checked_semantically(self) -> None:
def test_endpoint_port_is_checked_against_registered_contract(self) -> None:
xml = valid_xml().replace(
b'nominalRole="outlet"',
b'nominalRole="bidirectional"',
b'component="cylinder_1" port="port_b"',
b'component="cylinder_1" port="missing_port"',
1,
)
self.assertIn("PORT_NOMINAL_ROLE_MISMATCH", issue_codes(xml))
self.assertIn("ENDPOINT_PORT_UNKNOWN", issue_codes(xml))
def test_unknown_connection_endpoint_is_reported(self) -> None:
xml = valid_xml().replace(
@@ -178,6 +218,7 @@ class SystemXmlParserTests(unittest.TestCase):
"label": "receiver-tank-2",
"componentType": "tank",
"modelType": "tank",
"modelVersion": "1.0.0",
"ports": [physical_port("port_a", "inlet", "left")],
"parameters": {"volume": 0.1, "p0": 100000, "T0": 300},
},
@@ -193,9 +234,11 @@ class SystemXmlParserTests(unittest.TestCase):
})
)
xml = build_reactflow_system_xml(project)
self.assertIn("PHYSICAL_PORT_ALREADY_CONNECTED", issue_codes(xml))
with self.assertRaisesRegex(
ValueError,
"PHYSICAL_PORT_ALREADY_CONNECTED",
):
build_reactflow_system_xml(project)
def test_unconnected_registered_port_is_a_warning(self) -> None:
project = physical_connection_project()
@@ -211,11 +254,7 @@ class SystemXmlParserTests(unittest.TestCase):
)
def test_missing_required_parameter_is_reported(self) -> None:
xml = valid_xml().replace(
b' <Parameter name="T0" value="300" />\n',
b"",
1,
)
xml = without_parameter(valid_xml(), "cylinder_1", "T0")
self.assertIn("PARAMETER_REQUIRED_MISSING", issue_codes(xml))
@@ -225,7 +264,7 @@ class SystemXmlParserTests(unittest.TestCase):
self.assertIn("SIMULATION_METHOD_UNSUPPORTED", issue_codes(xml))
def test_non_positive_simulation_steps_are_rejected_by_schema(self) -> None:
xml = valid_xml().replace(b'step="0.2"', b'step="0"')
xml = valid_xml().replace(b'sampleStep="0.2"', b'sampleStep="0"')
xml = xml.replace(b'maxStep="0.01"', b'maxStep="-1"')
report = validate_system_xml_document(xml)
+58 -76
View File
@@ -32,6 +32,7 @@ def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProje
"label": "source-cylinder",
"componentType": "cylinder",
"modelType": "cylinder",
"modelVersion": "1.0.0",
"ports": [physical_port("port_b", "outlet", "right")],
"parameters": {"volume": 0.01, "p0": 35000000, "T0": 300},
},
@@ -44,6 +45,7 @@ def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProje
"label": "receiver-tank",
"componentType": "tank",
"modelType": "tank",
"modelVersion": "1.0.0",
"ports": [physical_port("port_a", "inlet", "left")],
"parameters": {"volume": 0.1, "p0": 100000, "T0": 300},
},
@@ -66,6 +68,7 @@ def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProje
}
)
return ReactFlowProjectPayload(
projectSchemaVersion=1,
name="protocol-test",
nodes=[source, target],
edges=[edge],
@@ -80,15 +83,18 @@ def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProje
class SystemXmlProtocolTests(unittest.TestCase):
def test_v2_xml_contains_port_metadata_and_neutral_physical_endpoints(self) -> None:
def test_v3_xml_contains_only_execution_model_fields(self) -> None:
root = ET.fromstring(build_reactflow_system_xml(physical_connection_project()))
self.assertEqual(root.tag, "System")
self.assertEqual(root.attrib["name"], "protocol-test")
self.assertEqual(root.attrib["schemaVersion"], "2")
self.assertEqual(root.attrib["unitSystem"], "SI")
self.assertEqual(root.attrib["mediumReferenceVersion"], "1")
self.assertEqual(root.attrib["amesimParameterEncodingVersion"], "1")
self.assertEqual(
root.attrib,
{
"name": "protocol-test",
"schemaVersion": "3",
"unitSystem": "SI",
},
)
self.assertEqual(
[child.tag for child in root],
["Simulation", "Components", "Connections"],
@@ -101,37 +107,34 @@ class SystemXmlProtocolTests(unittest.TestCase):
{
"tStart": "1.0",
"tStop": "5.0",
"step": "0.2",
"sampleStep": "0.2",
"maxStep": "0.01",
"method": "BDF",
},
)
port = root.find("./Components/Component[@id='cylinder_1']/Port")
assert port is not None
component = root.find("./Components/Component[@id='cylinder_1']")
assert component is not None
self.assertEqual(component.attrib["rotation"], "0")
self.assertEqual(component.attrib["mirrored"], "false")
self.assertEqual(
port.attrib,
component.attrib,
{
"name": "port_b",
"kind": "physical",
"domain": "pneumatic",
"nominalRole": "outlet",
"side": "right",
"positiveFlowDirection": "intoComponent",
"id": "cylinder_1",
"type": "cylinder",
"modelVersion": "1.0.0",
},
)
self.assertEqual(component.findall("Port"), [])
self.assertEqual(
{
parameter.attrib["name"]: float(parameter.attrib["value"])
for parameter in component.findall("Parameter")
},
{"volume": 0.01, "p0": 35_000_000.0, "T0": 300.0},
)
connection = root.find("./Connections/Connection")
assert connection is not None
self.assertEqual(
connection.attrib,
{"id": "edge-1", "kind": "physical", "domain": "pneumatic"},
)
self.assertNotIn("source", connection.attrib)
self.assertEqual(connection.attrib, {"id": "edge-1"})
self.assertEqual(
{
(endpoint.attrib["component"], endpoint.attrib["port"])
@@ -139,9 +142,7 @@ class SystemXmlProtocolTests(unittest.TestCase):
},
{("cylinder_1", "port_b"), ("tank_1", "port_a")},
)
self.assertTrue(
all("role" not in endpoint.attrib for endpoint in connection.findall("Endpoint"))
)
self.assertTrue(all(len(endpoint.attrib) == 2 for endpoint in connection))
def test_physical_connection_meaning_does_not_depend_on_drag_direction(self) -> None:
forward = ET.fromstring(
@@ -159,63 +160,24 @@ class SystemXmlProtocolTests(unittest.TestCase):
self.assertEqual(endpoint_set(forward), endpoint_set(reverse))
def test_component_orientation_is_exported_as_layout_metadata(self) -> None:
def test_editor_layout_and_display_fields_are_not_exported(self) -> None:
project = physical_connection_project()
project.nodes[0].position.x = 987
project.nodes[0].position.y = 654
project.nodes[0].data.label = "display-only-name"
project.nodes[0].data.rotation = 90
project.nodes[0].data.mirrored = True
root = ET.fromstring(build_reactflow_system_xml(project))
component = root.find("./Components/Component[@id='cylinder_1']")
assert component is not None
self.assertEqual(component.attrib["rotation"], "90")
self.assertEqual(component.attrib["mirrored"], "true")
def test_legacy_string_port_is_migrated_when_exporting_v2(self) -> None:
project = ReactFlowProjectPayload(
name="legacy-port",
nodes=[
{
"id": "tank_1",
"data": {
"label": "tank_1",
"componentType": "tank",
"modelType": "tank",
"ports": ["port_a"],
},
}
],
)
root = ET.fromstring(build_reactflow_system_xml(project))
port = root.find("./Components/Component/Port")
assert port is not None
self.assertEqual(port.attrib["kind"], "physical")
self.assertEqual(port.attrib["domain"], "pneumatic")
self.assertEqual(port.attrib["nominalRole"], "inlet")
self.assertEqual(port.attrib["positiveFlowDirection"], "intoComponent")
self.assertEqual(port.attrib["side"], "left")
def test_legacy_tee_port_names_do_not_restore_direction_constraints(self) -> None:
project = ReactFlowProjectPayload(
name="legacy-tee",
nodes=[
{
"id": "tee_1",
"data": {
"label": "tee_1",
"componentType": "tee",
"modelType": "tee",
"ports": ["port_in", "port_out1", "port_out2"],
},
}
],
)
root = ET.fromstring(build_reactflow_system_xml(project))
ports = root.findall("./Components/Component/Port")
self.assertEqual(
[port.attrib["nominalRole"] for port in ports],
["bidirectional", "bidirectional", "bidirectional"],
component.attrib,
{
"id": "cylinder_1",
"type": "cylinder",
"modelVersion": "1.0.0",
},
)
def test_incompatible_physical_domains_are_rejected(self) -> None:
@@ -224,9 +186,29 @@ class SystemXmlProtocolTests(unittest.TestCase):
assert not isinstance(tank_port, str)
tank_port.domain = "hydraulic"
with self.assertRaisesRegex(ValueError, "incompatible domains"):
with self.assertRaisesRegex(ValueError, "incompatible port type"):
build_reactflow_system_xml(project)
def test_exporter_never_returns_xml_that_fails_the_v3_contract(self) -> None:
invalid_time = physical_connection_project()
invalid_time.simulation.t_stop = invalid_time.simulation.t_start
with self.assertRaisesRegex(ValueError, "SIMULATION_TIME_RANGE_INVALID"):
build_reactflow_system_xml(invalid_time)
duplicate_component = physical_connection_project()
duplicate_component.nodes[1].id = duplicate_component.nodes[0].id
duplicate_component.edges = []
with self.assertRaisesRegex(ValueError, "COMPONENT_ID_DUPLICATE"):
build_reactflow_system_xml(duplicate_component)
def test_blank_project_name_is_omitted(self) -> None:
project = physical_connection_project()
project.name = " "
root = ET.fromstring(build_reactflow_system_xml(project))
self.assertNotIn("name", root.attrib)
if __name__ == "__main__":
unittest.main()
+200
View File
@@ -0,0 +1,200 @@
from __future__ import annotations
from dataclasses import replace
import unittest
from app.main import compile_system_xml_network
from app.system_xml import validate_system_xml_document
def physical_system_xml() -> str:
return """<?xml version="1.0" encoding="UTF-8"?>
<System schemaVersion="3" unitSystem="SI">
<Simulation tStart="1" tStop="5" sampleStep="0.2" maxStep="0.01" method="BDF"/>
<Components>
<Component id="cylinder_1" type="cylinder" modelVersion="1.0.0">
<Parameter name="volume" value="0.01"/>
<Parameter name="p0" value="35000000"/>
<Parameter name="T0" value="300"/>
</Component>
<Component id="tank_1" type="tank" modelVersion="1.0.0">
<Parameter name="volume" value="0.1"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections>
<Connection>
<Endpoint component="cylinder_1" port="port_b"/>
<Endpoint component="tank_1" port="port_a"/>
</Connection>
</Connections>
</System>
"""
def issue_codes(xml: str) -> set[str]:
return {issue.code for issue in validate_system_xml_document(xml).issues}
class SystemXmlV3Tests(unittest.TestCase):
def test_minimal_execution_model_is_valid(self) -> None:
report = validate_system_xml_document(physical_system_xml())
self.assertTrue(report.valid)
self.assertEqual(report.issues, ())
assert report.document is not None
self.assertEqual(report.document.connections[0].id, "connection_1")
model = report.document.as_model_data()
self.assertEqual(model["name"], "untitled")
self.assertEqual(model["simulation"]["sample_step"], 0.2)
self.assertEqual(model["components"][0]["model_version"], "1.0.0")
def test_editor_and_port_snapshot_fields_are_rejected(self) -> None:
xml = physical_system_xml().replace(
'modelVersion="1.0.0">',
'modelVersion="1.0.0" x="10">\n <Port name="port_b"/>',
1,
)
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertIsNone(report.document)
self.assertTrue(all(issue.layer == "schema" for issue in report.issues))
def test_model_version_is_checked_against_registry(self) -> None:
xml = physical_system_xml().replace(
'type="cylinder" modelVersion="1.0.0"',
'type="cylinder" modelVersion="9.9.9"',
)
self.assertIn("COMPONENT_MODEL_VERSION_MISMATCH", issue_codes(xml))
def test_direct_compiler_defends_the_model_version_contract(self) -> None:
report = validate_system_xml_document(physical_system_xml())
assert report.document is not None
first = replace(
report.document.components[0],
model_version="9.9.9",
)
document = replace(
report.document,
components=(first, *report.document.components[1:]),
)
with self.assertRaisesRegex(ValueError, "expected '1.0.0'"):
compile_system_xml_network(document)
def test_component_cannot_connect_two_of_its_own_ports(self) -> None:
xml = """<System schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Components>
<Component id="pipe_1" type="pipe" modelVersion="1.0.0">
<Parameter name="length" value="1"/>
<Parameter name="diameter" value="0.02"/>
<Parameter name="lambda_darcy" value="0.02"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections>
<Connection>
<Endpoint component="pipe_1" port="port_a"/>
<Endpoint component="pipe_1" port="port_b"/>
</Connection>
</Connections>
</System>"""
self.assertIn("CONNECTION_SELF_REFERENCE", issue_codes(xml))
def test_signal_direction_is_derived_from_registered_ports(self) -> None:
xml = """<System schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Components>
<Component id="step_1" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="1"/>
<Parameter name="time" value="0"/>
</Component>
<Component id="step_2" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="1"/>
<Parameter name="time" value="0"/>
</Component>
</Components>
<Connections>
<Connection id="signal_1">
<Endpoint component="step_1" port="out"/>
<Endpoint component="step_2" port="out"/>
</Connection>
</Connections>
</System>"""
self.assertIn("SIGNAL_PORT_ROLES_INVALID", issue_codes(xml))
def test_signal_input_rejects_multiple_drivers_but_output_may_fan_out(self) -> None:
components = """
<Component id="step_1" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="1"/>
<Parameter name="time" value="0"/>
</Component>
<Component id="step_2" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="1"/>
<Parameter name="final" value="0"/>
<Parameter name="time" value="0"/>
</Component>
<Component id="force_1" type="amesim_forc" modelVersion="0.2.0">
<Parameter name="direction" value="1"/>
</Component>
<Component id="force_2" type="amesim_forc" modelVersion="0.2.0">
<Parameter name="direction" value="1"/>
</Component>"""
def signal_xml(connections: str) -> str:
return f"""<System schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="1" sampleStep="0.1" maxStep="0.01" method="BDF"/>
<Components>{components}
</Components>
<Connections>{connections}
</Connections>
</System>"""
multiple_drivers = signal_xml("""
<Connection id="signal_1">
<Endpoint component="step_1" port="out"/>
<Endpoint component="force_1" port="res"/>
</Connection>
<Connection id="signal_2">
<Endpoint component="step_2" port="out"/>
<Endpoint component="force_1" port="res"/>
</Connection>""")
fan_out = signal_xml("""
<Connection id="signal_1">
<Endpoint component="step_1" port="out"/>
<Endpoint component="force_1" port="res"/>
</Connection>
<Connection id="signal_2">
<Endpoint component="step_1" port="out"/>
<Endpoint component="force_2" port="res"/>
</Connection>""")
self.assertIn(
"SIGNAL_INPUT_MULTIPLE_DRIVERS",
issue_codes(multiple_drivers),
)
self.assertTrue(validate_system_xml_document(fan_out).valid)
def test_v2_step_name_is_not_accepted(self) -> None:
xml = physical_system_xml().replace('sampleStep="0.2"', 'step="0.2"')
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertIsNone(report.document)
self.assertIn("XSD_VALIDATION_ERROR", issue_codes(xml))
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -38,6 +38,7 @@ class TestMqlExampleRunnerTests(unittest.TestCase):
def test_reactflow_helper_runs_fixed_topology_test_mql(self) -> None:
payload = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="test-mql-fixed",
simulation={
"t_start": 0.0,