完善建模交互、组件图标与系统协议
This commit is contained in:
1 parent
456c29b3b6
commit
6572defaa4
66 files changed
+10067
-4163
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user