完善 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

+418 -15
View File
@@ -3,18 +3,28 @@ from __future__ import annotations
from datetime import datetime, timezone
import json
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any, Literal
from xml.etree import ElementTree as ET
from fastapi import FastAPI, HTTPException, Response
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field
from app.system_xml import (
SystemXmlDocument,
SystemXmlValidationReport,
validate_system_xml_document,
)
if TYPE_CHECKING:
from PythonModels.core.network import SimulationNetwork
from PythonModels.core.ports import PortDefinition
app = FastAPI(title="System Simulation ReactFlow App")
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
SYSTEM_XML_SCHEMA_VERSION = "1"
SYSTEM_XML_SCHEMA_VERSION = "2"
SYSTEM_XML_UNIT_SYSTEM = "SI"
@@ -23,12 +33,29 @@ class ReactFlowPosition(BaseModel):
y: float = 0.0
class ReactFlowPortDefinition(BaseModel):
name: str
kind: Literal["physical", "signal"] = "physical"
domain: str = "pneumatic"
nominalRole: Literal[
"inlet",
"outlet",
"bidirectional",
"input",
"output",
] = "bidirectional"
positiveFlowDirection: Literal["intoComponent"] | None = None
side: Literal["left", "right"] = "left"
class ReactFlowNodeData(BaseModel):
label: str = ""
componentType: str = "component"
modelType: str = "component"
ports: list[str] = Field(default_factory=list)
ports: list[ReactFlowPortDefinition | str] = Field(default_factory=list)
parameters: dict[str, Any] = Field(default_factory=dict)
rotation: Literal[0, 90, 180, 270] = 0
mirrored: bool = False
class ReactFlowNodePayload(BaseModel):
@@ -130,10 +157,11 @@ def frontend_asset(path: str) -> FileResponse:
@app.post("/api/reactflow/system-xml")
def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
return Response(
content=build_reactflow_system_xml(payload),
media_type="application/xml",
)
try:
xml = build_reactflow_system_xml(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return Response(content=xml, media_type="application/xml")
@app.get("/api/reactflow/projects")
@@ -191,6 +219,178 @@ def simulate_reactflow_testmodel(payload: ReactFlowProjectPayload) -> dict[str,
return result
@app.post("/api/reactflow/compile-model")
def compile_reactflow_model(payload: ReactFlowProjectPayload) -> dict[str, object]:
try:
network = compile_reactflow_network(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"success": True, **network.as_interface_dict()}
@app.post("/api/system-xml/validate")
async def validate_system_xml(request: Request) -> dict[str, object]:
report = validate_system_xml_document(await request.body())
return report.as_dict()
@app.post("/api/system-xml/parse")
async def parse_system_xml(request: Request) -> dict[str, object]:
report = validate_system_xml_document(await request.body())
document = _validated_xml_document_or_422(report)
return {
"success": True,
"validation": report.as_dict(),
"project": document.as_project_data(),
}
@app.post("/api/system-xml/compile-model")
async def compile_system_xml_model(request: Request) -> dict[str, object]:
report = validate_system_xml_document(await request.body())
document = _validated_xml_document_or_422(report)
project, network = _compile_xml_document_or_422(document)
return {
"success": True,
"validation": report.as_dict(),
"simulation": pydantic_to_jsonable(project.simulation),
**network.as_interface_dict(),
}
@app.post("/api/system-xml/simulate")
async def simulate_system_xml(request: Request) -> dict[str, object]:
from PythonModels.core.algebraic import AlgebraicSolveError
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.core.stream import StreamSolveError
from PythonModels.systems.generic import (
GenericFluidSystem,
SimulationPreparationError,
)
report = validate_system_xml_document(await request.body())
document = _validated_xml_document_or_422(report)
project, network = _compile_xml_document_or_422(document)
try:
system = GenericFluidSystem(network)
result = system.simulate(
SolveIVPConfig(
t_start=project.simulation.t_start,
t_stop=project.simulation.t_stop,
method=project.simulation.method,
max_step=project.simulation.max_step,
),
sample_step=project.simulation.step,
)
except SimulationPreparationError as exc:
raise HTTPException(
status_code=422,
detail={
"message": "The compiled model is not ready for simulation.",
"issues": [
{
"severity": "error",
"layer": "simulation",
**issue.as_dict(),
}
for issue in exc.issues
],
},
) from exc
except AlgebraicSolveError as exc:
raise HTTPException(
status_code=422,
detail={
"message": str(exc),
"issues": [
{
"severity": "error",
"layer": "simulation",
"code": "PRESSURE_FLOW_SOLVE_FAILED",
"message": str(exc),
}
],
"diagnostics": exc.diagnostics.as_dict(),
},
) from exc
except StreamSolveError as exc:
raise HTTPException(
status_code=422,
detail={
"message": str(exc),
"issues": [
{
"severity": "error",
"layer": "simulation",
"code": "STREAM_SOLVE_FAILED",
"message": str(exc),
}
],
"diagnostics": exc.diagnostics.as_dict(),
},
) from exc
except (RuntimeError, ValueError) as exc:
raise HTTPException(
status_code=422,
detail={
"message": "Simulation failed while evaluating the compiled model.",
"issues": [
{
"severity": "error",
"layer": "simulation",
"code": "SIMULATION_EXECUTION_FAILED",
"message": str(exc),
}
],
},
) from exc
return {
"validation": report.as_dict(),
"simulation": pydantic_to_jsonable(project.simulation),
"model": network.as_interface_dict(),
**result.as_dict(),
}
def _validated_xml_document_or_422(
report: SystemXmlValidationReport,
) -> SystemXmlDocument:
if not report.valid or report.document is None:
raise HTTPException(
status_code=422,
detail={
"message": "System XML validation failed.",
"issues": [issue.as_dict() for issue in report.issues],
},
)
return report.document
def _compile_xml_document_or_422(
document: SystemXmlDocument,
) -> tuple[ReactFlowProjectPayload, "SimulationNetwork"]:
project = ReactFlowProjectPayload(**document.as_project_data())
try:
network = compile_reactflow_network(project)
except ValueError as exc:
raise HTTPException(
status_code=422,
detail={
"message": "System XML passed protocol validation but model compilation failed.",
"issues": [
{
"severity": "error",
"layer": "semantic",
"code": "MODEL_COMPILATION_FAILED",
"message": str(exc),
}
],
},
) from exc
return project, network
def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
system = ET.Element(
"System",
@@ -213,6 +413,7 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
)
components_node = ET.SubElement(system, "Components")
connections_node = ET.SubElement(system, "Connections")
port_index: dict[tuple[str, str], ReactFlowPortDefinition] = {}
for node in project.nodes:
component_node = ET.SubElement(
@@ -225,10 +426,34 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
"componentType": node.data.componentType,
"x": f"{node.position.x:g}",
"y": f"{node.position.y:g}",
"rotation": str(node.data.rotation),
"mirrored": str(node.data.mirrored).lower(),
},
)
for port in node.data.ports:
ET.SubElement(component_node, "Port", {"name": port})
for index, port in enumerate(node.data.ports):
port_definition = normalize_port_definition(
port,
index=index,
component_type=node.data.componentType,
)
port_key = (node.id, port_definition.name)
if port_key in port_index:
raise ValueError(
f"Component {node.id} contains duplicate port {port_definition.name}."
)
port_index[port_key] = port_definition
port_attributes = {
"name": port_definition.name,
"kind": port_definition.kind,
"domain": port_definition.domain,
"nominalRole": port_definition.nominalRole,
"side": port_definition.side,
}
if port_definition.kind == "physical":
port_attributes["positiveFlowDirection"] = (
port_definition.positiveFlowDirection or "intoComponent"
)
ET.SubElement(component_node, "Port", port_attributes)
for name, value in node.data.parameters.items():
ET.SubElement(
component_node,
@@ -237,22 +462,200 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
)
for edge in project.edges:
ET.SubElement(
first = require_connection_port(
port_index,
edge.source,
edge.sourceHandle,
edge.id,
)
second = require_connection_port(
port_index,
edge.target,
edge.targetHandle,
edge.id,
)
validate_compatible_ports(first, second, edge.id)
connection_node = ET.SubElement(
connections_node,
"Connection",
{
"id": edge.id,
"source": edge.source,
"sourcePort": edge.sourceHandle or "",
"target": edge.target,
"targetPort": edge.targetHandle or "",
"kind": first.kind,
"domain": first.domain,
},
)
endpoints = [
(edge.source, first, None),
(edge.target, second, None),
]
if first.kind == "signal":
if first.nominalRole == "input":
endpoints.reverse()
endpoints = [
(endpoints[0][0], endpoints[0][1], "source"),
(endpoints[1][0], endpoints[1][1], "target"),
]
for component_id, port, role in endpoints:
attributes = {"component": component_id, "port": port.name}
if role is not None:
attributes["role"] = role
ET.SubElement(connection_node, "Endpoint", attributes)
ET.indent(system, space=" ")
return ET.tostring(system, encoding="utf-8", xml_declaration=True)
def normalize_port_definition(
port: ReactFlowPortDefinition | str,
*,
index: int,
component_type: str,
) -> ReactFlowPortDefinition:
if isinstance(port, ReactFlowPortDefinition):
return port
registered_legacy_ports: dict[str, dict[str, tuple[str, str]]] = {
"cylinder": {"port_b": ("outlet", "right")},
"tank": {"port_a": ("inlet", "left")},
"pipe": {
"port_a": ("inlet", "left"),
"port_b": ("outlet", "right"),
},
"orifice": {
"port_a": ("inlet", "left"),
"port_b": ("outlet", "right"),
},
"tee": {
"port_in": ("bidirectional", "left"),
"port_out1": ("bidirectional", "right"),
"port_out2": ("bidirectional", "right"),
},
}
registered = registered_legacy_ports.get(component_type, {}).get(port)
if registered is not None:
nominal_role, side = registered
return ReactFlowPortDefinition(
name=port,
nominalRole=nominal_role,
positiveFlowDirection="intoComponent",
side=side,
)
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional"
if "out" in port or port == "port_b":
nominal_role = "outlet"
elif "in" in port or port == "port_a":
nominal_role = "inlet"
return ReactFlowPortDefinition(
name=port,
nominalRole=nominal_role,
positiveFlowDirection="intoComponent",
side="left" if index == 0 else "right",
)
def require_connection_port(
port_index: dict[tuple[str, str], ReactFlowPortDefinition],
component_id: str,
port_name: str | None,
connection_id: str,
) -> ReactFlowPortDefinition:
if port_name is None or (component_id, port_name) not in port_index:
raise ValueError(
f"Connection {connection_id} references missing endpoint "
f"{component_id}.{port_name or '<empty>'}."
)
return port_index[(component_id, port_name)]
def validate_compatible_ports(
first: ReactFlowPortDefinition,
second: ReactFlowPortDefinition,
connection_id: str,
) -> None:
if first.kind != second.kind:
raise ValueError(f"Connection {connection_id} mixes physical and signal ports.")
if first.domain != second.domain:
raise ValueError(f"Connection {connection_id} connects incompatible domains.")
if first.kind == "signal" and {first.nominalRole, second.nominalRole} != {
"input",
"output",
}:
raise ValueError(
f"Signal connection {connection_id} must connect one output to one input."
)
def compile_reactflow_network(project: ReactFlowProjectPayload) -> "SimulationNetwork":
from PythonModels.core.medium import IdealGasMedium
from PythonModels.core.network import SimulationNetwork
from PythonModels.registry import get_component_model_spec
medium = IdealGasMedium()
network = SimulationNetwork(name=project.name)
for node in project.nodes:
spec = get_component_model_spec(node.data.modelType)
parameter_values = {
parameter.name: parameter_float(node, parameter.name, parameter.default)
for parameter in spec.parameters
}
unknown_parameters = set(node.data.parameters) - set(spec.parameter_by_name)
if unknown_parameters:
raise ValueError(
f"Component '{node.id}' contains unsupported parameters: "
+ ", ".join(sorted(unknown_parameters))
+ "."
)
component = spec.create(node.id, medium, parameter_values)
validate_component_port_interface(node, component.port_definitions)
network.add_component(component)
for edge in project.edges:
network.connect(
edge.source,
edge.sourceHandle or "",
edge.target,
edge.targetHandle or "",
connection_id=edge.id,
)
return network
def validate_component_port_interface(
node: ReactFlowNodePayload,
component_ports: tuple["PortDefinition", ...],
) -> None:
payload_ports = [
normalize_port_definition(
port,
index=index,
component_type=node.data.componentType,
)
for index, port in enumerate(node.data.ports)
]
expected_by_name = {port.name: port for port in component_ports}
payload_by_name = {port.name: port for port in payload_ports}
if len(payload_by_name) != len(payload_ports):
raise ValueError(f"Component {node.id} contains duplicate port names.")
if set(payload_by_name) != set(expected_by_name):
raise ValueError(
f"Component {node.id} port names do not match model {node.data.modelType}."
)
for name, payload_port in payload_by_name.items():
expected = expected_by_name[name]
if payload_port.kind != expected.kind or payload_port.domain != expected.domain:
raise ValueError(f"Component {node.id}.{name} has an incompatible port type.")
if payload_port.nominalRole != expected.nominal_role:
raise ValueError(f"Component {node.id}.{name} has an incompatible nominal role.")
if expected.kind == "physical" and (
payload_port.positiveFlowDirection or "intoComponent"
) != expected.positive_flow_direction:
raise ValueError(f"Component {node.id}.{name} has an incompatible flow sign.")
def reactflow_project_path(project_id: str) -> Path:
safe_id = sanitize_project_id(project_id)
return PROJECT_STORAGE_DIR / f"{safe_id}.json"