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