from __future__ import annotations
from datetime import datetime, timezone
import json
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from xml.etree import ElementTree as ET
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 = "2"
SYSTEM_XML_UNIT_SYSTEM = "SI"
class ReactFlowPosition(BaseModel):
x: float = 0.0
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[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):
id: str
type: str | None = None
position: ReactFlowPosition = Field(default_factory=ReactFlowPosition)
data: ReactFlowNodeData
class ReactFlowEdgePayload(BaseModel):
id: str
source: str
target: str
sourceHandle: str | None = None
targetHandle: str | None = None
class ReactFlowSimulationConfig(BaseModel):
t_start: float = 0.0
t_stop: float = 2.0
step: float = 0.1
max_step: float = 0.005
method: str = "BDF"
class ReactFlowProjectPayload(BaseModel):
name: str = "untitled"
nodes: list[ReactFlowNodePayload] = Field(default_factory=list)
edges: list[ReactFlowEdgePayload] = Field(default_factory=list)
simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig)
def pydantic_to_jsonable(model: BaseModel) -> dict[str, Any]:
if hasattr(model, "model_dump"):
return model.model_dump(mode="json")
return model.dict()
@app.get("/")
def index() -> HTMLResponse:
frontend_index = FRONTEND_DIST_DIR / "index.html"
if frontend_index.exists():
return HTMLResponse(
frontend_index.read_text(encoding="utf-8"),
headers={"Cache-Control": "no-store"},
)
return HTMLResponse(
"""
System Simulation Flow
System Simulation Flow
ReactFlow 前端未构建。开发时请在 frontend 目录运行 npm run dev,然后打开 http://127.0.0.1:5173。
如果要让 FastAPI 直接服务页面,请先运行 npm run build 生成 frontend/dist。
""",
headers={"Cache-Control": "no-store"},
)
@app.get("/assets/{path:path}")
def frontend_asset(path: str) -> FileResponse:
asset_path = (FRONTEND_DIST_DIR / "assets" / path).resolve()
assets_root = (FRONTEND_DIST_DIR / "assets").resolve()
try:
asset_path.relative_to(assets_root)
except ValueError as exc:
raise HTTPException(status_code=404, detail="Frontend asset was not found.") from exc
if not asset_path.exists():
raise HTTPException(status_code=404, detail="Frontend asset was not found.")
return FileResponse(asset_path)
@app.post("/api/reactflow/system-xml")
def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
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")
def list_reactflow_projects() -> dict[str, object]:
PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
projects = []
for path in sorted(PROJECT_STORAGE_DIR.glob("*.json")):
projects.append(
{
"id": path.stem,
"updatedAt": datetime.fromtimestamp(
path.stat().st_mtime,
timezone.utc,
).isoformat(),
}
)
return {"projects": projects}
@app.get("/api/reactflow/projects/{project_id}")
def load_reactflow_project(project_id: str) -> dict[str, object]:
path = reactflow_project_path(project_id)
if not path.exists():
raise HTTPException(status_code=404, detail="Project was not found.")
return json.loads(path.read_text(encoding="utf-8"))
@app.post("/api/reactflow/projects/{project_id}")
def save_reactflow_project(
project_id: str,
payload: ReactFlowProjectPayload,
) -> dict[str, object]:
path = reactflow_project_path(project_id)
PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
data = pydantic_to_jsonable(payload)
data["name"] = payload.name or project_id
path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return {
"id": path.stem,
"path": str(path),
"componentCount": len(payload.nodes),
"connectionCount": len(payload.edges),
}
@app.post("/api/reactflow/simulate-testmodel")
def simulate_reactflow_testmodel(payload: ReactFlowProjectPayload) -> dict[str, object]:
try:
result = run_reactflow_testmodel(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
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",
{
"name": project.name,
"schemaVersion": SYSTEM_XML_SCHEMA_VERSION,
"unitSystem": SYSTEM_XML_UNIT_SYSTEM,
},
)
ET.SubElement(
system,
"Simulation",
{
"tStart": str(project.simulation.t_start),
"tStop": str(project.simulation.t_stop),
"step": str(project.simulation.step),
"maxStep": str(project.simulation.max_step),
"method": project.simulation.method,
},
)
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(
components_node,
"Component",
{
"id": node.id,
"name": node.data.label,
"type": node.data.modelType,
"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 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,
"Parameter",
{"name": name, "value": str(value)},
)
for edge in project.edges:
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,
"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 ''}."
)
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"
def sanitize_project_id(project_id: str) -> str:
safe_id = "".join(
character
for character in project_id.strip()
if character.isalnum() or character in {"-", "_"}
)
if not safe_id:
raise HTTPException(status_code=400, detail="Project id cannot be empty.")
return safe_id[:80]
def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, object]:
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.scripts.run_testmodel import (
TestModelExecutionConfig,
TestModelRunConfig,
TestModelSamplingConfig,
run_testmodel,
)
from PythonModels.systems.testmodel import (
BranchConfig,
CylinderConfig,
OrificeConfig,
PipeConfig,
TankConfig,
TestModelConfig,
)
nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {}
for node in project.nodes:
nodes_by_type.setdefault(node.data.modelType, []).append(node)
cylinder = first_node(nodes_by_type, "cylinder")
tank = first_node(nodes_by_type, "tank")
orifices = nodes_by_type.get("orifice", [])
pipes = nodes_by_type.get("pipe", [])
model_config = TestModelConfig(
cylinder=CylinderConfig(
volume=parameter_float(cylinder, "volume", 0.01),
p0=parameter_float(cylinder, "p0", 35e6),
T0=parameter_float(cylinder, "T0", 300.0),
),
upper_branch=BranchConfig(
orifice=OrificeConfig(K=parameter_float(node_at(orifices, 0), "K", 1e-5)),
pipe=pipe_config_from_node(node_at(pipes, 0), PipeConfig),
),
lower_branch=BranchConfig(
orifice=OrificeConfig(K=parameter_float(node_at(orifices, 1), "K", 1e-5)),
pipe=pipe_config_from_node(node_at(pipes, 1), PipeConfig),
),
tank=TankConfig(
volume=parameter_float(tank, "volume", 0.1),
p0=parameter_float(tank, "p0", 1e5),
T0=parameter_float(tank, "T0", 300.0),
),
)
run_config = TestModelRunConfig(
model=model_config,
solver=SolveIVPConfig(
t_start=project.simulation.t_start,
t_stop=project.simulation.t_stop,
method=project.simulation.method,
max_step=project.simulation.max_step,
),
sampling=TestModelSamplingConfig(step=project.simulation.step),
execution=TestModelExecutionConfig(use_modelica_reference_if_available=False),
)
result = run_testmodel(run_config=run_config)
series_keys = ("time", "mytank.p", "mytank.T", "mycylinder.p", "mycylinder.T")
series = {
key: [float(value) for value in result.series[key]]
for key in series_keys
if key in result.series
}
return {
"success": bool(result.solution.success),
"message": str(result.solution.message),
"usedModelicaReference": result.used_modelica_reference,
"final": {
"time": series["time"][-1],
"tankPressure": series["mytank.p"][-1],
"tankTemperature": series["mytank.T"][-1],
"cylinderPressure": series["mycylinder.p"][-1],
"cylinderTemperature": series["mycylinder.T"][-1],
},
"series": series,
"artifacts": {
"primaryCsv": str(result.artifacts.primary_csv_path),
"temperatureCsv": str(result.artifacts.temperature_csv_path),
"temperatureSvg": str(result.artifacts.temperature_svg_path),
"runReport": str(result.artifacts.run_report_path),
},
"networkSummary": result.system.network.summary(),
}
def first_node(
nodes_by_type: dict[str, list[ReactFlowNodePayload]],
model_type: str,
) -> ReactFlowNodePayload | None:
nodes = nodes_by_type.get(model_type, [])
return nodes[0] if nodes else None
def node_at(
nodes: list[ReactFlowNodePayload],
index: int,
) -> ReactFlowNodePayload | None:
return nodes[index] if index < len(nodes) else None
def parameter_float(
node: ReactFlowNodePayload | None,
name: str,
default: float,
) -> float:
if node is None:
return default
value = node.data.parameters.get(name, default)
try:
return float(value)
except (TypeError, ValueError):
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.")
def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type):
return pipe_config_type(
length=parameter_float(node, "length", 5.0),
diameter=parameter_float(node, "diameter", 0.02),
lambda_darcy=parameter_float(node, "lambda_darcy", 0.02),
p0=parameter_float(node, "p0", 1e5),
T0=parameter_float(node, "T0", 300.0),
)