Files
SystemSimulationApp/app/main.py
T

1332 lines
44 KiB
Python

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(
"""
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>System Simulation Flow</title>
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1f2933;
background: #f8fafc;
}
main {
max-width: 720px;
margin: 96px auto;
padding: 0 24px;
}
code {
padding: 2px 6px;
border-radius: 4px;
background: #e5e7eb;
}
</style>
</head>
<body>
<main>
<h1>System Simulation Flow</h1>
<p>ReactFlow 前端未构建。开发时请在 <code>frontend</code> 目录运行 <code>npm run dev</code>,然后打开 <code>http://127.0.0.1:5173</code>。</p>
<p>如果要让 FastAPI 直接服务页面,请先运行 <code>npm run build</code> 生成 <code>frontend/dist</code>。</p>
</main>
</body>
</html>
""",
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 '<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 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),
)