from __future__ import annotations from collections.abc import Callable, Iterator import csv from dataclasses import dataclass from datetime import datetime, timezone import io import json from math import isfinite from pathlib import Path import queue import re import threading from time import monotonic from typing import TYPE_CHECKING, Any, Literal from urllib.parse import quote from uuid import uuid4 from xml.etree import ElementTree as ET from fastapi import FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from pydantic import BaseModel, Field from app.system_xml import ( SystemXmlDocument, SystemXmlValidationReport, validate_system_xml_document, ) if TYPE_CHECKING: from app.simulation.core.ports import PortDefinition from app.simulation.systems.network import SimulationNetwork 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" SimulationProgressEmitter = Callable[ [int, str, str, float | None, float | None], None, ] SIMULATION_PHASE_MESSAGES = { "initializing": "正在初始化状态与方程", "integrating": "正在进行时间积分与压力流量求解", "postprocessing": "正在整理采样结果", "cancelled": "正在整理已终止仿真的部分结果", "failed": "正在整理异常终止前的部分结果", "complete": "正在汇总仿真结果", } SIMULATION_STREAM_HEARTBEAT_SECONDS = 5.0 SIMULATION_TASK_RETENTION_SECONDS = 600.0 SimulationCancelReason = Literal["user", "stalled"] SimulationTaskStatus = Literal[ "queued", "running", "cancelling", "completed", "stopped", "stalled", "failed", ] @dataclass class SimulationTaskRecord: simulation_id: str cancel_event: threading.Event status: SimulationTaskStatus = "queued" cancel_reason: SimulationCancelReason | None = None result: dict[str, object] | None = None error: dict[str, object] | None = None updated_at: float = 0.0 SIMULATION_TASKS: dict[str, SimulationTaskRecord] = {} SIMULATION_TASKS_LOCK = threading.Lock() 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) class SimulationResultVariablePayload(BaseModel): key: str componentId: str componentType: str scope: Literal["component", "port"] portName: str | None = None name: str label: str quantity: str unit: str = "" category: str = "derived" order: int = 0 class SimulationResultCsvPayload(BaseModel): projectName: str = "simulation" variables: list[SimulationResultVariablePayload] = Field(default_factory=list) series: dict[str, list[float]] = Field(default_factory=dict) class SimulationCancellationPayload(BaseModel): reason: SimulationCancelReason = "user" 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.get("/api/components/catalog") def get_component_catalog() -> dict[str, object]: from app.simulation.registry import build_component_catalog return build_component_catalog() @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.post("/api/simulation-results/csv") def export_simulation_results_csv( payload: SimulationResultCsvPayload, ) -> Response: csv_text = build_simulation_results_csv(payload) filename = simulation_results_csv_filename(payload.projectName) ascii_filename = re.sub(r"[^A-Za-z0-9._-]+", "_", filename).strip("._") if not ascii_filename: ascii_filename = "simulation-results.csv" disposition = ( f'attachment; filename="{ascii_filename}"; ' f"filename*=UTF-8''{quote(filename)}" ) return Response( content="\ufeff" + csv_text, media_type="text/csv", headers={"Content-Disposition": disposition}, ) def build_simulation_results_csv(payload: SimulationResultCsvPayload) -> str: times = payload.series.get("time") if not times: raise HTTPException( status_code=422, detail="Simulation results must contain a non-empty time series.", ) variable_keys = [variable.key for variable in payload.variables] if not variable_keys: raise HTTPException( status_code=422, detail="Simulation results do not contain exportable variables.", ) if len(variable_keys) != len(set(variable_keys)): raise HTTPException( status_code=422, detail="Simulation result metadata contains duplicate variable keys.", ) expected_keys = {"time", *variable_keys} missing_keys = sorted(expected_keys - set(payload.series)) unknown_keys = sorted(set(payload.series) - expected_keys) if missing_keys or unknown_keys: details = [] if missing_keys: details.append("missing " + ", ".join(missing_keys)) if unknown_keys: details.append("unmapped " + ", ".join(unknown_keys)) raise HTTPException( status_code=422, detail="Simulation result columns do not match metadata: " + "; ".join(details) + ".", ) row_count = len(times) for key in ("time", *variable_keys): values = payload.series[key] if len(values) != row_count: raise HTTPException( status_code=422, detail=f"Simulation result column '{key}' has an inconsistent length.", ) if not all(isfinite(value) for value in values): raise HTTPException( status_code=422, detail=f"Simulation result column '{key}' contains non-finite values.", ) output = io.StringIO(newline="") writer = csv.writer(output, lineterminator="\r\n") writer.writerow(["time", *variable_keys]) for index in range(row_count): writer.writerow( [payload.series["time"][index]] + [payload.series[key][index] for key in variable_keys] ) return output.getvalue() def simulation_results_csv_filename(project_name: str) -> str: stem = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", project_name).strip(" .") return f"{stem[:80] or 'simulation'}-results.csv" @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/simulate-test-mql") def simulate_reactflow_test_mql(payload: ReactFlowProjectPayload) -> dict[str, object]: try: result = run_reactflow_test_mql(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(), } TERMINAL_SIMULATION_TASK_STATUSES = {"completed", "stopped", "stalled", "failed"} def _register_simulation_task(simulation_id: str) -> SimulationTaskRecord: normalized_id = simulation_id.strip() if ( not normalized_id or len(normalized_id) > 128 or re.fullmatch(r"[A-Za-z0-9._-]+", normalized_id) is None ): raise HTTPException(status_code=422, detail="Invalid simulation task id.") now = monotonic() with SIMULATION_TASKS_LOCK: expired_ids = [ task_id for task_id, task in SIMULATION_TASKS.items() if task.status in TERMINAL_SIMULATION_TASK_STATUSES and now - task.updated_at > SIMULATION_TASK_RETENTION_SECONDS ] for task_id in expired_ids: del SIMULATION_TASKS[task_id] if normalized_id in SIMULATION_TASKS: raise HTTPException( status_code=409, detail="A simulation task with this id already exists.", ) task = SimulationTaskRecord( simulation_id=normalized_id, cancel_event=threading.Event(), updated_at=now, ) SIMULATION_TASKS[normalized_id] = task return task def _simulation_task_snapshot(task: SimulationTaskRecord) -> dict[str, object]: with SIMULATION_TASKS_LOCK: return { "simulationId": task.simulation_id, "status": task.status, "cancelReason": task.cancel_reason, "result": task.result, "error": task.error, } def _request_simulation_task_cancel( task: SimulationTaskRecord, reason: SimulationCancelReason, ) -> bool: with SIMULATION_TASKS_LOCK: if task.status in TERMINAL_SIMULATION_TASK_STATUSES: return False if task.cancel_reason is None: task.cancel_reason = reason task.status = "cancelling" task.updated_at = monotonic() task.cancel_event.set() return True def _mark_simulation_task_running(task: SimulationTaskRecord) -> None: with SIMULATION_TASKS_LOCK: if task.status == "queued": task.status = "running" task.updated_at = monotonic() def _mark_simulation_task_result( task: SimulationTaskRecord, result: dict[str, object], ) -> dict[str, object]: with SIMULATION_TASKS_LOCK: result_status = result.get("status") if result_status == "cancelled": public_status: SimulationTaskStatus = ( "stalled" if task.cancel_reason == "stalled" else "stopped" ) elif result_status == "completed" and bool(result.get("success")): public_status = "completed" else: public_status = "failed" result["status"] = public_status result["partial"] = public_status != "completed" task.status = public_status task.result = result task.updated_at = monotonic() return result def _mark_simulation_task_error( task: SimulationTaskRecord, error: dict[str, object], ) -> None: with SIMULATION_TASKS_LOCK: task.status = "failed" task.error = error task.updated_at = monotonic() @app.post("/api/system-xml/simulate") async def simulate_system_xml(request: Request) -> dict[str, object]: return run_system_xml_simulation(await request.body()) @app.post("/api/system-xml/simulate-stream") async def simulate_system_xml_stream(request: Request) -> StreamingResponse: simulation_id = request.headers.get("x-simulation-id") or uuid4().hex task = _register_simulation_task(simulation_id) return StreamingResponse( simulation_event_stream(await request.body(), task=task), media_type="application/x-ndjson", headers={ "Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no", "X-Simulation-Id": task.simulation_id, }, ) @app.post("/api/system-xml/simulations/{simulation_id}/cancel") def cancel_system_xml_simulation( simulation_id: str, payload: SimulationCancellationPayload, ) -> dict[str, object]: with SIMULATION_TASKS_LOCK: task = SIMULATION_TASKS.get(simulation_id) if task is None: raise HTTPException(status_code=404, detail="Simulation task was not found.") accepted = _request_simulation_task_cancel(task, payload.reason) return { "simulationId": simulation_id, "accepted": accepted, "status": task.status, } @app.get("/api/system-xml/simulations/{simulation_id}") def get_system_xml_simulation(simulation_id: str) -> dict[str, object]: with SIMULATION_TASKS_LOCK: task = SIMULATION_TASKS.get(simulation_id) if task is None: raise HTTPException(status_code=404, detail="Simulation task was not found.") return _simulation_task_snapshot(task) def run_system_xml_simulation( xml_bytes: bytes, progress_callback: SimulationProgressEmitter | None = None, cancel_check: Callable[[], bool] | None = None, ) -> dict[str, object]: from app.simulation.solvers.algebraic import AlgebraicSolveError from app.simulation.solvers.solver import SolveIVPConfig from app.simulation.solvers.stream import StreamSolveError from app.simulation.systems.generic import ( GenericFluidSystem, SimulationPreparationError, ) def emit( progress: int, phase: str, message: str, simulated_time: float | None = None, total_time: float | None = None, ) -> None: if progress_callback is not None: progress_callback( progress, phase, message, simulated_time, total_time, ) emit(0, "validation", "正在校验 System XML") report = validate_system_xml_document(xml_bytes) document = _validated_xml_document_or_422(report) emit(0, "compilation", "正在编译组件与连接关系") project, network = _compile_xml_document_or_422(document) emit(0, "initialization", "模型编译完成,正在准备求解器") def report_system_progress(progress: float, phase: str) -> None: bounded_progress = min(1.0, max(0.0, progress)) simulated_time = project.simulation.t_start + bounded_progress * ( project.simulation.t_stop - project.simulation.t_start ) emit( round(100 * bounded_progress), phase, SIMULATION_PHASE_MESSAGES.get(phase, "正在运行仿真"), simulated_time, project.simulation.t_stop, ) 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, progress_callback=report_system_progress, cancel_check=cancel_check, ) 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 simulation_event_stream( xml_bytes: bytes, *, task: SimulationTaskRecord | None = None, ) -> Iterator[str]: events: queue.Queue[dict[str, object] | object] = queue.Queue() finished = object() latest_progress = 0 latest_phase = "queued" latest_message = "正在等待仿真任务启动" latest_simulated_time: float | None = None latest_total_time: float | None = None def emit_progress( progress: int, phase: str, message: str, simulated_time: float | None = None, total_time: float | None = None, ) -> None: nonlocal latest_message, latest_phase, latest_progress nonlocal latest_simulated_time, latest_total_time latest_progress = max(latest_progress, min(100, max(0, progress))) latest_phase = phase latest_message = message if simulated_time is not None and isfinite(simulated_time): latest_simulated_time = simulated_time if total_time is not None and isfinite(total_time): latest_total_time = total_time event: dict[str, object] = { "event": "progress", "progress": latest_progress, "phase": phase, "message": message, } if latest_simulated_time is not None: event["simulatedTime"] = latest_simulated_time if latest_total_time is not None: event["totalTime"] = latest_total_time events.put(event) def worker() -> None: if task is not None: _mark_simulation_task_running(task) try: result = run_system_xml_simulation( xml_bytes, emit_progress, task.cancel_event.is_set if task is not None else None, ) if task is not None: result = _mark_simulation_task_result(task, result) result_status = str(result.get("status", "completed")) result_messages = { "completed": "仿真完成", "stopped": "仿真已由用户终止,已保留部分结果", "stalled": "仿真因进度连接异常而终止,已保留部分结果", "failed": "仿真异常终止,已保留可用的部分结果", } events.put( { "event": "result", "progress": 100 if result_status == "completed" else latest_progress, "phase": result_status, "message": result_messages.get(result_status, "仿真任务结束"), "simulatedTime": result.get("simulatedUntil"), "totalTime": result.get("requestedStopTime"), "result": result, } ) except HTTPException as exc: detail = exc.detail message = ( str(detail.get("message", "仿真失败")) if isinstance(detail, dict) else str(detail) ) error_event = { "event": "error", "progress": latest_progress, "phase": "failed", "status": exc.status_code, "message": message, "detail": detail, } if task is not None: _mark_simulation_task_error(task, error_event) events.put(error_event) except Exception as exc: # pragma: no cover - last-resort stream guard error_event = { "event": "error", "progress": latest_progress, "phase": "failed", "status": 500, "message": "仿真服务发生未预期错误。", "detail": str(exc), } if task is not None: _mark_simulation_task_error(task, error_event) events.put(error_event) finally: events.put(finished) threading.Thread( target=worker, name="system-simulation", daemon=True, ).start() try: while True: try: event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS) except queue.Empty: yield json.dumps( { "event": "progress", "progress": latest_progress, "phase": latest_phase, "message": latest_message, "heartbeat": True, "simulatedTime": latest_simulated_time, "totalTime": latest_total_time, }, ensure_ascii=False, separators=(",", ":"), ) + "\n" continue if event is finished: break yield json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n" finally: if task is not None: _request_simulation_task_cancel(task, "stalled") 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 app.simulation.core.medium import IdealGasMedium from app.simulation.registry import get_component_model_spec from app.simulation.systems.network import SimulationNetwork 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 app.simulation.examples.testmodel.run import ( TestModelExecutionConfig, TestModelRunConfig, TestModelSamplingConfig, run_testmodel, ) from app.simulation.examples.testmodel.system import ( BranchConfig, CylinderConfig, OrificeConfig, PipeConfig, TankConfig, TestModelConfig, ) from app.simulation.solvers.solver import SolveIVPConfig 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 run_reactflow_test_mql(project: ReactFlowProjectPayload) -> dict[str, object]: from app.simulation.examples.test_mql.run import run_test_mql from PythonModels.systems.test_mql import TestMqlRunConfig run_config = TestMqlRunConfig( t_start=project.simulation.t_start, t_stop=project.simulation.t_stop, sample_step=project.simulation.step, ) result = run_test_mql(run_config=run_config) series = { key: [float(value) for value in values] for key, values in result.result.series.items() } final = {key: values[-1] for key, values in series.items() if values} snapshot = result.system.snapshot() return { "success": True, "message": "test_mql fixed-topology simulation completed.", "model": { "name": snapshot.model_name, "componentCount": snapshot.component_count, "connectionCount": snapshot.connection_count, "continuousStateCount": snapshot.continuous_state_count, "discreteStateCount": snapshot.discrete_state_count, }, "final": final, "series": series, "artifacts": { "summary": str(result.summary_path), }, } 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), )