完善 XML 通用仿真与结果查看

This commit is contained in:
ljz committed 2026-07-21 13:42:55 +08:00
1 parent 104d41d294
commit f1256a121d
36 files changed
+6666 -196

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
import unittest
from app.main import compile_reactflow_network
from PythonModels.components.cylinder import Cylinder
from PythonModels.components.tank import Tank
from PythonModels.components.tee import Tee
from PythonModels.core.base import Component
from PythonModels.core.medium import IdealGasMedium
from PythonModels.core.network import SimulationNetwork
from PythonModels.core.ports import PortDefinition, PortState, PortVariableDefinition
from PythonModels.systems.testmodel import TestModelSystem
from tests.test_system_xml_protocol import physical_connection_project
class ComponentInterfaceTests(unittest.TestCase):
def test_pneumatic_port_exposes_equation_roles_and_signed_flow(self) -> None:
port = PortState.pneumatic("port_a", nominal_role="bidirectional")
assert port.definition is not None
self.assertEqual(
[
(variable.name, variable.role, variable.connection_rule)
for variable in port.definition.variables
],
[
("p", "effort", "equal"),
("m_flow", "flow", "sumToZero"),
("h_outflow", "stream", "streamMix"),
],
)
port.m_flow = 10.0
self.assertEqual(port.actual_direction(), "in")
self.assertEqual(port.inflow_rate, 10.0)
self.assertEqual(port.outflow_rate, 0.0)
port.m_flow = -4.0
self.assertEqual(port.actual_direction(), "out")
self.assertEqual(port.inflow_rate, 0.0)
self.assertEqual(port.outflow_rate, 4.0)
def test_physical_connection_is_neutral_and_canonical(self) -> None:
forward = compile_reactflow_network(physical_connection_project())
reverse = compile_reactflow_network(
physical_connection_project(reverse_edge=True)
)
self.assertEqual(
forward.connections[0].undirected_key,
reverse.connections[0].undirected_key,
)
self.assertEqual(
forward.connections[0].as_interface_dict(),
reverse.connections[0].as_interface_dict(),
)
self.assertNotIn("source", forward.connections[0].as_interface_dict())
def test_compiler_instantiates_parameters_and_actual_edges(self) -> None:
project = physical_connection_project()
network = compile_reactflow_network(project)
self.assertEqual(set(network.components), {"cylinder_1", "tank_1"})
self.assertEqual(network.components["cylinder_1"].V, 0.01)
self.assertEqual(network.components["tank_1"].V, 0.1)
self.assertEqual(len(network.connections), 1)
self.assertEqual(network.connections[0].id, "edge-1")
self.assertEqual(network.connections[0].kind, "physical")
self.assertEqual(network.connections[0].domain, "pneumatic")
equation_system = network.as_interface_dict()["pressureFlowSystem"]
self.assertEqual(equation_system["unknownCount"], 4)
self.assertEqual(equation_system["equationCount"], 4)
self.assertTrue(equation_system["isSquare"])
def test_physical_port_requires_a_tee_for_branching(self) -> None:
medium = IdealGasMedium()
network = SimulationNetwork("branch-check")
network.add_component(Cylinder("cylinder_1", medium))
network.add_component(Tank("tank_1", medium))
network.add_component(Tank("tank_2", medium))
network.connect("cylinder_1", "port_b", "tank_1", "port_a")
with self.assertRaisesRegex(ValueError, "Use a junction component"):
network.connect("cylinder_1", "port_b", "tank_2", "port_a")
def test_connection_requires_the_same_variable_contract(self) -> None:
medium = IdealGasMedium()
network = SimulationNetwork("contract-check")
network.add_component(Cylinder("cylinder_1", medium))
incompatible = Component("incompatible_1")
incompatible.register_port(
PortState(
definition=PortDefinition(
name="port_a",
kind="physical",
domain="pneumatic",
nominal_role="bidirectional",
positive_flow_direction="intoComponent",
variables=(PortVariableDefinition("p", "effort", "equal"),),
)
)
)
network.add_component(incompatible)
with self.assertRaisesRegex(ValueError, "variable contracts do not match"):
network.connect("cylinder_1", "port_b", "incompatible_1", "port_a")
def test_connection_equations_are_executable_and_endpoint_neutral(self) -> None:
medium = IdealGasMedium()
network = SimulationNetwork("residual-check")
cylinder = Cylinder("cylinder_1", medium)
tank = Tank("tank_1", medium)
network.add_component(cylinder)
network.add_component(tank)
network.connect("tank_1", "port_a", "cylinder_1", "port_b")
cylinder.port_b.p = 2.0e5
cylinder.port_b.m_flow = -3.0
tank.port_a.p = 2.0e5
tank.port_a.m_flow = 3.0
residuals = {
residual.variables[0].rsplit(".", 1)[-1]: residual
for residual in network.connection_equation_residuals()
}
self.assertEqual(set(residuals), {"p", "m_flow"})
self.assertEqual(residuals["p"].relation, "equal")
self.assertEqual(residuals["m_flow"].relation, "sumToZero")
self.assertEqual(residuals["p"].value, 0.0)
self.assertEqual(residuals["m_flow"].value, 0.0)
def test_tee_ports_are_physical_and_bidirectional(self) -> None:
tee = Tee("tee_1")
self.assertEqual(
{definition.name for definition in tee.port_definitions},
{"port_in", "port_out1", "port_out2"},
)
self.assertTrue(
all(
definition.kind == "physical"
and definition.nominal_role == "bidirectional"
for definition in tee.port_definitions
)
)
def test_existing_testmodel_topology_still_builds(self) -> None:
system = TestModelSystem()
self.assertEqual(len(system.network.components), 8)
self.assertEqual(len(system.network.connections), 8)
self.assertTrue(
all(connection.kind == "physical" for connection in system.network.connections)
)
self.assertEqual(len(system.initial_state_vector()), 8)
def test_testmodel_snapshot_satisfies_acausal_connection_equations(self) -> None:
system = TestModelSystem()
state = system.consistent_initial_state_vector()
system.snapshot(state)
residuals = system.network.pressure_flow_equation_residuals()
self.assertEqual(len(residuals), 4 * len(system.network.connections))
self.assertEqual(len(system.network.pressure_flow_unknowns()), len(residuals))
for residual in residuals:
tolerance = 1e-6
self.assertLessEqual(
abs(residual.value),
tolerance,
msg=f"{residual.id} residual is {residual.value}",
)
upstream_tee = system.mytee
self.assertAlmostEqual(
upstream_tee.port_in.m_flow
+ upstream_tee.port_out1.m_flow
+ upstream_tee.port_out2.m_flow,
0.0,
places=12,
)
self.assertAlmostEqual(upstream_tee.port_in.p, upstream_tee.port_out1.p)
self.assertAlmostEqual(upstream_tee.port_in.p, upstream_tee.port_out2.p)
if __name__ == "__main__":
unittest.main()
+381
View File
@@ -0,0 +1,381 @@
from __future__ import annotations
import asyncio
import unittest
from fastapi import HTTPException, Request
from app.main import (
ReactFlowEdgePayload,
ReactFlowProjectPayload,
build_reactflow_system_xml,
compile_reactflow_network,
simulate_system_xml,
)
from PythonModels.components.resistive_pipe import ResistivePipe
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.systems.generic import (
GenericFluidSystem,
SimulationPreparationError,
)
from tests.test_system_xml_protocol import physical_port
def component_node(
component_id: str,
model_type: str,
ports: list[dict[str, str]],
parameters: dict[str, float] | None = None,
) -> dict[str, object]:
return {
"id": component_id,
"type": "simulationComponent",
"position": {"x": 0.0, "y": 0.0},
"data": {
"label": component_id,
"componentType": model_type,
"modelType": model_type,
"ports": ports,
"parameters": parameters or {},
},
}
def physical_edge(
edge_id: str,
first_component: str,
first_port: str,
second_component: str,
second_port: str,
) -> dict[str, str]:
return {
"id": edge_id,
"source": first_component,
"sourceHandle": first_port,
"target": second_component,
"targetHandle": second_port,
}
def two_port_definitions() -> list[dict[str, str]]:
return [
physical_port("port_a", "inlet", "left"),
physical_port("port_b", "outlet", "right"),
]
def tee_port_definitions() -> list[dict[str, str]]:
return [
physical_port("port_in", "bidirectional", "left"),
physical_port("port_out1", "bidirectional", "right"),
physical_port("port_out2", "bidirectional", "right"),
]
def chain_project(*, reverse_edges: bool = False) -> ReactFlowProjectPayload:
edges = [
physical_edge("edge-1", "cylinder_1", "port_b", "orifice_1", "port_a"),
physical_edge("edge-2", "orifice_1", "port_b", "pipe_1", "port_a"),
physical_edge("edge-3", "pipe_1", "port_b", "tank_1", "port_a"),
]
if reverse_edges:
edges = [
physical_edge(
edge["id"],
edge["target"],
edge["targetHandle"],
edge["source"],
edge["sourceHandle"],
)
for edge in edges
]
return ReactFlowProjectPayload(
name="generic-chain",
nodes=[
component_node(
"cylinder_1",
"cylinder",
[physical_port("port_b", "outlet", "right")],
{"volume": 0.01, "p0": 500000.0, "T0": 300.0},
),
component_node(
"orifice_1",
"orifice",
two_port_definitions(),
{"K": 1e-5, "opening": 1.0},
),
component_node(
"pipe_1",
"pipe",
two_port_definitions(),
{
"length": 1.0,
"diameter": 0.02,
"lambda_darcy": 0.02,
"p0": 100000.0,
"T0": 300.0,
},
),
component_node(
"tank_1",
"tank",
[physical_port("port_a", "inlet", "left")],
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
),
],
edges=edges,
simulation={
"t_start": 0.0,
"t_stop": 0.01,
"step": 0.005,
"max_step": 0.001,
"method": "BDF",
},
)
def branched_project() -> ReactFlowProjectPayload:
return ReactFlowProjectPayload(
name="generic-branch",
nodes=[
component_node(
"cylinder_1",
"cylinder",
[physical_port("port_b", "outlet", "right")],
{"volume": 0.01, "p0": 500000.0, "T0": 300.0},
),
component_node("tee_1", "tee", tee_port_definitions()),
component_node(
"orifice_1",
"orifice",
two_port_definitions(),
{"K": 1e-5, "opening": 1.0},
),
component_node(
"pipe_1",
"pipe",
two_port_definitions(),
{
"length": 1.0,
"diameter": 0.02,
"lambda_darcy": 0.02,
"p0": 100000.0,
"T0": 300.0,
},
),
component_node(
"orifice_2",
"orifice",
two_port_definitions(),
{"K": 1e-5, "opening": 1.0},
),
component_node(
"pipe_2",
"pipe",
two_port_definitions(),
{
"length": 1.0,
"diameter": 0.02,
"lambda_darcy": 0.02,
"p0": 100000.0,
"T0": 300.0,
},
),
component_node("tee_2", "tee", tee_port_definitions()),
component_node(
"tank_1",
"tank",
[physical_port("port_a", "inlet", "left")],
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
),
],
edges=[
physical_edge("edge-1", "cylinder_1", "port_b", "tee_1", "port_in"),
physical_edge("edge-2", "tee_1", "port_out1", "orifice_1", "port_a"),
physical_edge("edge-3", "orifice_1", "port_b", "pipe_1", "port_a"),
physical_edge("edge-4", "pipe_1", "port_b", "tee_2", "port_out1"),
physical_edge("edge-5", "tee_1", "port_out2", "orifice_2", "port_a"),
physical_edge("edge-6", "orifice_2", "port_b", "pipe_2", "port_a"),
physical_edge("edge-7", "pipe_2", "port_b", "tee_2", "port_out2"),
physical_edge("edge-8", "tee_2", "port_in", "tank_1", "port_a"),
],
simulation={
"t_start": 0.0,
"t_stop": 0.005,
"step": 0.005,
"max_step": 0.001,
"method": "BDF",
},
)
def xml_request(body: bytes) -> Request:
delivered = False
async def receive():
nonlocal delivered
if delivered:
return {"type": "http.disconnect"}
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return Request(
{
"type": "http",
"method": "POST",
"path": "/api/system-xml/simulate",
"headers": [(b"content-type", b"application/xml")],
},
receive,
)
class GenericSystemXmlSimulationTests(unittest.TestCase):
def test_xml_pipe_compiles_to_quasi_steady_resistance(self) -> None:
network = compile_reactflow_network(chain_project())
self.assertIsInstance(network.components["pipe_1"], ResistivePipe)
structure = network.pressure_flow_structure_dict()
self.assertEqual(structure["unknownCount"], 12)
self.assertEqual(structure["equationCount"], 12)
self.assertTrue(structure["isSquare"])
def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None:
network = compile_reactflow_network(chain_project())
result = GenericFluidSystem(network).simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.01,
method="BDF",
max_step=0.001,
),
sample_step=0.005,
)
self.assertTrue(result.success)
self.assertLess(result.series["cylinder_1.p"][-1], 500000.0)
self.assertGreater(result.series["tank_1.p"][-1], 100000.0)
total_mass = [
cylinder + tank
for cylinder, tank in zip(
result.series["cylinder_1.m"],
result.series["tank_1.m"],
)
]
self.assertLess(max(total_mass) - min(total_mass), 1e-12)
total_energy = [
cylinder + tank
for cylinder, tank in zip(
result.series["cylinder_1.U"],
result.series["tank_1.U"],
)
]
self.assertLess(max(total_energy) - min(total_energy), 1e-6)
self.assertLess(
result.diagnostics["pressureFlow"]["maxScaledResidual"],
1e-7,
)
def test_physical_edge_order_does_not_change_simulation(self) -> None:
forward = GenericFluidSystem(
compile_reactflow_network(chain_project())
).simulate(
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
sample_step=0.005,
)
reverse = GenericFluidSystem(
compile_reactflow_network(chain_project(reverse_edges=True))
).simulate(
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
sample_step=0.005,
)
self.assertAlmostEqual(
forward.final["tank_1.p"],
reverse.final["tank_1.p"],
places=7,
)
def test_branched_topology_is_solved_without_fixed_testmodel_closure(self) -> None:
network = compile_reactflow_network(branched_project())
result = GenericFluidSystem(network).simulate(
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
sample_step=0.005,
)
self.assertTrue(result.success)
self.assertAlmostEqual(
result.final["pipe_1.port_a.m_flow"],
result.final["pipe_2.port_a.m_flow"],
places=10,
)
self.assertGreater(result.final["tank_1.p"], 100000.0)
def test_directly_coupled_storage_components_are_rejected(self) -> None:
project = chain_project()
project.nodes = [project.nodes[0], project.nodes[-1]]
project.edges = [
ReactFlowEdgePayload(
**physical_edge(
"edge-1",
"cylinder_1",
"port_b",
"tank_1",
"port_a",
)
)
]
with self.assertRaises(SimulationPreparationError) as caught:
GenericFluidSystem(compile_reactflow_network(project))
self.assertIn(
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
{issue.code for issue in caught.exception.issues},
)
def test_raw_system_xml_runs_through_generic_simulation_endpoint(self) -> None:
xml = build_reactflow_system_xml(chain_project())
response = asyncio.run(simulate_system_xml(xml_request(xml)))
self.assertTrue(response["success"])
self.assertEqual(response["simulation"]["method"], "BDF")
self.assertEqual(response["model"]["pressureFlowSystem"]["unknownCount"], 12)
self.assertEqual(response["diagnostics"]["stateCount"], 4)
self.assertGreater(response["final"]["tank_1.p"], 100000.0)
def test_simulation_endpoint_returns_422_for_ideal_storage_coupling(self) -> None:
project = chain_project()
project.nodes = [project.nodes[0], project.nodes[-1]]
project.edges = [
ReactFlowEdgePayload(
**physical_edge(
"edge-1",
"cylinder_1",
"port_b",
"tank_1",
"port_a",
)
)
]
with self.assertRaises(HTTPException) as caught:
asyncio.run(
simulate_system_xml(
xml_request(build_reactflow_system_xml(project))
)
)
self.assertEqual(caught.exception.status_code, 422)
self.assertIn(
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
{
issue["code"]
for issue in caught.exception.detail["issues"]
},
)
if __name__ == "__main__":
unittest.main()
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
import asyncio
import unittest
from fastapi import HTTPException, Request
from app.main import (
ReactFlowEdgePayload,
ReactFlowNodePayload,
ReactFlowProjectPayload,
build_reactflow_system_xml,
compile_system_xml_model,
compile_reactflow_network,
parse_system_xml,
)
from app.system_xml import validate_system_xml_document
from tests.test_system_xml_protocol import (
physical_connection_project,
physical_port,
)
def valid_xml() -> bytes:
return build_reactflow_system_xml(physical_connection_project())
def issue_codes(xml: bytes | str) -> set[str]:
return {
issue.code
for issue in validate_system_xml_document(xml).issues
}
def xml_request(body: bytes) -> Request:
delivered = False
async def receive():
nonlocal delivered
if delivered:
return {"type": "http.disconnect"}
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return Request(
{
"type": "http",
"method": "POST",
"path": "/api/system-xml/compile-model",
"headers": [(b"content-type", b"application/xml")],
},
receive,
)
class SystemXmlParserTests(unittest.TestCase):
def test_valid_v2_xml_round_trips_and_compiles(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)
self.assertEqual(project.name, "protocol-test")
self.assertEqual(project.simulation.method, "BDF")
self.assertEqual(set(network.components), {"cylinder_1", "tank_1"})
self.assertEqual(len(network.connections), 1)
self.assertEqual(network.connections[0].id, "edge-1")
def test_compile_api_consumes_raw_xml_body(self) -> None:
response = asyncio.run(compile_system_xml_model(xml_request(valid_xml())))
self.assertTrue(response["success"])
self.assertEqual(response["name"], "protocol-test")
self.assertEqual(len(response["components"]), 2)
self.assertEqual(len(response["connections"]), 1)
self.assertTrue(response["validation"]["valid"])
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>")))
self.assertEqual(caught.exception.status_code, 422)
detail = caught.exception.detail
self.assertEqual(detail["message"], "System XML validation failed.")
self.assertEqual(detail["issues"][0]["layer"], "xml")
self.assertEqual(detail["issues"][0]["code"], "XML_SYNTAX_ERROR")
def test_malformed_xml_reports_xml_layer_error(self) -> None:
report = validate_system_xml_document("<System><broken></System>")
self.assertFalse(report.valid)
self.assertIsNone(report.document)
self.assertEqual(report.issues[0].layer, "xml")
self.assertEqual(report.issues[0].code, "XML_SYNTAX_ERROR")
self.assertIsNotNone(report.issues[0].line)
def test_dtd_is_rejected_before_schema_validation(self) -> None:
xml = valid_xml().replace(
b"<System ",
b"<!DOCTYPE System [<!ENTITY demo 'unsafe'>]>\n<System ",
1,
)
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertEqual(report.issues[0].layer, "xml")
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"')
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertIsNone(report.document)
self.assertTrue(report.issues)
self.assertTrue(all(issue.layer == "schema" for issue in report.issues))
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,
)
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertIn("PARAMETER_VALUE_INVALID", issue_codes(xml))
issue = next(
issue for issue in report.issues if issue.code == "PARAMETER_VALUE_INVALID"
)
self.assertEqual(issue.layer, "semantic")
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,
)
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:
xml = valid_xml().replace(
b'nominalRole="outlet"',
b'nominalRole="bidirectional"',
1,
)
self.assertIn("PORT_NOMINAL_ROLE_MISMATCH", issue_codes(xml))
def test_unknown_connection_endpoint_is_reported(self) -> None:
xml = valid_xml().replace(
b'component="tank_1" port="port_a"',
b'component="missing_tank" port="port_a"',
1,
)
self.assertIn("ENDPOINT_COMPONENT_UNKNOWN", issue_codes(xml))
def test_physical_port_cannot_be_used_by_two_connections(self) -> None:
project = physical_connection_project()
project.nodes.append(
ReactFlowNodePayload(**{
"id": "tank_2",
"type": "simulationComponent",
"position": {"x": 420, "y": 180},
"data": {
"label": "receiver-tank-2",
"componentType": "tank",
"modelType": "tank",
"ports": [physical_port("port_a", "inlet", "left")],
"parameters": {"volume": 0.1, "p0": 100000, "T0": 300},
},
})
)
project.edges.append(
ReactFlowEdgePayload(**{
"id": "edge-2",
"source": "cylinder_1",
"sourceHandle": "port_b",
"target": "tank_2",
"targetHandle": "port_a",
})
)
xml = build_reactflow_system_xml(project)
self.assertIn("PHYSICAL_PORT_ALREADY_CONNECTED", issue_codes(xml))
def test_unconnected_registered_port_is_a_warning(self) -> None:
project = physical_connection_project()
project.edges.clear()
report = validate_system_xml_document(build_reactflow_system_xml(project))
self.assertTrue(report.valid)
self.assertEqual(report.as_dict()["warningCount"], 2)
self.assertEqual(
{issue.code for issue in report.issues},
{"PORT_UNCONNECTED"},
)
def test_missing_required_parameter_is_reported(self) -> None:
xml = valid_xml().replace(
b' <Parameter name="T0" value="300" />\n',
b"",
1,
)
self.assertIn("PARAMETER_REQUIRED_MISSING", issue_codes(xml))
def test_unsupported_solver_method_is_reported(self) -> None:
xml = valid_xml().replace(b'method="BDF"', b'method="unknown"')
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 = xml.replace(b'maxStep="0.01"', b'maxStep="-1"')
report = validate_system_xml_document(xml)
self.assertFalse(report.valid)
self.assertEqual(
[issue.code for issue in report.issues].count("XSD_VALIDATION_ERROR"),
2,
)
if __name__ == "__main__":
unittest.main()
+198 -35
View File
@@ -6,44 +6,93 @@ from xml.etree import ElementTree as ET
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
class SystemXmlProtocolTests(unittest.TestCase):
def test_v1_xml_contains_simulation_and_stable_section_order(self) -> None:
project = ReactFlowProjectPayload(
name="protocol-test",
nodes=[
{
"id": "cylinder_1",
"type": "simulationComponent",
"position": {"x": 12.5, "y": 24.0},
"data": {
"label": "source-cylinder",
"componentType": "cylinder",
"modelType": "cylinder",
"ports": ["port_b"],
"parameters": {"volume": 0.01, "p0": 35000000, "T0": 300},
},
}
],
edges=[],
simulation={
"t_start": 1.0,
"t_stop": 5.0,
"step": 0.2,
"max_step": 0.01,
"method": "BDF",
},
)
def physical_port(
name: str,
role: str,
side: str,
*,
domain: str = "pneumatic",
) -> dict[str, str]:
return {
"name": name,
"kind": "physical",
"domain": domain,
"nominalRole": role,
"positiveFlowDirection": "intoComponent",
"side": side,
}
root = ET.fromstring(build_reactflow_system_xml(project))
def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProjectPayload:
source = {
"id": "cylinder_1",
"type": "simulationComponent",
"position": {"x": 12.5, "y": 24.0},
"data": {
"label": "source-cylinder",
"componentType": "cylinder",
"modelType": "cylinder",
"ports": [physical_port("port_b", "outlet", "right")],
"parameters": {"volume": 0.01, "p0": 35000000, "T0": 300},
},
}
target = {
"id": "tank_1",
"type": "simulationComponent",
"position": {"x": 420.0, "y": 24.0},
"data": {
"label": "receiver-tank",
"componentType": "tank",
"modelType": "tank",
"ports": [physical_port("port_a", "inlet", "left")],
"parameters": {"volume": 0.1, "p0": 100000, "T0": 300},
},
}
edge = (
{
"id": "edge-1",
"source": "tank_1",
"sourceHandle": "port_a",
"target": "cylinder_1",
"targetHandle": "port_b",
}
if reverse_edge
else {
"id": "edge-1",
"source": "cylinder_1",
"sourceHandle": "port_b",
"target": "tank_1",
"targetHandle": "port_a",
}
)
return ReactFlowProjectPayload(
name="protocol-test",
nodes=[source, target],
edges=[edge],
simulation={
"t_start": 1.0,
"t_stop": 5.0,
"step": 0.2,
"max_step": 0.01,
"method": "BDF",
},
)
class SystemXmlProtocolTests(unittest.TestCase):
def test_v2_xml_contains_port_metadata_and_neutral_physical_endpoints(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"], "1")
self.assertEqual(root.attrib["schemaVersion"], "2")
self.assertEqual(root.attrib["unitSystem"], "SI")
self.assertEqual([child.tag for child in root], ["Simulation", "Components", "Connections"])
self.assertEqual(
[child.tag for child in root],
["Simulation", "Components", "Connections"],
)
simulation = root.find("Simulation")
self.assertIsNotNone(simulation)
assert simulation is not None
self.assertEqual(
simulation.attrib,
@@ -56,11 +105,125 @@ class SystemXmlProtocolTests(unittest.TestCase):
},
)
component = root.find("./Components/Component")
self.assertIsNotNone(component)
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["id"], "cylinder_1")
self.assertEqual(component.find("./Parameter[@name='p0']").attrib["value"], "35000000")
self.assertEqual(component.attrib["rotation"], "0")
self.assertEqual(component.attrib["mirrored"], "false")
self.assertEqual(
port.attrib,
{
"name": "port_b",
"kind": "physical",
"domain": "pneumatic",
"nominalRole": "outlet",
"side": "right",
"positiveFlowDirection": "intoComponent",
},
)
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(
{
(endpoint.attrib["component"], endpoint.attrib["port"])
for endpoint in connection.findall("Endpoint")
},
{("cylinder_1", "port_b"), ("tank_1", "port_a")},
)
self.assertTrue(
all("role" not in endpoint.attrib for endpoint in connection.findall("Endpoint"))
)
def test_physical_connection_meaning_does_not_depend_on_drag_direction(self) -> None:
forward = ET.fromstring(
build_reactflow_system_xml(physical_connection_project())
)
reverse = ET.fromstring(
build_reactflow_system_xml(physical_connection_project(reverse_edge=True))
)
def endpoint_set(root: ET.Element) -> set[tuple[str, str]]:
return {
(endpoint.attrib["component"], endpoint.attrib["port"])
for endpoint in root.findall("./Connections/Connection/Endpoint")
}
self.assertEqual(endpoint_set(forward), endpoint_set(reverse))
def test_component_orientation_is_exported_as_layout_metadata(self) -> None:
project = physical_connection_project()
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"],
)
def test_incompatible_physical_domains_are_rejected(self) -> None:
project = physical_connection_project()
tank_port = project.nodes[1].data.ports[0]
assert not isinstance(tank_port, str)
tank_port.domain = "hydraulic"
with self.assertRaisesRegex(ValueError, "incompatible domains"):
build_reactflow_system_xml(project)
if __name__ == "__main__":