from __future__ import annotations from collections.abc import Callable, Iterator, Mapping 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, ConfigDict, Field, ValidationError from app.system_xml import ( SystemXmlDocument, SystemXmlValidationReport, validate_system_xml_document, ) if TYPE_CHECKING: from app.simulation.components.amesim.gases import AmesimGasRegistry from app.simulation.core.ports import PortDefinition from app.simulation.registry import ComponentModelSpec 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 = "3" 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 ReactFlowParameterScientificNotation(BaseModel): text: str unit: str class ReactFlowNodeData(BaseModel): # Editor-only nested metadata is preserved on a storage round trip even # when a newer frontend adds fields the current backend does not consume. model_config = ConfigDict(extra="allow") label: str = "" componentType: str = "component" modelType: str = "component" # Optional at the storage boundary so an incompatible project can still be # opened and inspected. Every execution path requires an exact registry # match before defaults, equations, or ports are consumed. modelVersion: str | None = None ports: list[ReactFlowPortDefinition] = Field(default_factory=list) parameters: dict[str, Any] = Field(default_factory=dict) parameterUnits: dict[str, str] = Field(default_factory=dict) parameterScientificNotation: dict[str, ReactFlowParameterScientificNotation] = 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 ReactFlowEdgeData(BaseModel): model_config = ConfigDict(extra="allow") isContactEdge: bool = False class ReactFlowEdgePayload(BaseModel): model_config = ConfigDict(extra="allow") id: str source: str target: str sourceHandle: str | None = None targetHandle: str | None = None data: ReactFlowEdgeData = Field(default_factory=ReactFlowEdgeData) 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): model_config = ConfigDict(extra="forbid") projectSchemaVersion: Literal[1] name: str = "untitled" nodes: list[ReactFlowNodePayload] = Field(default_factory=list) edges: list[ReactFlowEdgePayload] = Field(default_factory=list) simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig) @dataclass(frozen=True) class SolverComponentInput: """One component instance in the editor-independent execution model.""" id: str model_type: str model_version: str | None parameters: Mapping[str, float] @dataclass(frozen=True) class SolverConnectionInput: """One neutral two-endpoint connection in the execution model.""" id: str | None endpoint_a_component: str endpoint_a_port: str endpoint_b_component: str endpoint_b_port: str @dataclass(frozen=True) class SolverModelInput: """Small common contract compiled from either project JSON or System XML.""" name: str components: tuple[SolverComponentInput, ...] connections: tuple[SolverConnectionInput, ...] 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]: return model.model_dump(mode="json", exclude_none=True) def reactflow_project_storage_data( project: ReactFlowProjectPayload, ) -> dict[str, Any]: """Serialize a project without changing model or parameter semantics.""" return pydantic_to_jsonable(project) @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 = [ { "id": path.stem, "updatedAt": datetime.fromtimestamp( path.stat().st_mtime, timezone.utc, ).isoformat(), } for path in sorted(PROJECT_STORAGE_DIR.glob("*.json")) ] 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.") try: raw_data = json.loads(path.read_text(encoding="utf-8")) ReactFlowProjectPayload.model_validate(raw_data) except (OSError, UnicodeError, json.JSONDecodeError, ValidationError) as exc: raise HTTPException( status_code=422, detail=( f"Stored project '{path.stem}' does not satisfy project schema 1: " f"{exc}" ), ) from exc if not isinstance(raw_data, dict): # Covered by model validation; narrows the type. raise HTTPException(status_code=422, detail="Stored project must be an object.") return raw_data @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 = reactflow_project_storage_data(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(), "model": document.as_dict(), } @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) network = _compile_xml_document_or_422(document) return { "success": True, "validation": report.as_dict(), "simulation": document.as_model_data()["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", "正在编译组件与连接关系") 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 = document.simulation.t_start + bounded_progress * ( document.simulation.t_stop - document.simulation.t_start ) emit( round(100 * bounded_progress), phase, SIMULATION_PHASE_MESSAGES.get(phase, "正在运行仿真"), simulated_time, document.simulation.t_stop, ) try: system = GenericFluidSystem(network) result = system.simulate( SolveIVPConfig( t_start=document.simulation.t_start, t_stop=document.simulation.t_stop, method=document.simulation.method, # The pressure-flow closure is solved to a scaled 1e-7 # residual. State-specific mechanical absolute tolerances now # keep ideal-stop Jacobian perturbations stable, so the outer # integrator can use its canonical 1e-6 relative accuracy. rtol=1.0e-6, max_step=document.simulation.max_step, ), sample_step=document.simulation.sample_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": document.as_model_data()["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, ) -> "SimulationNetwork": try: network = compile_system_xml_network(document) 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 network def validate_reactflow_component_contract( node: ReactFlowNodePayload, component_spec: "ComponentModelSpec", ) -> dict[str, float]: """Validate the persisted model contract before consuming current defaults.""" if ( node.data.componentType != node.data.modelType or node.data.modelType != component_spec.model_type ): raise ValueError( f"COMPONENT_MODEL_TYPE_MISMATCH: Component '{node.id}' declares " f"componentType '{node.data.componentType}' and modelType " f"'{node.data.modelType}', expected both to be " f"'{component_spec.model_type}'." ) declared_version = node.data.modelVersion if declared_version is None or not declared_version.strip(): raise ValueError( f"COMPONENT_MODEL_VERSION_MISSING: Component '{node.id}' must declare " f"modelVersion '{component_spec.model_version}' before it can execute." ) if declared_version != component_spec.model_version: raise ValueError( f"COMPONENT_MODEL_VERSION_MISMATCH: Component '{node.id}' declares " f"modelVersion '{declared_version}', expected " f"'{component_spec.model_version}'." ) validate_component_port_interface(node, component_spec.ports) unknown_parameters = set(node.data.parameters) - set( component_spec.parameter_by_name ) if unknown_parameters: raise ValueError( f"Component '{node.id}' contains unsupported parameters: " + ", ".join(sorted(unknown_parameters)) + "." ) parameter_values: dict[str, float] = {} for parameter in component_spec.parameters: value = parameter_float(node, parameter.name, parameter.default) validation_message = parameter.validation_message(value) if validation_message is not None: raise ValueError( f"Parameter '{parameter.name}' on component '{node.id}' " f"{validation_message}." ) parameter_values[parameter.name] = value return parameter_values def validate_reactflow_execution_contract( project: ReactFlowProjectPayload, ) -> None: from app.simulation.registry import get_component_model_spec component_ids: set[str] = set() for node in project.nodes: if node.id in component_ids: raise ValueError(f"COMPONENT_ID_DUPLICATE: {node.id}.") component_ids.add(node.id) validate_reactflow_component_contract( node, get_component_model_spec(node.data.modelType), ) def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: from app.simulation.registry import get_component_model_spec system_attributes = { "schemaVersion": SYSTEM_XML_SCHEMA_VERSION, "unitSystem": SYSTEM_XML_UNIT_SYSTEM, } if project.name.strip(): system_attributes["name"] = project.name.strip() system = ET.Element("System", system_attributes) ET.SubElement( system, "Simulation", { "tStart": str(project.simulation.t_start), "tStop": str(project.simulation.t_stop), "sampleStep": 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], "PortDefinition"] = {} component_ids: set[str] = set() connection_ids: set[str] = set() for node in project.nodes: if node.id in component_ids: raise ValueError(f"COMPONENT_ID_DUPLICATE: {node.id}.") component_ids.add(node.id) component_spec = get_component_model_spec(node.data.modelType) parameter_values = validate_reactflow_component_contract( node, component_spec, ) component_node = ET.SubElement( components_node, "Component", { "id": node.id, "type": node.data.modelType, "modelVersion": node.data.modelVersion or "", }, ) for port_definition in component_spec.active_ports(parameter_values): 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 for parameter in component_spec.parameters: ET.SubElement( component_node, "Parameter", { "name": parameter.name, "value": str(parameter_values[parameter.name]), }, ) for edge in project.edges: if edge.id in connection_ids: raise ValueError(f"CONNECTION_ID_DUPLICATE: {edge.id}.") connection_ids.add(edge.id) 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}, ) endpoints = [(edge.source, first), (edge.target, second)] if first.kind == "signal": if first.nominal_role == "input": endpoints.reverse() for component_id, port in endpoints: ET.SubElement( connection_node, "Endpoint", {"component": component_id, "port": port.name}, ) ET.indent(system, space=" ") xml = ET.tostring(system, encoding="utf-8", xml_declaration=True) report = validate_system_xml_document(xml) if not report.valid: errors = [ f"{issue.code}: {issue.message}" for issue in report.issues if issue.severity == "error" ] raise ValueError( "Generated System XML does not satisfy the v3 contract: " + "; ".join(errors) ) return xml def require_connection_port( port_index: dict[tuple[str, str], "PortDefinition"], component_id: str, port_name: str | None, connection_id: str, ) -> "PortDefinition": 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: "PortDefinition", second: "PortDefinition", 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.nominal_role, second.nominal_role} != { "input", "output", }: raise ValueError( f"Signal connection {connection_id} must connect one output to one input." ) def compile_reactflow_network( project: ReactFlowProjectPayload, *, amesim_gas_registry: "AmesimGasRegistry | None" = None, ) -> "SimulationNetwork": return _compile_solver_network( _solver_model_from_reactflow(project), amesim_gas_registry=amesim_gas_registry, ) def compile_system_xml_network( document: SystemXmlDocument, *, amesim_gas_registry: "AmesimGasRegistry | None" = None, ) -> "SimulationNetwork": """Compile validated v3 XML without recreating editor/ReactFlow state.""" model = SolverModelInput( name=document.name or "untitled", components=tuple( SolverComponentInput( id=component.id, model_type=component.model_type, model_version=component.model_version, parameters={ parameter.name: parameter.value for parameter in component.parameters }, ) for component in document.components ), connections=tuple( SolverConnectionInput( id=connection.id, endpoint_a_component=connection.endpoints[0].component, endpoint_a_port=connection.endpoints[0].port, endpoint_b_component=connection.endpoints[1].component, endpoint_b_port=connection.endpoints[1].port, ) for connection in document.connections ), ) return _compile_solver_network( model, amesim_gas_registry=amesim_gas_registry, ) def _solver_model_from_reactflow( project: ReactFlowProjectPayload, ) -> SolverModelInput: from app.simulation.registry import get_component_model_spec components: list[SolverComponentInput] = [] for node in project.nodes: spec = get_component_model_spec(node.data.modelType) parameter_values = validate_reactflow_component_contract(node, spec) components.append( SolverComponentInput( id=node.id, model_type=node.data.modelType, model_version=node.data.modelVersion, parameters=parameter_values, ) ) return SolverModelInput( name=project.name, components=tuple(components), connections=tuple( SolverConnectionInput( id=edge.id, endpoint_a_component=edge.source, endpoint_a_port=edge.sourceHandle or "", endpoint_b_component=edge.target, endpoint_b_port=edge.targetHandle or "", ) for edge in project.edges ), ) def _compile_solver_network( model: SolverModelInput, *, amesim_gas_registry: "AmesimGasRegistry | None" = None, ) -> "SimulationNetwork": from app.simulation.components.amesim.gases import ( AMESIM_BUILTIN_AIR_GAS_INDEX, default_amesim_gas_registry, ) from app.simulation.components.amesim.media.properties import ( AmesimGasMediumDefinitionComponent, ) from app.simulation.registry import get_component_model_spec from app.simulation.systems.network import SimulationNetwork if amesim_gas_registry is None: amesim_gas_registry = default_amesim_gas_registry() else: amesim_gas_registry = amesim_gas_registry.copy() network = SimulationNetwork(name=model.name) resolved_nodes = [] specs_by_component_id = {} gas_indices_by_component_id = {} component_ids: set[str] = set() # Phase 1: normalize every node and consume compile-time medium definitions. # A definition node represents one complete property-method instance and is # intentionally not added to the equation network. for node in model.components: if node.id in component_ids: raise ValueError(f"Duplicate component id: {node.id}.") component_ids.add(node.id) spec = get_component_model_spec(node.model_type) if node.model_version is None or not node.model_version.strip(): raise ValueError( f"COMPONENT_MODEL_VERSION_MISSING: Component '{node.id}' must " f"declare modelVersion '{spec.model_version}' before it can execute." ) if node.model_version != spec.model_version: raise ValueError( f"COMPONENT_MODEL_VERSION_MISMATCH: Component '{node.id}' declares " f"modelVersion '{node.model_version}', expected " f"'{spec.model_version}'." ) parameter_values = dict(node.parameters) if issubclass( spec.component_class, AmesimGasMediumDefinitionComponent, ): definition_component = spec.create( node.id, amesim_gas_registry.resolve(AMESIM_BUILTIN_AIR_GAS_INDEX), parameter_values, ) assert isinstance( definition_component, AmesimGasMediumDefinitionComponent, ) try: amesim_gas_registry.register( definition_component.gas_definition() ) except ValueError as exc: raise ValueError( f"AMESim medium definition component '{node.id}' is invalid: " f"{exc}" ) from exc continue resolved_nodes.append((node, spec, parameter_values)) specs_by_component_id[node.id] = spec gas_indices_by_component_id[node.id] = ( parameter_values["gi"] if spec.library.id == "amesim" and "gi" in spec.parameter_by_name else None ) # Phase 2: resolve one gas property model for each pneumatic circuit before # any dynamic component initializes its thermodynamic state. pneumatic_connections = [] for edge in model.connections: source_spec = specs_by_component_id.get(edge.endpoint_a_component) target_spec = specs_by_component_id.get(edge.endpoint_b_component) if source_spec is None or target_spec is None: continue source_ports = {port.name: port for port in source_spec.ports} target_ports = {port.name: port for port in target_spec.ports} source_port = source_ports.get(edge.endpoint_a_port) target_port = target_ports.get(edge.endpoint_b_port) if ( source_port is not None and target_port is not None and source_port.domain == "pneumatic" and target_port.domain == "pneumatic" ): pneumatic_connections.append( (edge.endpoint_a_component, edge.endpoint_b_component) ) media_by_component_id = amesim_gas_registry.resolve_network_media( gas_indices_by_component_id, pneumatic_connections, ) for node, spec, parameter_values in resolved_nodes: component = spec.create( node.id, media_by_component_id[node.id], parameter_values, ) network.add_component(component) for edge in model.connections: network.connect( edge.endpoint_a_component, edge.endpoint_a_port, edge.endpoint_b_component, edge.endpoint_b_port, connection_id=edge.id, ) return network def validate_component_port_interface( node: ReactFlowNodePayload, component_ports: tuple["PortDefinition", ...], ) -> None: payload_ports = 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 validate_reactflow_execution_contract(project) 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 app.simulation.examples.test_mql.system import TestMqlRunConfig validate_reactflow_execution_contract(project) 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), )