Files
SystemSimulationApp/app/main.py
T

1494 lines
50 KiB
Python

from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
from contextlib import asynccontextmanager
import csv
from dataclasses import dataclass, field
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.simulation.performance import performance_span, profile_phase, profile_run
from app.simulation.config import SolverActivityTracker
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
@asynccontextmanager
async def _app_lifespan(application: FastAPI) -> AsyncIterator[None]:
import logging
from app.simulation.backends import numeric_engine_name
from app.simulation.warmup import warm_up_simulation_runtime
application.state.simulation_numeric_engine = numeric_engine_name()
logging.getLogger("uvicorn.error").info(
"Simulation numeric engine: %s", application.state.simulation_numeric_engine,
)
application.state.simulation_warmup = warm_up_simulation_runtime().as_dict()
yield
app = FastAPI(
title="System Simulation ReactFlow App",
lifespan=_app_lifespan,
)
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
activity_tracker: SolverActivityTracker = field(
default_factory=SolverActivityTracker
)
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(
"""
<!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 = [
{
"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,
**task.activity_tracker.snapshot().as_dict(),
}
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,
activity_tracker: SolverActivityTracker | None = None,
) -> dict[str, object]:
with profile_run() as trace:
result = _run_system_xml_simulation_profiled(
xml_bytes,
progress_callback,
cancel_check,
activity_tracker,
)
performance = trace.snapshot()
if performance.get("mode") != "off":
diagnostics = dict(result.get("diagnostics", {}))
diagnostics["performance"] = performance
result["diagnostics"] = diagnostics
return result
def _run_system_xml_simulation_profiled(
xml_bytes: bytes,
progress_callback: SimulationProgressEmitter | None = None,
cancel_check: Callable[[], bool] | None = None,
activity_tracker: SolverActivityTracker | None = None,
) -> dict[str, object]:
from app.simulation.backends import simulate_network
from app.simulation.results import 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:
result = simulate_network(
network,
document.simulation,
progress_callback=report_system_progress,
cancel_check=cancel_check,
activity_tracker=activity_tracker,
)
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 (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
with performance_span("simulation.response_assembly"):
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
activity_tracker = (
task.activity_tracker if task is not None else SolverActivityTracker()
)
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
event.update(activity_tracker.snapshot().as_dict())
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,
activity_tracker,
)
if task is not None:
result = _mark_simulation_task_result(task, result)
result_status = str(result.get("status", "completed"))
final_simulated_time = result.get("simulatedUntil")
final_activity_kind = (
"complete" if result_status == "completed" else result_status
)
if activity_tracker.snapshot().activity_kind != final_activity_kind:
activity_tracker.record_phase(
final_activity_kind,
(
float(final_simulated_time)
if isinstance(final_simulated_time, (int, float))
and isfinite(final_simulated_time)
else None
),
)
result_messages = {
"completed": "仿真完成",
"stopped": "仿真已由用户终止,已保留部分结果",
"stalled": "仿真因进度连接异常而终止,已保留部分结果",
"failed": "仿真异常终止,已保留可用的部分结果",
}
result_event = {
"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,
}
result_event.update(activity_tracker.snapshot().as_dict())
events.put(result_event)
except HTTPException as exc:
activity_tracker.record_phase("failed")
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,
}
error_event.update(activity_tracker.snapshot().as_dict())
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
activity_tracker.record_phase("failed")
error_event = {
"event": "error",
"progress": latest_progress,
"phase": "failed",
"status": 500,
"message": "仿真服务发生未预期错误。",
"detail": str(exc),
}
error_event.update(activity_tracker.snapshot().as_dict())
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:
heartbeat_event = {
"event": "progress",
"progress": latest_progress,
"phase": latest_phase,
"message": latest_message,
"heartbeat": True,
"simulatedTime": latest_simulated_time,
"totalTime": latest_total_time,
}
heartbeat_event.update(activity_tracker.snapshot().as_dict())
yield json.dumps(
heartbeat_event,
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 '<empty>'}."
)
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,
)
@profile_phase("simulation.network_compilation")
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]:
raise HTTPException(status_code=410, detail="The legacy Python example runner has been removed. Export System XML and use /api/system-xml/simulate.")
def run_reactflow_test_mql(project: ReactFlowProjectPayload) -> dict[str, object]:
raise HTTPException(status_code=410, detail="The legacy Python example runner has been removed. Export System XML and use /api/system-xml/simulate.")
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.")