完善 XML 通用仿真与结果查看
This commit is contained in:
1 parent
104d41d294
commit
f1256a121d
36 files changed
+6666
-196
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user