1561 lines
54 KiB
Python
1561 lines
54 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, StrictFloat, StrictStr, ValidationError
|
|
|
|
from app.project_parameters import prepare_project, version_warning
|
|
|
|
from app.simulation.performance import performance_span, profile_phase, profile_run
|
|
from app.simulation.native_codegen.transport import NativeSeriesJson, serialize_result_parts
|
|
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 SimulationRuntimeCheck
|
|
|
|
application.state.simulation_numeric_engine = numeric_engine_name()
|
|
logging.getLogger("uvicorn.error").info(
|
|
"Simulation numeric engine: %s", application.state.simulation_numeric_engine,
|
|
)
|
|
runtime_check = SimulationRuntimeCheck()
|
|
application.state.simulation_runtime_check = runtime_check
|
|
runtime_check.start()
|
|
try:
|
|
yield
|
|
finally:
|
|
runtime_check.close()
|
|
|
|
|
|
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": "正在初始化状态与方程",
|
|
"native-generation": "正在生成原生模型代码",
|
|
"native-cache-check": "正在检查已编译模型缓存",
|
|
"native-cache-hit": "已复用模型缓存,正在启动求解器",
|
|
"native-compilation": "正在编译模型与缺失的原生模块",
|
|
"native-linking": "正在链接模型可执行文件",
|
|
"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. Input adapters warn and select the current model;
|
|
# the normalized XML/numerical boundary still enforces an exact match.
|
|
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: StrictFloat | StrictStr = 0.0
|
|
t_stop: StrictFloat | StrictStr = 2.0
|
|
step: StrictFloat | StrictStr = 0.1
|
|
max_step: StrictFloat | StrictStr = 0.005
|
|
method: str = "BDF"
|
|
|
|
|
|
class ReactFlowProjectPayload(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
projectSchemaVersion: Literal[1, 2]
|
|
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.get("/api/simulation/runtime-check")
|
|
def get_simulation_runtime_check(request: Request) -> Response:
|
|
report = request.app.state.simulation_runtime_check.snapshot()
|
|
return Response(content=json.dumps(report, ensure_ascii=False), media_type="application/json",
|
|
headers={"Cache-Control": "no-store"})
|
|
|
|
|
|
@app.post("/api/reactflow/system-xml")
|
|
def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
|
|
try:
|
|
normalized, notices = prepare_project(payload)
|
|
xml = build_reactflow_system_xml(normalized)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
headers = {}
|
|
if notices:
|
|
warning = {**version_warning(notices), "totalCount": len(notices), "components": notices[:10]}
|
|
encoded = quote(json.dumps(warning, ensure_ascii=False))
|
|
while len(encoded) > 3800 and warning["components"]:
|
|
warning["components"] = warning["components"][:-1]
|
|
encoded = quote(json.dumps(warning, ensure_ascii=False))
|
|
headers["X-Component-Version-Warnings"] = encoded
|
|
return Response(content=xml, media_type="application/xml", headers=headers)
|
|
|
|
|
|
@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:
|
|
normalized, notices = prepare_project(payload)
|
|
network = compile_reactflow_network(normalized)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
return {"success": True, **network.as_interface_dict(), "warnings": [version_warning(notices)] if notices else []}
|
|
|
|
|
|
@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, raw_series=True),
|
|
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}", response_model=None)
|
|
def get_system_xml_simulation(simulation_id: str) -> dict[str, object] | Response:
|
|
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.")
|
|
snapshot = _simulation_task_snapshot(task)
|
|
result = snapshot.get("result")
|
|
if isinstance(result, dict) and isinstance(result.get("series"), NativeSeriesJson):
|
|
return StreamingResponse(iter(serialize_result_parts(snapshot)), media_type="application/json")
|
|
return snapshot
|
|
|
|
|
|
def run_system_xml_simulation(
|
|
xml_bytes: bytes,
|
|
progress_callback: SimulationProgressEmitter | None = None,
|
|
cancel_check: Callable[[], bool] | None = None,
|
|
activity_tracker: SolverActivityTracker | None = None,
|
|
*, raw_series: bool = False,
|
|
) -> dict[str, object]:
|
|
with profile_run() as trace:
|
|
result = _run_system_xml_simulation_profiled(
|
|
xml_bytes,
|
|
progress_callback,
|
|
cancel_check,
|
|
activity_tracker,
|
|
raw_series=raw_series,
|
|
)
|
|
|
|
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,
|
|
*, raw_series: bool = False,
|
|
) -> 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,
|
|
raw_series=raw_series,
|
|
)
|
|
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(raw_series=raw_series),
|
|
}
|
|
|
|
|
|
def simulation_event_stream(
|
|
xml_bytes: bytes,
|
|
*,
|
|
task: SimulationTaskRecord | None = None,
|
|
raw_series: bool = False,
|
|
) -> Iterator[str | bytes]:
|
|
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,
|
|
**({"raw_series": True} if raw_series else {}),
|
|
)
|
|
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
|
|
if raw_series and isinstance(event, dict) and event.get("event") == "result":
|
|
yield from serialize_result_parts(event)
|
|
yield b"\n"
|
|
else:
|
|
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 normalized SI 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
|
|
|
|
validate_si_project(project)
|
|
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,
|
|
f"{edge.source}.{first.name}", f"{edge.target}.{second.name}")
|
|
|
|
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,
|
|
first_label: str | None = None,
|
|
second_label: str | None = None,
|
|
) -> None:
|
|
from app.simulation.core.port_computation import PortSupplyError, port_supply_issue
|
|
|
|
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.")
|
|
supply_issue = port_supply_issue(first, second, first_label, second_label)
|
|
if supply_issue:
|
|
raise PortSupplyError(supply_issue)
|
|
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
|
|
|
|
validate_si_project(project)
|
|
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,
|
|
)
|
|
network.validate_port_supplies()
|
|
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:
|
|
if type(value) in (int, float) and isfinite(value):
|
|
return float(value)
|
|
except OverflowError:
|
|
pass
|
|
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be a finite SI number; normalize expressions before execution.")
|
|
|
|
|
|
def validate_si_project(project: ReactFlowProjectPayload) -> None:
|
|
"""Guard the editor-independent numerical boundary, including time settings."""
|
|
if project.projectSchemaVersion != 1:
|
|
raise ValueError("Execution requires normalized SI data; preprocess project input first.")
|
|
for name in ("t_start", "t_stop", "step", "max_step"):
|
|
value = getattr(project.simulation, name)
|
|
if type(value) not in (int, float) or not isfinite(value):
|
|
raise ValueError(f"Simulation {name} must be a finite SI number.")
|