from __future__ import annotations from datetime import datetime, timezone import json from pathlib import Path from typing import Any from xml.etree import ElementTree as ET from fastapi import FastAPI, HTTPException, Response from fastapi.responses import FileResponse, HTMLResponse from pydantic import BaseModel, Field 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" class ReactFlowPosition(BaseModel): x: float = 0.0 y: float = 0.0 class ReactFlowNodeData(BaseModel): label: str = "" componentType: str = "component" modelType: str = "component" ports: list[str] = Field(default_factory=list) parameters: dict[str, Any] = Field(default_factory=dict) 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: return Response( content=build_reactflow_system_xml(payload), 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 def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: system = ET.Element("System", {"name": project.name}) components_node = ET.SubElement(system, "Components") connections_node = ET.SubElement(system, "Connections") 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}", }, ) for port in node.data.ports: ET.SubElement(component_node, "Port", {"name": port}) for name, value in node.data.parameters.items(): ET.SubElement( component_node, "Parameter", {"name": name, "value": str(value)}, ) for edge in project.edges: ET.SubElement( connections_node, "Connection", { "id": edge.id, "source": edge.source, "sourcePort": edge.sourceHandle or "", "target": edge.target, "targetPort": edge.targetHandle or "", }, ) ET.indent(system, space=" ") return ET.tostring(system, encoding="utf-8", xml_declaration=True) 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), )