完善仿真交互、结果展示与模型元数据
This commit is contained in:
1 parent
f1256a121d
commit
f7f1078911
26 files changed
+9042
-575
No files matched your search
+485
-2
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user