完善仿真交互、结果展示与模型元数据

This commit is contained in:
ljz committed 2026-07-22 19:33:38 +08:00
1 parent f1256a121d
commit f7f1078911
26 files changed
+9042 -575

No files matched your search

+485 -2
View File
@@ -1,13 +1,24 @@
from __future__ import annotations
from collections.abc import Callable, Iterator
import csv
from dataclasses import dataclass
from datetime import datetime, timezone
import io
import json
from math import isfinite
from pathlib import Path
import queue
import re
import threading
from time import monotonic
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import quote
from uuid import uuid4
from xml.etree import ElementTree as ET
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from pydantic import BaseModel, Field
from app.system_xml import (
@@ -27,6 +38,47 @@ PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
SYSTEM_XML_SCHEMA_VERSION = "2"
SYSTEM_XML_UNIT_SYSTEM = "SI"
SimulationProgressEmitter = Callable[
[int, str, str, float | None, float | None],
None,
]
SIMULATION_PHASE_MESSAGES = {
"initializing": "正在初始化状态与方程",
"integrating": "正在进行时间积分与压力流量求解",
"postprocessing": "正在整理采样结果",
"cancelled": "正在整理已终止仿真的部分结果",
"failed": "正在整理异常终止前的部分结果",
"complete": "正在汇总仿真结果",
}
SIMULATION_STREAM_HEARTBEAT_SECONDS = 5.0
SIMULATION_TASK_RETENTION_SECONDS = 600.0
SimulationCancelReason = Literal["user", "stalled"]
SimulationTaskStatus = Literal[
"queued",
"running",
"cancelling",
"completed",
"stopped",
"stalled",
"failed",
]
@dataclass
class SimulationTaskRecord:
simulation_id: str
cancel_event: threading.Event
status: SimulationTaskStatus = "queued"
cancel_reason: SimulationCancelReason | None = None
result: dict[str, object] | None = None
error: dict[str, object] | None = None
updated_at: float = 0.0
SIMULATION_TASKS: dict[str, SimulationTaskRecord] = {}
SIMULATION_TASKS_LOCK = threading.Lock()
class ReactFlowPosition(BaseModel):
x: float = 0.0
@@ -88,6 +140,30 @@ class ReactFlowProjectPayload(BaseModel):
simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig)
class SimulationResultVariablePayload(BaseModel):
key: str
componentId: str
componentType: str
scope: Literal["component", "port"]
portName: str | None = None
name: str
label: str
quantity: str
unit: str = ""
category: str = "derived"
order: int = 0
class SimulationResultCsvPayload(BaseModel):
projectName: str = "simulation"
variables: list[SimulationResultVariablePayload] = Field(default_factory=list)
series: dict[str, list[float]] = Field(default_factory=dict)
class SimulationCancellationPayload(BaseModel):
reason: SimulationCancelReason = "user"
def pydantic_to_jsonable(model: BaseModel) -> dict[str, Any]:
if hasattr(model, "model_dump"):
return model.model_dump(mode="json")
@@ -164,6 +240,92 @@ def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
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)
@@ -258,8 +420,158 @@ async def compile_system_xml_model(request: Request) -> dict[str, object]:
}
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 PythonModels.core.algebraic import AlgebraicSolveError
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.core.stream import StreamSolveError
@@ -268,9 +580,42 @@ async def simulate_system_xml(request: Request) -> dict[str, object]:
SimulationPreparationError,
)
report = validate_system_xml_document(await request.body())
def emit(
progress: int,
phase: str,
message: str,
simulated_time: float | None = None,
total_time: float | None = None,
) -> None:
if progress_callback is not None:
progress_callback(
progress,
phase,
message,
simulated_time,
total_time,
)
emit(0, "validation", "正在校验 System XML")
report = validate_system_xml_document(xml_bytes)
document = _validated_xml_document_or_422(report)
emit(0, "compilation", "正在编译组件与连接关系")
project, network = _compile_xml_document_or_422(document)
emit(0, "initialization", "模型编译完成,正在准备求解器")
def report_system_progress(progress: float, phase: str) -> None:
bounded_progress = min(1.0, max(0.0, progress))
simulated_time = project.simulation.t_start + bounded_progress * (
project.simulation.t_stop - project.simulation.t_start
)
emit(
round(100 * bounded_progress),
phase,
SIMULATION_PHASE_MESSAGES.get(phase, "正在运行仿真"),
simulated_time,
project.simulation.t_stop,
)
try:
system = GenericFluidSystem(network)
result = system.simulate(
@@ -281,6 +626,8 @@ async def simulate_system_xml(request: Request) -> dict[str, object]:
max_step=project.simulation.max_step,
),
sample_step=project.simulation.step,
progress_callback=report_system_progress,
cancel_check=cancel_check,
)
except SimulationPreparationError as exc:
raise HTTPException(
@@ -353,6 +700,142 @@ async def simulate_system_xml(request: Request) -> dict[str, object]:
}
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: