diff --git a/.gitignore b/.gitignore index 6e87027..5f91e45 100644 --- a/.gitignore +++ b/.gitignore @@ -12,11 +12,13 @@ htmlcov/ # Local virtual environments .venv/ .venv-win/ -PythonModels/runs/ app/data/ frontend/node_modules/ frontend/dist/ frontend/.vite/ +frontend/test-results/ +frontend/playwright-report/ +frontend/blob-report/ venv/ env/ diff --git a/PythonModels/core/solver.py b/PythonModels/core/solver.py index ce755d9..681886c 100644 --- a/PythonModels/core/solver.py +++ b/PythonModels/core/solver.py @@ -1,7 +1,16 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Callable +from typing import Callable, Literal + + +CancellationCheck = Callable[[], bool] +AcceptedStepCallback = Callable[[float], None] +IntegrationStatus = Literal["completed", "cancelled", "failed"] + + +class _IntegrationCancelled(Exception): + pass @dataclass(frozen=True) @@ -21,17 +30,34 @@ class ODESolution: y: list[list[float]] success: bool message: str + status: IntegrationStatus = "completed" + error: Exception | None = None def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]: return [x + scale * y for x, y in zip(a, b)] +def _append_solution_sample( + times: list[float], + states: list[list[float]], + time: float, + state: list[float], +) -> None: + if times and time <= times[-1] + 1e-12: + return + times.append(float(time)) + for index, value in enumerate(state): + states[index].append(float(value)) + + def _runge_kutta_4( rhs: Callable[[float, list[float]], list[float]], initial_state: list[float], config: SolveIVPConfig, t_eval: list[float] | None, + cancel_check: CancellationCheck | None = None, + accepted_step_callback: AcceptedStepCallback | None = None, ) -> ODESolution: if t_eval is None: point_count = max( @@ -45,29 +71,195 @@ def _runge_kutta_4( states = [[value] for value in state] times = [float(t_eval[0])] current_time = float(t_eval[0]) + status: IntegrationStatus = "completed" + message = "Integrated with built-in RK4 fallback because SciPy is unavailable." + error: Exception | None = None - for target_time in t_eval[1:]: - while current_time < target_time - 1e-15: - dt = min(config.max_step, target_time - current_time) - k1 = rhs(current_time, state) - k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt)) - k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt)) - k4 = rhs(current_time + dt, _vector_add(state, k3, dt)) - state = [ - value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d) - for value, a, b, c, d in zip(state, k1, k2, k3, k4) - ] - current_time += dt + try: + for target_time in t_eval[1:]: + while current_time < target_time - 1e-15: + if cancel_check is not None and cancel_check(): + raise _IntegrationCancelled + dt = min(config.max_step, target_time - current_time) + k1 = rhs(current_time, state) + k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt)) + k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt)) + k4 = rhs(current_time + dt, _vector_add(state, k3, dt)) + state = [ + value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d) + for value, a, b, c, d in zip(state, k1, k2, k3, k4) + ] + current_time += dt + if accepted_step_callback is not None: + accepted_step_callback(current_time) - times.append(float(target_time)) - for index, value in enumerate(state): - states[index].append(value) + _append_solution_sample(times, states, target_time, state) + except _IntegrationCancelled: + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + _append_solution_sample(times, states, current_time, state) + except Exception as exc: + status = "failed" + message = str(exc) + error = exc + _append_solution_sample(times, states, current_time, state) return ODESolution( t=times, y=states, - success=True, - message="Integrated with built-in RK4 fallback because SciPy is unavailable.", + success=status == "completed", + message=message, + status=status, + error=error, + ) + + +def _integrate_scipy_stepwise( + rhs: Callable[[float, list[float]], list[float]], + initial_state: list[float], + config: SolveIVPConfig, + t_eval: list[float] | None, + cancel_check: CancellationCheck, + accepted_step_callback: AcceptedStepCallback | None, +) -> ODESolution: + import numpy as np + from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau + + solver_types = { + "BDF": BDF, + "DOP853": DOP853, + "LSODA": LSODA, + "RK23": RK23, + "RK45": RK45, + "Radau": Radau, + } + solver_type = solver_types.get(config.method) + if solver_type is None: + raise ValueError(f"Unsupported integration method: {config.method}") + + times = [float(config.t_start)] + states = [[float(value)] for value in initial_state] + last_accepted_time = float(config.t_start) + last_accepted_state = [float(value) for value in initial_state] + sample_times = list(t_eval or []) + sample_index = 0 + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= config.t_start + 1e-12 + ): + sample_index += 1 + + def cancellable_rhs(time, state): + if cancel_check(): + raise _IntegrationCancelled + return rhs(float(time), [float(value) for value in state]) + + if cancel_check(): + return ODESolution( + t=times, + y=states, + success=False, + message="Simulation was stopped before integration started.", + status="cancelled", + ) + + solver_options = { + "rtol": config.rtol, + "atol": config.atol, + "max_step": config.max_step, + } + if config.first_step is not None: + solver_options["first_step"] = config.first_step + + try: + solver = solver_type( + cancellable_rhs, + config.t_start, + np.asarray(initial_state, dtype=float), + config.t_stop, + **solver_options, + ) + except _IntegrationCancelled: + return ODESolution( + t=times, + y=states, + success=False, + message="Simulation was stopped before integration started.", + status="cancelled", + ) + except Exception as exc: + return ODESolution( + t=times, + y=states, + success=False, + message=str(exc), + status="failed", + error=exc, + ) + + status: IntegrationStatus = "completed" + message = "The solver successfully reached the end of the integration interval." + error: Exception | None = None + + while solver.status == "running": + if cancel_check(): + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + break + try: + step_message = solver.step() + except _IntegrationCancelled: + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + break + except Exception as exc: + status = "failed" + message = str(exc) + error = exc + break + + if solver.status == "failed": + status = "failed" + message = str(step_message or "Integration step failed.") + break + + last_accepted_time = float(solver.t) + last_accepted_state = [float(value) for value in solver.y] + if sample_times: + dense_output = solver.dense_output() + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= last_accepted_time + 1e-12 + ): + sample_time = float(sample_times[sample_index]) + sample_state = [float(value) for value in dense_output(sample_time)] + _append_solution_sample(times, states, sample_time, sample_state) + sample_index += 1 + else: + _append_solution_sample( + times, + states, + last_accepted_time, + last_accepted_state, + ) + if accepted_step_callback is not None: + accepted_step_callback(last_accepted_time) + + if status != "completed": + _append_solution_sample( + times, + states, + last_accepted_time, + last_accepted_state, + ) + + return ODESolution( + t=times, + y=states, + success=status == "completed", + message=message, + status=status, + error=error, ) @@ -76,6 +268,8 @@ def integrate_ode( initial_state: list[float], config: SolveIVPConfig, t_eval: list[float] | None = None, + cancel_check: CancellationCheck | None = None, + accepted_step_callback: AcceptedStepCallback | None = None, ): """Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback.""" @@ -90,7 +284,24 @@ def integrate_ode( try: from scipy.integrate import solve_ivp except ImportError: - return _runge_kutta_4(rhs, initial_state, config, t_eval) + return _runge_kutta_4( + rhs, + initial_state, + config, + t_eval, + cancel_check, + accepted_step_callback, + ) + + if cancel_check is not None: + return _integrate_scipy_stepwise( + rhs, + initial_state, + config, + t_eval, + cancel_check, + accepted_step_callback, + ) solve_options = { "fun": rhs, diff --git a/README.md b/README.md index e0f61a4..9481762 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,37 @@ # SystemSimulationApp -本仓库用于把 Modelica 和 AMESim 系统模型迁移到可运行、可测试、可与原模型结果对比的 Python 仿真平台。 +ReactFlow 系统建模与 `app.simulation` 仿真后端。 -## 当前模型 +## 后端接口 -- `Testmodel`:旧 Modelica 迁移模型,已有可运行的 ODE 近似、结果导出和 OpenModelica 对比能力。 -- `test_mql`:当前开发主线,从 `AmesimModels/test_mql.ame` 迁移到 `PythonModels`。 +- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。 +- `POST /api/reactflow/system-xml`:导出 System XML v2。 +- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。 +- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。 +- `POST /api/reactflow/simulate-test-mql`:运行现有固定拓扑 AMESim `test_mql` 迁移模型;该接口不把 AMESim 子模型注册为公开拖拽组件。 +- `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。 +- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。 +- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。 +- `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。 +- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。 -`test_mql` 已完成 112 个气动状态和 20 个机械状态组成的 132 状态总闭包,可以短时积分并按 AMESim `Data_Path` 生成 comparison 报告。当前工作重点已经从拓扑接入转为数值校准;尚不能宣称 Python 结果与 AMESim 完全一致。 +气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。 -## 目录 +当前网络层可以从组件和连接生成压力-流量残差,使用 SciPy 完成非线性代数闭合和时间积分,并按实际流向传播 stream 焓。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点。它不是完整 DAE 求解器,也不等价于严格 Modelica.Fluid 实现。 -- `AmesimModels/`:AMESim 源模型及模型级迁移说明。 -- `PythonModels/core/`:状态、网络、介质和求解器基础设施。 -- `PythonModels/components/`:气动、机械及通用组件方程。 -- `PythonModels/systems/`:系统装配、closure、snapshot、端口写回和 RHS。 -- `PythonModels/reporting/`:AMESim 结果读取、schema validation 和误差比较。 -- `PythonModels/scripts/`:仿真与 comparison 命令行入口。 -- `tests/`:组件、闭包、运行入口和回归测试。 +XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用: -## 运行 test_mql - -运行默认的 132 状态短时域 AMESim 对比: - -```bash -python3 -m PythonModels.scripts.run_test_mql_full_state_comparison +```powershell +.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt ``` -运行全量测试: +## 文档 -```bash -python3 -m unittest discover -s tests -t . -``` - -详细迁移说明见 `AmesimModels/test_mql/README.md`,Python 平台说明见 `PythonModels/README.md`。本机开发恢复记录保存在根目录 `STATUS.md`;该文件被 `.git/info/exclude` 忽略,不会随正常提交推送。 +- [开发文档索引](docs/README.md) +- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md) +- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md) +- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json) +- [System XML v2 协议](docs/system-xml-v2.md) +- [System XML v2 XSD](schemas/system-simulation-v2.xsd) +- [System XML v1 协议(旧版)](docs/system-xml-v1.md) +- [System XML v1 XSD(旧版)](schemas/system-simulation-v1.xsd) diff --git a/app/main.py b/app/main.py index 281e3b9..c722a86 100644 --- a/app/main.py +++ b/app/main.py @@ -1,19 +1,83 @@ 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 -from typing import Any +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, Response -from fastapi.responses import FileResponse, HTMLResponse +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from pydantic import BaseModel, Field +from app.system_xml import ( + SystemXmlDocument, + SystemXmlValidationReport, + validate_system_xml_document, +) + +if TYPE_CHECKING: + from app.simulation.core.ports import PortDefinition + from app.simulation.systems.network import SimulationNetwork + app = FastAPI(title="System Simulation ReactFlow App") FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist" PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects" +SYSTEM_XML_SCHEMA_VERSION = "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): @@ -21,12 +85,29 @@ class ReactFlowPosition(BaseModel): 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 ReactFlowNodeData(BaseModel): label: str = "" componentType: str = "component" modelType: str = "component" - ports: list[str] = Field(default_factory=list) + ports: list[ReactFlowPortDefinition | str] = Field(default_factory=list) parameters: dict[str, Any] = Field(default_factory=dict) + rotation: Literal[0, 90, 180, 270] = 0 + mirrored: bool = False class ReactFlowNodePayload(BaseModel): @@ -59,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") @@ -126,12 +231,106 @@ def frontend_asset(path: str) -> FileResponse: 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: - return Response( - content=build_reactflow_system_xml(payload), - media_type="application/xml", + 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") @@ -189,10 +388,531 @@ def simulate_reactflow_testmodel(payload: ReactFlowProjectPayload) -> dict[str, 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(), + "project": document.as_project_data(), + } + + +@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) + project, network = _compile_xml_document_or_422(document) + return { + "success": True, + "validation": report.as_dict(), + "simulation": pydantic_to_jsonable(project.simulation), + **network.as_interface_dict(), + } + + +TERMINAL_SIMULATION_TASK_STATUSES = {"completed", "stopped", "stalled", "failed"} + + +def _register_simulation_task(simulation_id: str) -> SimulationTaskRecord: + normalized_id = simulation_id.strip() + if ( + not normalized_id + or len(normalized_id) > 128 + or re.fullmatch(r"[A-Za-z0-9._-]+", normalized_id) is None + ): + raise HTTPException(status_code=422, detail="Invalid simulation task id.") + + now = monotonic() + with SIMULATION_TASKS_LOCK: + expired_ids = [ + task_id + for task_id, task in SIMULATION_TASKS.items() + if task.status in TERMINAL_SIMULATION_TASK_STATUSES + and now - task.updated_at > SIMULATION_TASK_RETENTION_SECONDS + ] + for task_id in expired_ids: + del SIMULATION_TASKS[task_id] + if normalized_id in SIMULATION_TASKS: + raise HTTPException( + status_code=409, + detail="A simulation task with this id already exists.", + ) + task = SimulationTaskRecord( + simulation_id=normalized_id, + cancel_event=threading.Event(), + updated_at=now, + ) + SIMULATION_TASKS[normalized_id] = task + return task + + +def _simulation_task_snapshot(task: SimulationTaskRecord) -> dict[str, object]: + with SIMULATION_TASKS_LOCK: + return { + "simulationId": task.simulation_id, + "status": task.status, + "cancelReason": task.cancel_reason, + "result": task.result, + "error": task.error, + } + + +def _request_simulation_task_cancel( + task: SimulationTaskRecord, + reason: SimulationCancelReason, +) -> bool: + with SIMULATION_TASKS_LOCK: + if task.status in TERMINAL_SIMULATION_TASK_STATUSES: + return False + if task.cancel_reason is None: + task.cancel_reason = reason + task.status = "cancelling" + task.updated_at = monotonic() + task.cancel_event.set() + return True + + +def _mark_simulation_task_running(task: SimulationTaskRecord) -> None: + with SIMULATION_TASKS_LOCK: + if task.status == "queued": + task.status = "running" + task.updated_at = monotonic() + + +def _mark_simulation_task_result( + task: SimulationTaskRecord, + result: dict[str, object], +) -> dict[str, object]: + with SIMULATION_TASKS_LOCK: + result_status = result.get("status") + if result_status == "cancelled": + public_status: SimulationTaskStatus = ( + "stalled" if task.cancel_reason == "stalled" else "stopped" + ) + elif result_status == "completed" and bool(result.get("success")): + public_status = "completed" + else: + public_status = "failed" + result["status"] = public_status + result["partial"] = public_status != "completed" + task.status = public_status + task.result = result + task.updated_at = monotonic() + return result + + +def _mark_simulation_task_error( + task: SimulationTaskRecord, + error: dict[str, object], +) -> None: + with SIMULATION_TASKS_LOCK: + task.status = "failed" + task.error = error + task.updated_at = monotonic() + + +@app.post("/api/system-xml/simulate") +async def simulate_system_xml(request: Request) -> dict[str, object]: + return run_system_xml_simulation(await request.body()) + + +@app.post("/api/system-xml/simulate-stream") +async def simulate_system_xml_stream(request: Request) -> StreamingResponse: + simulation_id = request.headers.get("x-simulation-id") or uuid4().hex + task = _register_simulation_task(simulation_id) + return StreamingResponse( + simulation_event_stream(await request.body(), task=task), + media_type="application/x-ndjson", + headers={ + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Simulation-Id": task.simulation_id, + }, + ) + + +@app.post("/api/system-xml/simulations/{simulation_id}/cancel") +def cancel_system_xml_simulation( + simulation_id: str, + payload: SimulationCancellationPayload, +) -> dict[str, object]: + with SIMULATION_TASKS_LOCK: + task = SIMULATION_TASKS.get(simulation_id) + if task is None: + raise HTTPException(status_code=404, detail="Simulation task was not found.") + accepted = _request_simulation_task_cancel(task, payload.reason) + return { + "simulationId": simulation_id, + "accepted": accepted, + "status": task.status, + } + + +@app.get("/api/system-xml/simulations/{simulation_id}") +def get_system_xml_simulation(simulation_id: str) -> dict[str, object]: + with SIMULATION_TASKS_LOCK: + task = SIMULATION_TASKS.get(simulation_id) + if task is None: + raise HTTPException(status_code=404, detail="Simulation task was not found.") + return _simulation_task_snapshot(task) + + +def run_system_xml_simulation( + xml_bytes: bytes, + progress_callback: SimulationProgressEmitter | None = None, + cancel_check: Callable[[], bool] | None = None, +) -> dict[str, object]: + from app.simulation.solvers.algebraic import AlgebraicSolveError + from app.simulation.solvers.solver import SolveIVPConfig + from app.simulation.solvers.stream import StreamSolveError + from app.simulation.systems.generic import ( + GenericFluidSystem, + SimulationPreparationError, + ) + + def emit( + progress: int, + phase: str, + message: str, + simulated_time: float | None = None, + total_time: float | None = None, + ) -> None: + if progress_callback is not None: + progress_callback( + progress, + phase, + message, + simulated_time, + total_time, + ) + + emit(0, "validation", "正在校验 System XML") + report = validate_system_xml_document(xml_bytes) + document = _validated_xml_document_or_422(report) + emit(0, "compilation", "正在编译组件与连接关系") + 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( + SolveIVPConfig( + t_start=project.simulation.t_start, + t_stop=project.simulation.t_stop, + method=project.simulation.method, + 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( + status_code=422, + detail={ + "message": "The compiled model is not ready for simulation.", + "issues": [ + { + "severity": "error", + "layer": "simulation", + **issue.as_dict(), + } + for issue in exc.issues + ], + }, + ) from exc + except AlgebraicSolveError as exc: + raise HTTPException( + status_code=422, + detail={ + "message": str(exc), + "issues": [ + { + "severity": "error", + "layer": "simulation", + "code": "PRESSURE_FLOW_SOLVE_FAILED", + "message": str(exc), + } + ], + "diagnostics": exc.diagnostics.as_dict(), + }, + ) from exc + except StreamSolveError as exc: + raise HTTPException( + status_code=422, + detail={ + "message": str(exc), + "issues": [ + { + "severity": "error", + "layer": "simulation", + "code": "STREAM_SOLVE_FAILED", + "message": str(exc), + } + ], + "diagnostics": exc.diagnostics.as_dict(), + }, + ) from exc + except (RuntimeError, ValueError) as exc: + raise HTTPException( + status_code=422, + detail={ + "message": "Simulation failed while evaluating the compiled model.", + "issues": [ + { + "severity": "error", + "layer": "simulation", + "code": "SIMULATION_EXECUTION_FAILED", + "message": str(exc), + } + ], + }, + ) from exc + + return { + "validation": report.as_dict(), + "simulation": pydantic_to_jsonable(project.simulation), + "model": network.as_interface_dict(), + **result.as_dict(), + } + + +def simulation_event_stream( + xml_bytes: bytes, + *, + task: SimulationTaskRecord | None = None, +) -> Iterator[str]: + events: queue.Queue[dict[str, object] | object] = queue.Queue() + finished = object() + latest_progress = 0 + latest_phase = "queued" + latest_message = "正在等待仿真任务启动" + latest_simulated_time: float | None = None + latest_total_time: float | None = None + + def emit_progress( + progress: int, + phase: str, + message: str, + simulated_time: float | None = None, + total_time: float | None = None, + ) -> None: + nonlocal latest_message, latest_phase, latest_progress + nonlocal latest_simulated_time, latest_total_time + latest_progress = max(latest_progress, min(100, max(0, progress))) + latest_phase = phase + latest_message = message + if simulated_time is not None and isfinite(simulated_time): + latest_simulated_time = simulated_time + if total_time is not None and isfinite(total_time): + latest_total_time = total_time + event: dict[str, object] = { + "event": "progress", + "progress": latest_progress, + "phase": phase, + "message": message, + } + if latest_simulated_time is not None: + event["simulatedTime"] = latest_simulated_time + if latest_total_time is not None: + event["totalTime"] = latest_total_time + events.put(event) + + def worker() -> None: + if task is not None: + _mark_simulation_task_running(task) + try: + result = run_system_xml_simulation( + xml_bytes, + emit_progress, + task.cancel_event.is_set if task is not None else None, + ) + if task is not None: + result = _mark_simulation_task_result(task, result) + result_status = str(result.get("status", "completed")) + result_messages = { + "completed": "仿真完成", + "stopped": "仿真已由用户终止,已保留部分结果", + "stalled": "仿真因进度连接异常而终止,已保留部分结果", + "failed": "仿真异常终止,已保留可用的部分结果", + } + events.put( + { + "event": "result", + "progress": 100 if result_status == "completed" else latest_progress, + "phase": result_status, + "message": result_messages.get(result_status, "仿真任务结束"), + "simulatedTime": result.get("simulatedUntil"), + "totalTime": result.get("requestedStopTime"), + "result": result, + } + ) + except HTTPException as exc: + detail = exc.detail + message = ( + str(detail.get("message", "仿真失败")) + if isinstance(detail, dict) + else str(detail) + ) + error_event = { + "event": "error", + "progress": latest_progress, + "phase": "failed", + "status": exc.status_code, + "message": message, + "detail": detail, + } + if task is not None: + _mark_simulation_task_error(task, error_event) + events.put(error_event) + except Exception as exc: # pragma: no cover - last-resort stream guard + error_event = { + "event": "error", + "progress": latest_progress, + "phase": "failed", + "status": 500, + "message": "仿真服务发生未预期错误。", + "detail": str(exc), + } + if task is not None: + _mark_simulation_task_error(task, error_event) + events.put(error_event) + finally: + events.put(finished) + + threading.Thread( + target=worker, + name="system-simulation", + daemon=True, + ).start() + + try: + while True: + try: + event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS) + except queue.Empty: + yield json.dumps( + { + "event": "progress", + "progress": latest_progress, + "phase": latest_phase, + "message": latest_message, + "heartbeat": True, + "simulatedTime": latest_simulated_time, + "totalTime": latest_total_time, + }, + ensure_ascii=False, + separators=(",", ":"), + ) + "\n" + continue + if event is finished: + break + yield json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n" + finally: + if task is not None: + _request_simulation_task_cancel(task, "stalled") + + +def _validated_xml_document_or_422( + report: SystemXmlValidationReport, +) -> SystemXmlDocument: + if not report.valid or report.document is None: + raise HTTPException( + status_code=422, + detail={ + "message": "System XML validation failed.", + "issues": [issue.as_dict() for issue in report.issues], + }, + ) + return report.document + + +def _compile_xml_document_or_422( + document: SystemXmlDocument, +) -> tuple[ReactFlowProjectPayload, "SimulationNetwork"]: + project = ReactFlowProjectPayload(**document.as_project_data()) + try: + network = compile_reactflow_network(project) + 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 project, network + + def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: - system = ET.Element("System", {"name": project.name}) + system = ET.Element( + "System", + { + "name": project.name, + "schemaVersion": SYSTEM_XML_SCHEMA_VERSION, + "unitSystem": SYSTEM_XML_UNIT_SYSTEM, + }, + ) + ET.SubElement( + system, + "Simulation", + { + "tStart": str(project.simulation.t_start), + "tStop": str(project.simulation.t_stop), + "step": 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], ReactFlowPortDefinition] = {} for node in project.nodes: component_node = ET.SubElement( @@ -205,10 +925,34 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: "componentType": node.data.componentType, "x": f"{node.position.x:g}", "y": f"{node.position.y:g}", + "rotation": str(node.data.rotation), + "mirrored": str(node.data.mirrored).lower(), }, ) - for port in node.data.ports: - ET.SubElement(component_node, "Port", {"name": port}) + for index, port in enumerate(node.data.ports): + port_definition = normalize_port_definition( + port, + index=index, + component_type=node.data.componentType, + ) + 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 + port_attributes = { + "name": port_definition.name, + "kind": port_definition.kind, + "domain": port_definition.domain, + "nominalRole": port_definition.nominalRole, + "side": port_definition.side, + } + if port_definition.kind == "physical": + port_attributes["positiveFlowDirection"] = ( + port_definition.positiveFlowDirection or "intoComponent" + ) + ET.SubElement(component_node, "Port", port_attributes) for name, value in node.data.parameters.items(): ET.SubElement( component_node, @@ -217,22 +961,200 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: ) for edge in project.edges: - ET.SubElement( + 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, - "source": edge.source, - "sourcePort": edge.sourceHandle or "", - "target": edge.target, - "targetPort": edge.targetHandle or "", + "kind": first.kind, + "domain": first.domain, }, ) + endpoints = [ + (edge.source, first, None), + (edge.target, second, None), + ] + if first.kind == "signal": + if first.nominalRole == "input": + endpoints.reverse() + endpoints = [ + (endpoints[0][0], endpoints[0][1], "source"), + (endpoints[1][0], endpoints[1][1], "target"), + ] + for component_id, port, role in endpoints: + attributes = {"component": component_id, "port": port.name} + if role is not None: + attributes["role"] = role + ET.SubElement(connection_node, "Endpoint", attributes) ET.indent(system, space=" ") return ET.tostring(system, encoding="utf-8", xml_declaration=True) +def normalize_port_definition( + port: ReactFlowPortDefinition | str, + *, + index: int, + component_type: str, +) -> ReactFlowPortDefinition: + if isinstance(port, ReactFlowPortDefinition): + return port + + registered_legacy_ports: dict[str, dict[str, tuple[str, str]]] = { + "cylinder": {"port_b": ("outlet", "right")}, + "tank": {"port_a": ("inlet", "left")}, + "pipe": { + "port_a": ("inlet", "left"), + "port_b": ("outlet", "right"), + }, + "orifice": { + "port_a": ("inlet", "left"), + "port_b": ("outlet", "right"), + }, + "tee": { + "port_in": ("bidirectional", "left"), + "port_out1": ("bidirectional", "right"), + "port_out2": ("bidirectional", "right"), + }, + } + registered = registered_legacy_ports.get(component_type, {}).get(port) + if registered is not None: + nominal_role, side = registered + return ReactFlowPortDefinition( + name=port, + nominalRole=nominal_role, + positiveFlowDirection="intoComponent", + side=side, + ) + + nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional" + if "out" in port or port == "port_b": + nominal_role = "outlet" + elif "in" in port or port == "port_a": + nominal_role = "inlet" + return ReactFlowPortDefinition( + name=port, + nominalRole=nominal_role, + positiveFlowDirection="intoComponent", + side="left" if index == 0 else "right", + ) + + +def require_connection_port( + port_index: dict[tuple[str, str], ReactFlowPortDefinition], + component_id: str, + port_name: str | None, + connection_id: str, +) -> ReactFlowPortDefinition: + if port_name is None or (component_id, port_name) not in port_index: + raise ValueError( + f"Connection {connection_id} references missing endpoint " + f"{component_id}.{port_name or ''}." + ) + return port_index[(component_id, port_name)] + + +def validate_compatible_ports( + first: ReactFlowPortDefinition, + second: ReactFlowPortDefinition, + 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.nominalRole, second.nominalRole} != { + "input", + "output", + }: + raise ValueError( + f"Signal connection {connection_id} must connect one output to one input." + ) + + +def compile_reactflow_network(project: ReactFlowProjectPayload) -> "SimulationNetwork": + from app.simulation.core.medium import IdealGasMedium + from app.simulation.registry import get_component_model_spec + from app.simulation.systems.network import SimulationNetwork + + medium = IdealGasMedium() + network = SimulationNetwork(name=project.name) + + for node in project.nodes: + spec = get_component_model_spec(node.data.modelType) + parameter_values = { + parameter.name: parameter_float(node, parameter.name, parameter.default) + for parameter in spec.parameters + } + unknown_parameters = set(node.data.parameters) - set(spec.parameter_by_name) + if unknown_parameters: + raise ValueError( + f"Component '{node.id}' contains unsupported parameters: " + + ", ".join(sorted(unknown_parameters)) + + "." + ) + component = spec.create(node.id, medium, parameter_values) + validate_component_port_interface(node, component.port_definitions) + network.add_component(component) + + for edge in project.edges: + network.connect( + edge.source, + edge.sourceHandle or "", + edge.target, + edge.targetHandle or "", + connection_id=edge.id, + ) + return network + + +def validate_component_port_interface( + node: ReactFlowNodePayload, + component_ports: tuple["PortDefinition", ...], +) -> None: + payload_ports = [ + normalize_port_definition( + port, + index=index, + component_type=node.data.componentType, + ) + for index, port in enumerate(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" @@ -250,14 +1172,13 @@ def sanitize_project_id(project_id: str) -> str: def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, object]: - from PythonModels.core.solver import SolveIVPConfig - from PythonModels.scripts.run_testmodel import ( + from app.simulation.examples.testmodel.run import ( TestModelExecutionConfig, TestModelRunConfig, TestModelSamplingConfig, run_testmodel, ) - from PythonModels.systems.testmodel import ( + from app.simulation.examples.testmodel.system import ( BranchConfig, CylinderConfig, OrificeConfig, @@ -265,6 +1186,7 @@ def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, objec TankConfig, TestModelConfig, ) + from app.simulation.solvers.solver import SolveIVPConfig nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {} for node in project.nodes: @@ -336,6 +1258,40 @@ def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, objec } +def run_reactflow_test_mql(project: ReactFlowProjectPayload) -> dict[str, object]: + from app.simulation.examples.test_mql.run import run_test_mql + from PythonModels.systems.test_mql import TestMqlRunConfig + + run_config = TestMqlRunConfig( + t_start=project.simulation.t_start, + t_stop=project.simulation.t_stop, + sample_step=project.simulation.step, + ) + result = run_test_mql(run_config=run_config) + series = { + key: [float(value) for value in values] + for key, values in result.result.series.items() + } + final = {key: values[-1] for key, values in series.items() if values} + snapshot = result.system.snapshot() + return { + "success": True, + "message": "test_mql fixed-topology simulation completed.", + "model": { + "name": snapshot.model_name, + "componentCount": snapshot.component_count, + "connectionCount": snapshot.connection_count, + "continuousStateCount": snapshot.continuous_state_count, + "discreteStateCount": snapshot.discrete_state_count, + }, + "final": final, + "series": series, + "artifacts": { + "summary": str(result.summary_path), + }, + } + + def first_node( nodes_by_type: dict[str, list[ReactFlowNodePayload]], model_type: str, diff --git a/app/simulation/README.md b/app/simulation/README.md new file mode 100644 index 0000000..193a99f --- /dev/null +++ b/app/simulation/README.md @@ -0,0 +1,352 @@ +# 仿真后端 + +`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。 + +目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。 + +当前状态不是“只有骨架”,而是“`Testmodel` 已有一版可运行的 ODE 近似实现,并具备基础结果导出与对比能力”。 + +## 当前目录 + +- `core/`: 元件基类、端口、状态、介质、方程和元数据协议。 +- `solvers/`: ODE、压力流量代数方程和 stream 求解。 +- `components/experimental/`: 用于验证元件开发规范的临时组件库。 +- `components/experimental/storage/`: 气瓶和贮箱等储能元件。 +- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。 +- `components/experimental/junctions/`: 三通等连接节点。 +- `systems/`: 通用仿真网络与 XML 驱动系统装配。 +- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和运行入口。 +- `reporting/`: CSV、SVG、运行报告和 Modelica 对比结果导出。 +- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。 +- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。 + +稳定基准存放在 `tests/baselines/simulation/`,实际运行产物默认写入被 Git 忽略的 +`app/data/simulation-runs/`。新增或修改元件时,先阅读 `components/example.md`。 +需要把运行产物写到仓库外时,可以设置 `SIMULATIONAPP_DATA_DIR` 环境变量。 + +FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow +启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。 + +临时组件库的声明入口是 `components/experimental/library.py`。公开模型必须在 +模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS / +RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见 +[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和 +[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。 + +当前关键文件: + +- `core/medium.py`: 理想气体近似介质 `IdealGasMedium` +- `core/medium.py`: 温度相关的空气近似介质 `IdealGasMedium` +- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装 +- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回 +- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔 +- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper +- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口 +- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写 +- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出 +- `examples/testmodel/run.py`: 基线运行与程序化执行入口 +- `tests/`: 当前组件契约、XML、通用系统和结果导出测试 + +## 当前阶段进度 + +这一阶段原先有 4 件重点工作,现在的状态如下: + +1. `mytee1` 的 stream/焓传播语义:已完成当前阶段收紧 + 现在如果只有一条支路发生倒流,下游来流焓统一按 `tank.h` 处理,不再临时借另一条支路的焓来凑。 +2. 下游初始化/约束处理:已完成当前阶段收口 + 之前是“直接改对象状态再开始积分”,现在已经收成显式的 `consistent_initial_state_vector()` 初始化入口。当前这一步会在不改下游总质量、总内能的前提下,把几段直接相连的体积拉回同一个连接压力。 +3. 自动校验:已完成当前阶段首版 + 已经补了标准库 `unittest` 回归测试,先把初始化投影是否守恒、是否污染原始状态,以及 4 个主变量的提交基线锁住。 +4. 更严格介质模型:已完成当前阶段首版 + 已经从固定 `cp/cv` 的理想气体近似,推进到随温度变化的空气近似,并接上了内能反解和初始化求根。 + +如果只看结果,可以把这一阶段理解成: + +- 连接器语义:首轮收紧已完成 +- 初始化入口:首轮收口已完成 +- 基线验证:首轮保护已完成 +- 介质精化:首轮近似已完成 + +## 当前阶段收口 + +上一轮 `N0-N3` 已全部完成首版,当前可以简单理解为: + +1. `N0`:系统层里最明显的流向/焓判断已经继续下沉到组件 helper。 +2. `N1`:模型参数和运行参数已经收口到配置对象。 +3. `N2`:运行接口已经分成“准备请求”和“执行请求”两层。 +4. `N3`:结果导出和命令行报告格式化已经统一收口到 `reporting/`。 + +这一轮结束后,项目已经不缺“能不能跑”的能力,下一步更重要的是把后续开发最容易卡住的地方先处理掉。 + +## 本次推送更新 + +本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态: + +1. `M2`:已完成当前阶段首版 + - 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `examples/testmodel/system.py` 拆到 `examples/testmodel/closure.py` + - `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节 + +2. `M3`:已完成当前阶段首版 + - 已给两条支路入口流量固定点求解、下游公共压力投影补了显式诊断 + - 诊断内容至少包含 `converged / iterations / residual` + - 已支持严格模式;内部求解不收敛时可以直接抛错,而不是静默返回最后一个近似值 + - `run_testmodel()` 的结构化结果和 `testmodel_run_report.txt` 已能带出最后一次内部闭合求解诊断 + +3. `M4`:已完成当前阶段首版 + - 自动测试已不再只盯最终主变量结果 + - 现在已经覆盖: + - 改支路参数后,初始支路入口流量是否按预期变化 + - 更偏激配置下,初始化和内部闭合是否仍然收敛 + - 有无 Modelica 参考两种运行路径下,程序接口与产物行为是否一致 + +4. `M5`:已启动 + - 当前已经明确选择优先走“更容易扩展”的方向,而不是先追求更贴近 Modelica + - 已完成第一步:把闭合器内部原来大量写死的 `upper/lower` 双支路逻辑,收成可复用的 `BranchClosureComponents / BranchClosureState` 结构 + - 当前已继续推进到 `G1-G5` 的首轮兼容层改造:`snapshot` 已提供通用分支集合,系统层结果生成已拆成“通用键生成 + 旧键别名派生”两层,报告层已开始优先消费通用分支键,旧导出列名仍通过兼容映射保留,兼容测试已显式保护分支顺序和旧导出语义 + +## 下一阶段接手建议 + +如果继续往前推进,建议按下面顺序做,而不是再零散补功能: + +1. `G1`:已完成当前阶段首轮兼容接入 + - `TestModelSnapshot` 已新增 `branches` 集合 + - 每个分支当前至少带 `name / pipe / inlet_flow / outlet_flow / inlet_h / inlet_flow_diagnostics` + - `pipe_upper / pipe_lower / branch_inlet_flows / branch_outlet_flows` 目前仍保留为兼容属性,供旧调用方继续使用 + +2. `G2`:已完成当前阶段首轮内部迁移 + - `evaluate_solution()` 已改成从 `snapshot.branches` 读取数据,再通过显式分支名映射写回当前旧列名 + - `rhs()` 里的分支导数计算已改成通过通用 helper 按分支循环生成,再按当前状态向量顺序拼回 + - 当前外部导出列名仍保持兼容: + - `mypipe.p` + - `mypipe1.p` + - `branch_upper.in/out` + - `branch_lower.in/out` + +3. `G3`:已完成当前阶段首轮兼容测试 + - 当前测试已经显式保护: + - `branches` 顺序是否稳定 + - `snapshot` 新字段和兼容字段是否一致 + - 旧导出列名是否仍映射到正确分支语义 + - 参数变化后 `upper/lower` 的名字和顺序是否不会被打乱 + +4. `G4`:已完成当前阶段首轮兼容拆层 + - `evaluate_solution()` 现在会同时产出: + - 通用分支键:`branch..p/in/out` + - 旧兼容键:`mypipe.p`、`mypipe1.p`、`branch_upper.*`、`branch_lower.*` + - 报告层当前已开始优先读取通用分支键,旧键只作为兼容后备 + - 当前已经把“内部统一表达”和“旧接口兼容导出”拆成两层,但还没有把所有报告/导出逻辑都迁干净 + +5. `G5`:已完成当前阶段首轮兼容收口 + - `evaluate_solution()` 当前会先生成通用分支键,再统一派生旧兼容键 + - 报告层当前已支持“通用键优先、旧键兼容后备” + - 当前已经把系统层和 reporting 层的主要旧专名读取入口收口到少量 helper 上,后续继续迁移不会再到处散改 + +6. `P1`:下一阶段建议从这里接手 + 当前更合适的下一步,不是继续深挖内核通用化,而是切回结果导向主线: + - 定义一份稳定的外部输入参数 schema + - 明确这些结构化参数如何映射到 `TestModelConfig / TestModelRunConfig` + - 建立“结构化参数 -> 仿真执行 -> 结果产物/摘要”的稳定接口 + 这样可以直接服务后续文档解析、网页入口和报告生成,而不是继续在 `Testmodel` 内部做边际收益越来越低的抽象整理 + +7. `P2`:在 `P1` 完成后,再推进文档解析或报告生成链路 + 更现实的顺序应是: + - 先把结构化输入跑通 + - 再把结果摘要/产物组织成更接近最终产品的输出包 + - 最后再接 Word 解析或页面入口 + +如果后续继续推进,这个 README 也要一起更新,不要长期保留已经失效的路线描述。 + +## 当前实现了什么 + +当前代码已经实现: + +1. `m`、`U` 作为动态元件主状态,`p`、`T`、`rho`、`u`、`h` 作为派生量。 +2. `Cylinder`、`Tank`、`Pipe` 的刚性绝热容腔近似。 +3. `Orifice` 的压差开方流量关系。 +4. `Tee` 的简化混合焓处理。 +5. `Testmodel` 的系统级拓扑映射和一版可运行的 `rhs(t, x)`。 +6. 基于 `solve_ivp` 的积分入口,以及 SciPy 不可用时的 RK4 回退。 +7. 温度相关空气近似介质,包括 `cp(T)`、`h(T)`、`u(T)` 以及 `u -> T` 反解。 +8. 显式一致初值入口 `consistent_initial_state_vector()`,以及可迭代初始化器 `initialize_consistent_state()`。 +9. Python 主变量结果导出: + `mytank.p`、`mytank.T`、`mycylinder.p`、`mycylinder.T` +10. 贮箱温度曲线导出: + `testmodel_tank_temperature.csv` + `testmodel_tank_temperature.svg` +11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。 +12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。 +13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。 + +当前没有实现: + +- 通用 DAE 初始化器 +- `Modelica.Media.Air.SimpleAir` 的严格复刻 +- 一般高指数 DAE、事件和严格 Modelica `inStream/actualStream` 求解器 + +## 当前怎么运行 + +最小运行方式: + +```bash +python -m app.simulation.examples.testmodel.run +``` + +如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如: + +```python +from app.simulation.examples.testmodel.run import ( + TestModelRunConfig, + TestModelSamplingConfig, + run_testmodel, +) +from app.simulation.examples.testmodel.system import ( + BranchConfig, + CylinderConfig, + OrificeConfig, + PipeConfig, + TankConfig, + TestModelConfig, +) +from app.simulation.solvers.solver import SolveIVPConfig + +run_config = TestModelRunConfig( + model=TestModelConfig( + cylinder=CylinderConfig(p0=30e6), + upper_branch=BranchConfig( + orifice=OrificeConfig(K=8e-6), + pipe=PipeConfig(length=6.0, diameter=0.03), + ), + tank=TankConfig(volume=0.12), + ), + solver=SolveIVPConfig(t_start=0.0, t_stop=10.0, method="BDF"), + sampling=TestModelSamplingConfig(step=0.05), +) + +result = run_testmodel(run_config=run_config) +``` + +如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行: + +```python +from app.simulation.examples.testmodel.run import ( + prepare_testmodel_run, + run_prepared_testmodel, + TestModelRunConfig, +) + +prepared = prepare_testmodel_run(run_config=TestModelRunConfig()) +print(prepared.output_dir) +print(prepared.t_eval) + +result = run_prepared_testmodel(prepared) +print(result.artifacts.primary_csv_path) +print(result.used_modelica_reference) +``` + +当前脚本会: + +1. 构建 `TestModelSystem` +2. 打印原始初值向量与约束一致后的初值向量 +3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s` +4. 将结果写入 `app/data/simulation-runs/` 下本次运行专属的时间戳目录 +5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果 + +当前脚本默认不会把运行结果直接写到提交基线目录,而是会在 +`app/data/simulation-runs/` 下创建一个带时间戳的子目录,例如: + +- `app/data/simulation-runs/testmodel_20260512_103000_123456/` + +该目录里通常会包含: + +- `testmodel_primary_series.csv` +- `testmodel_tank_temperature.csv` +- `testmodel_tank_temperature.svg` +- `testmodel_run_report.txt` +- `testmodel_modelica_comparison.csv` +- `testmodel_modelica_comparison_summary.txt` + +## 基线结果 + +当前基线对比摘要来自: +[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt) + +当前四个主变量的最大误差为: + +- `mytank.p`: `max_abs_error = 134.960857 Pa`, `max_rel_error = 0.006798%` +- `mytank.T`: `max_abs_error = 0.035507 K`, `max_rel_error = 0.009016%` +- `mycylinder.p`: `max_abs_error = 1391.986349 Pa`, `max_rel_error = 0.009447%` +- `mycylinder.T`: `max_abs_error = 0.009069 K`, `max_rel_error = 0.003870%` + +这说明在当前基线工况下,Python 版主变量已经能较好贴近 OpenModelica 结果。 + +## 当前架构判断 + +如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于: + +- 组件级:已完成首版 +- 系统闭合:已完成首版 +- 积分入口:已完成首版 +- 基线结果对齐:已具备初步能力 +- 去近似:仍在进行中 + +所以当前最准确的说法不是“已完成移植”,而是: + +`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。 + +## 已知限制 + +当前最主要的限制可以直接理解成下面几条: + +- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。 +- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。 +- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。 +- 通用 XML 求解链路已经支持按实际流向传播和三通混合 stream 焓,但仍是正则化 MVP,不是严格的 Modelica `inStream/actualStream` 框架。 +- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。 +- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。 +- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。 +- 当前内核已经足够支撑下一阶段“结构化参数 -> 仿真执行 -> 产物输出”的链路开发,但还没有现成的 Word 参数解析入口和正式报告生成链路。 + +所以,当前版本适合: + +- 架构验证 +- 组件接口验证 +- 基线工况对比 +- 结果导出与误差定位 + +但当前版本还不适合: + +- 直接宣称与 OpenModelica 严格等价 +- 作为最终工程结论的唯一依据 +- 直接扩展到更复杂拓扑而不补通用连接器语义 + +## 文件级现状 + +按代码现状逐项看: + +- `core/base.py`: 正常 + 只提供最小抽象层,没有明显冗余。 +- `core/ports.py`: 正常 + `PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。 +- `core/state.py`: 正常 + `VolumeState` 只负责 `[m, U]` 状态打包。 +- `systems/network.py`: 正常 + 负责状态向量拼装和连接摘要,不参与物理求解。 +- `solvers/solver.py`: 正常 + 已支持 SciPy、RK4 回退和零时长仿真。 +- `components/experimental/**/*.py`: 正常 + 都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。 +- `examples/testmodel/system.py`: 是当前最重要的技术债集中区 + 这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。 +- `examples/testmodel/run.py`: 正常 + 已不是“最小打印脚本”,而是当前结果导出和对比入口。 +- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。 +- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。 + +## 当前主技术债 + +目前最主要的技术债,可以直接理解成下面 4 件事: + +1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。 +2. `examples/testmodel/system.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。 +3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。 +4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。 diff --git a/app/simulation/__init__.py b/app/simulation/__init__.py new file mode 100644 index 0000000..350f022 --- /dev/null +++ b/app/simulation/__init__.py @@ -0,0 +1,2 @@ +"""Simulation domain models, solvers, system assembly, and result tools.""" + diff --git a/app/simulation/components/__init__.py b/app/simulation/components/__init__.py new file mode 100644 index 0000000..d8e493c --- /dev/null +++ b/app/simulation/components/__init__.py @@ -0,0 +1,2 @@ +"""Component implementations for the Python system model.""" + diff --git a/app/simulation/components/amesim/__init__.py b/app/simulation/components/amesim/__init__.py new file mode 100644 index 0000000..bdec2fc --- /dev/null +++ b/app/simulation/components/amesim/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__all__: list[str] = [] diff --git a/app/simulation/components/amesim/boundary/__init__.py b/app/simulation/components/amesim/boundary/__init__.py new file mode 100644 index 0000000..92b0e5f --- /dev/null +++ b/app/simulation/components/amesim/boundary/__init__.py @@ -0,0 +1 @@ +"""AMESim pneumatic boundary components.""" diff --git a/app/simulation/components/amesim/boundary/sources.py b/app/simulation/components/amesim/boundary/sources.py new file mode 100644 index 0000000..87aca87 --- /dev/null +++ b/app/simulation/components/amesim/boundary/sources.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from app.simulation.core.base import AlgebraicComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition + + +class AmesimPnpl01(AlgebraicComponent): + """AMESim PNPL01 zero pneumatic flow source. + + The component behaves as a sealed pneumatic boundary in the current acausal + solver: it does not prescribe pressure, and only constrains its port mass + flow to zero. + """ + + MODEL_TYPE = "amesim_pnpl01" + MODEL_VERSION = "0.1.0" + PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),) + PARAMETERS = () + RESULT_VARIABLES = () + DISPLAY = ComponentDisplaySpec( + label="PNPL01 零气动流边界", + library_id="amesim", + category_id="boundary", + symbol="generic", + ports=(PortDisplaySpec("port_1", "left", order=10),), + order=10, + ) + + def __init__(self, name: str) -> None: + super().__init__(name=name) + self.set_parameter_values({}) + self.port_1 = self.register_declared_port("port_1") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> AmesimPnpl01: + return cls(name=name) + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + return ( + EquationResidual( + id=f"{self.name}:zero_mass_flow", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=(f"{self.name}.port_1.m_flow",), + role="flow", + value=self.port_1.m_flow, + ), + ) + + def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + if "port_1" in connected_h: + self.port_1.h_outflow = connected_h["port_1"] diff --git a/app/simulation/components/amesim/flow/__init__.py b/app/simulation/components/amesim/flow/__init__.py new file mode 100644 index 0000000..c1f9910 --- /dev/null +++ b/app/simulation/components/amesim/flow/__init__.py @@ -0,0 +1 @@ +"""AMESim pneumatic flow components.""" diff --git a/app/simulation/components/amesim/flow/orifices.py b/app/simulation/components/amesim/flow/orifices.py new file mode 100644 index 0000000..96f4066 --- /dev/null +++ b/app/simulation/components/amesim/flow/orifices.py @@ -0,0 +1,610 @@ +from __future__ import annotations + +from collections.abc import Mapping +from math import isclose, sqrt + +from app.simulation.core.base import AlgebraicComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ( + ParameterDefinition, + ResultVariableDefinition, +) +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition + + +class AmesimPnor001(AlgebraicComponent): + """AMESim PNOR001 constant-flow-coefficient pneumatic orifice. + + This public component preserves the PNOR001 catalog/XML contract and uses a + finite bidirectional compressible-orifice approximation. The Siemens + `pn2rcqfix_` details remain a later calibration target. + """ + + MODEL_TYPE = "amesim_pnor001" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + "cq", + 0.72, + label="流量系数 Cq", + quantity="dimensionless", + unit="", + minimum=1.0e-10, + maximum=1.0, + ), + ParameterDefinition( + "area", + 5.0e-6, + label="孔口面积", + quantity="area", + unit="m2", + minimum=0.0, + maximum=1.0, + ), + ParameterDefinition( + "Cv", + 0.5, + label="流量系数 Cv", + quantity="dimensionless", + unit="", + minimum=0.0, + ), + ParameterDefinition( + "Kv", + 0.4, + label="流量系数 Kv", + quantity="dimensionless", + unit="", + minimum=0.0, + ), + ParameterDefinition( + "gi", + 1.0, + label="气体类型索引", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=99.0, + ), + ParameterDefinition( + "flowset", + 1.0, + label="流量系数设置", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=3.0, + ), + ) + RESULT_VARIABLES = ( + ResultVariableDefinition( + "cm", + label="质量流量参数", + quantity="dimensionless", + unit="", + category="derived", + order=10, + ), + ResultVariableDefinition( + "gasvel", + label="缩流截面气体速度", + quantity="velocity", + unit="m/s", + category="derived", + order=20, + ), + ) + DISPLAY = ComponentDisplaySpec( + label="PNOR001 常系数气动孔口", + library_id="amesim", + category_id="flow", + symbol="orifice", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=10, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + cq: float = 0.72, + area: float = 5.0e-6, + Cv: float = 0.5, + Kv: float = 0.4, + gi: float = 1.0, + flowset: float = 1.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values( + { + "cq": cq, + "area": area, + "Cv": Cv, + "Kv": Kv, + "gi": gi, + "flowset": flowset, + } + ) + self.medium = medium + self.cq = float(cq) + self.area = float(area) + self.Cv = float(Cv) + self.Kv = float(Kv) + self.gi = self._integer_parameter("gi", gi) + self.flowset = self._integer_parameter("flowset", flowset) + if self.flowset not in {1, 2, 3}: + raise ValueError("PNOR001 flowset must be 1, 2, or 3.") + + initial_h = medium.specific_enthalpy(medium.T_ref) + self.port_1 = self.register_declared_port("port_1") + self.port_1.h_outflow = initial_h + self.port_2 = self.register_declared_port("port_2") + self.port_2.h_outflow = initial_h + + @staticmethod + def _integer_parameter(name: str, value: float) -> int: + rounded = round(value) + if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12): + raise ValueError(f"PNOR001 parameter {name} must be an integer value.") + return int(rounded) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> AmesimPnor001: + return cls( + name=name, + medium=medium, + cq=parameters["cq"], + area=parameters["area"], + Cv=parameters["Cv"], + Kv=parameters["Kv"], + gi=parameters["gi"], + flowset=parameters["flowset"], + ) + + @property + def effective_cq(self) -> float: + return self.cq if self.flowset == 1 else 0.72 + + @property + def effective_area(self) -> float: + if self.flowset == 1: + return self.area + if self.flowset == 2: + return self._area_from_cv(self.Cv, self.effective_cq) + return self._area_from_kv(self.Kv, self.effective_cq) + + @staticmethod + def _area_from_cv(Cv: float, cq: float) -> float: + water_density = 999.0 + reference_flow_m3_s = Cv * 6.30901964e-5 + reference_dp_pa = 6894.75729 + return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density)) + + @staticmethod + def _area_from_kv(Kv: float, cq: float) -> float: + water_density = 999.0 + reference_flow_m3_s = Kv / 3600.0 + reference_dp_pa = 100000.0 + return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density)) + + def _upstream_temperature(self, port_name: str) -> float: + port = self.get_port(port_name) + if port.h_outflow > 0.0: + return max(port.h_outflow / self.medium.cp_ref, 1.0) + return self.medium.T_ref + + def mass_flow(self, p_1: float, p_2: float) -> float: + if p_1 == p_2 or self.effective_area == 0.0: + return 0.0 + if p_1 > p_2: + return self._one_way_mass_flow( + upstream_pressure=p_1, + downstream_pressure=p_2, + upstream_temperature=self._upstream_temperature("port_1"), + ) + return -self._one_way_mass_flow( + upstream_pressure=p_2, + downstream_pressure=p_1, + upstream_temperature=self._upstream_temperature("port_2"), + ) + + def _one_way_mass_flow( + self, + *, + upstream_pressure: float, + downstream_pressure: float, + upstream_temperature: float, + ) -> float: + p_up = max(upstream_pressure, 1.0) + p_down = max(min(downstream_pressure, p_up), 0.0) + T_up = max(upstream_temperature, 1.0) + gamma = max(self.medium.gamma, 1.000001) + pressure_ratio = max(p_down / p_up, 0.0) + critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0)) + if pressure_ratio <= critical_ratio: + flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * ( + 2.0 / (gamma + 1.0) + ) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0))) + else: + expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** ( + (gamma + 1.0) / gamma + ) + flow_factor = sqrt( + max( + 2.0 + * gamma + * expansion + / (self.medium.R_gas * T_up * (gamma - 1.0)), + 0.0, + ) + ) + return self.effective_cq * self.effective_area * p_up * flow_factor + + def component_result_values(self) -> Mapping[str, float]: + p_1 = max(self.port_1.p, 1.0) + p_2 = max(self.port_2.p, 1.0) + m_flow = abs(self.mass_flow(self.port_1.p, self.port_2.p)) + upstream_pressure = max(p_1, p_2) + upstream_temperature = self._upstream_temperature( + "port_1" if p_1 >= p_2 else "port_2" + ) + density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12) + area = max(self.effective_area, 1.0e-18) + return { + "cm": m_flow / (self.effective_cq * area * upstream_pressure), + "gasvel": m_flow / (density * area), + } + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + return ( + EquationResidual( + id=f"{self.name}:mass_flow_balance", + owner="component", + owner_id=self.name, + relation="sumToZero", + variables=( + f"{self.name}.port_1.m_flow", + f"{self.name}.port_2.m_flow", + ), + role="flow", + value=self.port_1.m_flow + self.port_2.m_flow, + ), + EquationResidual( + id=f"{self.name}:pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_1.p", + f"{self.name}.port_2.p", + f"{self.name}.port_1.m_flow", + ), + role="flow", + value=self.port_1.m_flow + - self.mass_flow(self.port_1.p, self.port_2.p), + ), + ) + + def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + self.port_1.h_outflow = connected_h["port_2"] + self.port_2.h_outflow = connected_h["port_1"] + + +class AmesimPnvo001FixedOpening(AlgebraicComponent): + """Fixed-opening public variant of AMESim PNVO001. + + Full PNVO001 has a signal input port. The current public component library + does not support signal simulation, so this model exposes the pneumatic + ports and replaces the signal with a normalized `opening` parameter. + """ + + MODEL_TYPE = "amesim_pnvo001_fixed" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + "cq", + 0.72, + label="流量系数 Cq", + quantity="dimensionless", + unit="", + minimum=1.0e-10, + maximum=1.0, + ), + ParameterDefinition( + "area0", + 5.0e-6, + label="最大孔口面积", + quantity="area", + unit="m2", + minimum=0.0, + maximum=1.0, + ), + ParameterDefinition( + "Cv", + 0.5, + label="最大流量系数 Cv", + quantity="dimensionless", + unit="", + minimum=0.0, + ), + ParameterDefinition( + "Kv", + 0.4, + label="最大流量系数 Kv", + quantity="dimensionless", + unit="", + minimum=0.0, + ), + ParameterDefinition( + "gi", + 1.0, + label="气体类型索引", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=99.0, + ), + ParameterDefinition( + "flowset", + 1.0, + label="流量系数设置", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=3.0, + ), + ParameterDefinition( + "opening", + 1.0, + label="固定开度", + quantity="dimensionless", + unit="", + minimum=0.0, + maximum=1.0, + ), + ) + RESULT_VARIABLES = ( + ResultVariableDefinition( + "xv", + label="有效开度", + quantity="dimensionless", + unit="", + category="derived", + order=10, + ), + ResultVariableDefinition( + "cm", + label="质量流量参数", + quantity="dimensionless", + unit="", + category="derived", + order=20, + ), + ResultVariableDefinition( + "gasvel", + label="缩流截面气体速度", + quantity="velocity", + unit="m/s", + category="derived", + order=30, + ), + ) + DISPLAY = ComponentDisplaySpec( + label="PNVO001 固定开度气动孔口", + library_id="amesim", + category_id="flow", + symbol="orifice", + ports=( + PortDisplaySpec("port_2", "left", order=10), + PortDisplaySpec("port_3", "right", order=20), + ), + order=30, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + cq: float = 0.72, + area0: float = 5.0e-6, + Cv: float = 0.5, + Kv: float = 0.4, + gi: float = 1.0, + flowset: float = 1.0, + opening: float = 1.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values( + { + "cq": cq, + "area0": area0, + "Cv": Cv, + "Kv": Kv, + "gi": gi, + "flowset": flowset, + "opening": opening, + } + ) + self.medium = medium + self.cq = float(cq) + self.area0 = float(area0) + self.Cv = float(Cv) + self.Kv = float(Kv) + self.gi = self._integer_parameter("gi", gi) + self.flowset = self._integer_parameter("flowset", flowset) + if self.flowset not in {1, 2, 3}: + raise ValueError("PNVO001 fixed-opening flowset must be 1, 2, or 3.") + self.opening = min(1.0, max(0.0, float(opening))) + + initial_h = medium.specific_enthalpy(medium.T_ref) + self.port_2 = self.register_declared_port("port_2") + self.port_2.h_outflow = initial_h + self.port_3 = self.register_declared_port("port_3") + self.port_3.h_outflow = initial_h + + @staticmethod + def _integer_parameter(name: str, value: float) -> int: + rounded = round(value) + if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12): + raise ValueError(f"PNVO001 fixed-opening parameter {name} must be an integer value.") + return int(rounded) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> AmesimPnvo001FixedOpening: + return cls( + name=name, + medium=medium, + cq=parameters["cq"], + area0=parameters["area0"], + Cv=parameters["Cv"], + Kv=parameters["Kv"], + gi=parameters["gi"], + flowset=parameters["flowset"], + opening=parameters["opening"], + ) + + @property + def effective_cq(self) -> float: + return self.cq if self.flowset == 1 else 0.72 + + @property + def maximum_area(self) -> float: + if self.flowset == 1: + return self.area0 + if self.flowset == 2: + return AmesimPnor001._area_from_cv(self.Cv, self.effective_cq) + return AmesimPnor001._area_from_kv(self.Kv, self.effective_cq) + + @property + def effective_area(self) -> float: + return self.opening * self.maximum_area + + def _upstream_temperature(self, port_name: str) -> float: + port = self.get_port(port_name) + if port.h_outflow > 0.0: + return max(port.h_outflow / self.medium.cp_ref, 1.0) + return self.medium.T_ref + + def mass_flow(self, p_2: float, p_3: float) -> float: + if p_2 == p_3 or self.effective_area == 0.0: + return 0.0 + if p_2 > p_3: + return self._one_way_mass_flow( + upstream_pressure=p_2, + downstream_pressure=p_3, + upstream_temperature=self._upstream_temperature("port_2"), + ) + return -self._one_way_mass_flow( + upstream_pressure=p_3, + downstream_pressure=p_2, + upstream_temperature=self._upstream_temperature("port_3"), + ) + + def _one_way_mass_flow( + self, + *, + upstream_pressure: float, + downstream_pressure: float, + upstream_temperature: float, + ) -> float: + p_up = max(upstream_pressure, 1.0) + p_down = max(min(downstream_pressure, p_up), 0.0) + T_up = max(upstream_temperature, 1.0) + gamma = max(self.medium.gamma, 1.000001) + pressure_ratio = max(p_down / p_up, 0.0) + critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0)) + if pressure_ratio <= critical_ratio: + flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * ( + 2.0 / (gamma + 1.0) + ) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0))) + else: + expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** ( + (gamma + 1.0) / gamma + ) + flow_factor = sqrt( + max( + 2.0 + * gamma + * expansion + / (self.medium.R_gas * T_up * (gamma - 1.0)), + 0.0, + ) + ) + return self.effective_cq * self.effective_area * p_up * flow_factor + + def component_result_values(self) -> Mapping[str, float]: + p_2 = max(self.port_2.p, 1.0) + p_3 = max(self.port_3.p, 1.0) + m_flow = abs(self.mass_flow(self.port_2.p, self.port_3.p)) + upstream_pressure = max(p_2, p_3) + upstream_temperature = self._upstream_temperature( + "port_2" if p_2 >= p_3 else "port_3" + ) + density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12) + area = max(self.effective_area, 1.0e-18) + return { + "xv": self.opening, + "cm": m_flow / (self.effective_cq * area * upstream_pressure), + "gasvel": m_flow / (density * area), + } + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + return ( + EquationResidual( + id=f"{self.name}:mass_flow_balance", + owner="component", + owner_id=self.name, + relation="sumToZero", + variables=( + f"{self.name}.port_2.m_flow", + f"{self.name}.port_3.m_flow", + ), + role="flow", + value=self.port_2.m_flow + self.port_3.m_flow, + ), + EquationResidual( + id=f"{self.name}:pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_2.p", + f"{self.name}.port_3.p", + f"{self.name}.port_2.m_flow", + ), + role="flow", + value=self.port_2.m_flow + - self.mass_flow(self.port_2.p, self.port_3.p), + ), + ) + + def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + self.port_2.h_outflow = connected_h["port_3"] + self.port_3.h_outflow = connected_h["port_2"] diff --git a/app/simulation/components/amesim/flow/pipes.py b/app/simulation/components/amesim/flow/pipes.py new file mode 100644 index 0000000..625daa8 --- /dev/null +++ b/app/simulation/components/amesim/flow/pipes.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +from collections.abc import Mapping +from math import isclose, log10, pi, sqrt + +from app.simulation.core.base import AlgebraicComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ( + ParameterDefinition, + ResultVariableDefinition, +) +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition + + +class AmesimPnl00r(AlgebraicComponent): + """AMESim PNL00R pneumatic pipe friction resistance. + + The public model exposes the AMESim PNL00R catalog/XML contract and uses + an auditable Darcy-Weisbach resistance with Reynolds/roughness-dependent + friction. Exact `pn2pipefr_` parity is left for the later model tuning pass. + """ + + MODEL_TYPE = "amesim_pnl00r" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + "diam", + 0.01, + label="管径", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "le", + 1.0, + label="管长", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "rr", + 1.0e-5, + label="相对粗糙度", + quantity="dimensionless", + unit="", + minimum=0.0, + maximum=0.1, + ), + ParameterDefinition( + "gi", + 1.0, + label="气体类型索引", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=99.0, + ), + ) + RESULT_VARIABLES = ( + ResultVariableDefinition( + "re", + label="Reynolds 数", + quantity="dimensionless", + unit="", + category="derived", + order=10, + ), + ResultVariableDefinition( + "cm", + label="质量流量参数", + quantity="dimensionless", + unit="", + category="derived", + order=20, + ), + ResultVariableDefinition( + "v", + label="平均气体速度", + quantity="velocity", + unit="m/s", + category="derived", + order=30, + ), + ResultVariableDefinition( + "ff", + label="摩擦因子", + quantity="dimensionless", + unit="", + category="derived", + order=40, + ), + ) + DISPLAY = ComponentDisplaySpec( + label="PNL00R 气动管路阻力", + library_id="amesim", + category_id="flow", + symbol="pipe", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=20, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + diam: float = 0.01, + le: float = 1.0, + rr: float = 1.0e-5, + gi: float = 1.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values({"diam": diam, "le": le, "rr": rr, "gi": gi}) + self.medium = medium + self.diam = float(diam) + self.le = float(le) + self.rr = float(rr) + self.gi = self._integer_parameter("gi", gi) + self.area = pi * self.diam * self.diam / 4.0 + + initial_h = medium.specific_enthalpy(medium.T_ref) + self.port_1 = self.register_declared_port("port_1") + self.port_1.h_outflow = initial_h + self.port_2 = self.register_declared_port("port_2") + self.port_2.h_outflow = initial_h + + @staticmethod + def _integer_parameter(name: str, value: float) -> int: + rounded = round(value) + if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12): + raise ValueError(f"PNL00R parameter {name} must be an integer value.") + return int(rounded) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> AmesimPnl00r: + return cls( + name=name, + medium=medium, + diam=parameters["diam"], + le=parameters["le"], + rr=parameters["rr"], + gi=parameters["gi"], + ) + + def _port_temperature(self, port_name: str) -> float: + port = self.get_port(port_name) + if port.h_outflow > 0.0: + return max(port.h_outflow / self.medium.cp_ref, 1.0) + return self.medium.T_ref + + @staticmethod + def _dynamic_viscosity(temperature_k: float) -> float: + if temperature_k <= 0.0: + raise ValueError("temperature_k must be positive") + reference_temperature = 293.15 + reference_viscosity = 1.82e-5 + sutherland_constant = 110.4 + return ( + reference_viscosity + * (temperature_k / reference_temperature) ** 1.5 + * (reference_temperature + sutherland_constant) + / (temperature_k + sutherland_constant) + ) + + def reynolds_number(self, mass_flow: float, temperature: float) -> float: + viscosity = self._dynamic_viscosity(temperature) + return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity) + + def friction_factor(self, reynolds_number: float) -> float: + if reynolds_number <= 0.0: + return 64_000_000.0 + laminar = 64.0 / reynolds_number + if reynolds_number <= 2300.0: + return laminar + turbulent = 1.0 / ( + -1.8 * log10((self.rr / 3.7) ** 1.11 + 6.9 / reynolds_number) + ) ** 2 + if reynolds_number >= 4000.0: + return turbulent + fraction = (reynolds_number - 2300.0) / 1700.0 + return laminar + fraction * (turbulent - laminar) + + def darcy_pressure_drop( + self, + mass_flow: float, + *, + density: float, + temperature: float, + ) -> float: + if mass_flow == 0.0: + return 0.0 + reynolds = self.reynolds_number(mass_flow, temperature) + friction = self.friction_factor(reynolds) + velocity = mass_flow / (density * self.area) + magnitude = ( + friction + * (self.le / self.diam) + * density + * velocity + * velocity + / 2.0 + ) + return magnitude if mass_flow > 0.0 else -magnitude + + def _mass_flow_for_pressure_drop( + self, + pressure_drop: float, + *, + density: float, + temperature: float, + ) -> float: + if pressure_drop <= 0.0: + return 0.0 + upper = 1.0e-9 + while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop: + upper *= 10.0 + if upper > 1.0e3: + raise ValueError("unable to bracket PNL00R resistance flow") + lower = 0.0 + for _ in range(48): + middle = 0.5 * (lower + upper) + if self.darcy_pressure_drop(middle, density=density, temperature=temperature) < pressure_drop: + lower = middle + else: + upper = middle + return 0.5 * (lower + upper) + + def mass_flow(self, p_1: float, p_2: float) -> float: + if p_1 == p_2: + return 0.0 + pressure_difference = p_1 - p_2 + upstream_pressure = max(p_1, p_2, 1.0) + upstream_temperature = self._port_temperature("port_1" if pressure_difference > 0.0 else "port_2") + density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12) + magnitude = self._mass_flow_for_pressure_drop( + abs(pressure_difference), + density=density, + temperature=upstream_temperature, + ) + return magnitude if pressure_difference > 0.0 else -magnitude + + def component_result_values(self) -> Mapping[str, float]: + m_flow = self.mass_flow(self.port_1.p, self.port_2.p) + upstream_pressure = max(self.port_1.p, self.port_2.p, 1.0) + upstream_temperature = self._port_temperature( + "port_1" if self.port_1.p >= self.port_2.p else "port_2" + ) + density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12) + reynolds = self.reynolds_number(m_flow, upstream_temperature) + velocity = m_flow / (density * self.area) + cm = abs(m_flow) / max(self.area * upstream_pressure, 1.0e-18) + return { + "re": reynolds, + "cm": cm, + "v": velocity, + "ff": self.friction_factor(reynolds), + } + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + return ( + EquationResidual( + id=f"{self.name}:mass_flow_balance", + owner="component", + owner_id=self.name, + relation="sumToZero", + variables=( + f"{self.name}.port_1.m_flow", + f"{self.name}.port_2.m_flow", + ), + role="flow", + value=self.port_1.m_flow + self.port_2.m_flow, + ), + EquationResidual( + id=f"{self.name}:pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_1.p", + f"{self.name}.port_2.p", + f"{self.name}.port_1.m_flow", + ), + role="flow", + value=self.port_1.m_flow + - self.mass_flow(self.port_1.p, self.port_2.p), + ), + ) + + def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + self.port_1.h_outflow = connected_h["port_2"] + self.port_2.h_outflow = connected_h["port_1"] diff --git a/app/simulation/components/amesim/junctions/__init__.py b/app/simulation/components/amesim/junctions/__init__.py new file mode 100644 index 0000000..830b29f --- /dev/null +++ b/app/simulation/components/amesim/junctions/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from app.simulation.components.amesim.junctions.nodes import AmesimP4Node2, AmesimPn3Node2 + +__all__ = ["AmesimP4Node2", "AmesimPn3Node2"] diff --git a/app/simulation/components/amesim/junctions/nodes.py b/app/simulation/components/amesim/junctions/nodes.py new file mode 100644 index 0000000..a7cd64c --- /dev/null +++ b/app/simulation/components/amesim/junctions/nodes.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from app.simulation.core.base import AlgebraicComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition, PortState + + +class _AmesimPneumaticNode(AlgebraicComponent): + """Shared implementation for AMESim pneumatic junction submodels.""" + + REFERENCE_PORT = "port_2" + + def __init__(self, name: str) -> None: + super().__init__(name=name) + self.set_parameter_values({}) + for definition in self.PORTS: + setattr(self, definition.name, self.register_declared_port(definition.name)) + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + reference = self.get_port(self.REFERENCE_PORT) + residuals: list[EquationResidual] = [] + for definition in self.PORTS: + if definition.name == self.REFERENCE_PORT: + continue + port = self.get_port(definition.name) + residuals.append( + EquationResidual( + id=f"{self.name}:{definition.name}_pressure_reference", + owner="component", + owner_id=self.name, + relation="equal", + variables=( + f"{self.name}.{definition.name}.p", + f"{self.name}.{self.REFERENCE_PORT}.p", + ), + role="effort", + value=port.p - reference.p, + ) + ) + residuals.append( + EquationResidual( + id=f"{self.name}:mass_flow_balance", + owner="component", + owner_id=self.name, + relation="sumToZero", + variables=tuple( + f"{self.name}.{definition.name}.m_flow" + for definition in self.PORTS + ), + role="flow", + value=sum(self.get_port(definition.name).m_flow for definition in self.PORTS), + ) + ) + return tuple(residuals) + + def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + incoming = [ + (port.m_flow, connected_h[name]) + for name, port in self.ports.items() + if port.m_flow > 1e-12 + ] + total_flow = sum(m_flow for m_flow, _ in incoming) + if total_flow > 1e-12: + mixed_h = sum(m_flow * h for m_flow, h in incoming) / total_flow + else: + mixed_h = connected_h.get( + self.REFERENCE_PORT, + sum(connected_h.values()) / len(connected_h) if connected_h else 0.0, + ) + for port in self.ports.values(): + port.h_outflow = mixed_h + + +class AmesimPn3Node2(_AmesimPneumaticNode): + """AMESim PN3NODE2 pneumatic three-port junction.""" + + MODEL_TYPE = "amesim_pn3node2" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), + ) + PARAMETERS = () + RESULT_VARIABLES = () + DISPLAY = ComponentDisplaySpec( + label="PN3NODE2 三端气动节点", + library_id="amesim", + category_id="junctions", + symbol="tee", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + PortDisplaySpec("port_3", "right", order=30), + ), + order=10, + ) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> AmesimPn3Node2: + return cls(name=name) + + +class AmesimP4Node2(_AmesimPneumaticNode): + """AMESim P4NODE2 pneumatic four-port junction.""" + + MODEL_TYPE = "amesim_p4node2" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_4", nominal_role="bidirectional"), + ) + PARAMETERS = () + RESULT_VARIABLES = () + DISPLAY = ComponentDisplaySpec( + label="P4NODE2 四端气动节点", + library_id="amesim", + category_id="junctions", + symbol="generic", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + PortDisplaySpec("port_3", "right", order=30), + PortDisplaySpec("port_4", "right", order=40), + ), + order=20, + ) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> AmesimP4Node2: + return cls(name=name) diff --git a/app/simulation/components/amesim/library.py b/app/simulation/components/amesim/library.py new file mode 100644 index 0000000..70cedd9 --- /dev/null +++ b/app/simulation/components/amesim/library.py @@ -0,0 +1,31 @@ +"""AMESim-compatible public component library.""" + +from app.simulation.core.catalog import ( + ComponentCategorySpec, + ComponentLibrarySpec, +) + + +LIBRARY = ComponentLibrarySpec( + id="amesim", + label="AMESim 组件库", + version="0.1.0", + source_package="app.simulation.components.amesim", + temporary=True, + order=200, + categories=( + ComponentCategorySpec(id="storage", label="储能元件", order=10), + ComponentCategorySpec(id="flow", label="流动元件", order=20), + ComponentCategorySpec(id="junctions", label="连接元件", order=30), + ComponentCategorySpec(id="boundary", label="边界元件", order=40), + ), + models=( + "app.simulation.components.amesim.boundary.sources:AmesimPnpl01", + "app.simulation.components.amesim.storage.chambers:AmesimPnch023", + "app.simulation.components.amesim.flow.orifices:AmesimPnor001", + "app.simulation.components.amesim.flow.orifices:AmesimPnvo001FixedOpening", + "app.simulation.components.amesim.flow.pipes:AmesimPnl00r", + "app.simulation.components.amesim.junctions.nodes:AmesimPn3Node2", + "app.simulation.components.amesim.junctions.nodes:AmesimP4Node2", + ), +) diff --git a/app/simulation/components/amesim/storage/__init__.py b/app/simulation/components/amesim/storage/__init__.py new file mode 100644 index 0000000..68843c4 --- /dev/null +++ b/app/simulation/components/amesim/storage/__init__.py @@ -0,0 +1 @@ +"""AMESim pneumatic storage components.""" diff --git a/app/simulation/components/amesim/storage/chambers.py b/app/simulation/components/amesim/storage/chambers.py new file mode 100644 index 0000000..9e4c368 --- /dev/null +++ b/app/simulation/components/amesim/storage/chambers.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from collections.abc import Mapping +from math import isclose + +from app.simulation.core.base import ThermodynamicVolumeComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ( + ParameterDefinition, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) +from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties +from app.simulation.core.ports import PortDefinition +from app.simulation.core.state import VolumeState + + +class AmesimPnch023(ThermodynamicVolumeComponent): + """AMESim PNCH023 simple pneumatic chamber with heat exchange. + + The AMESim submodel owns pressure and temperature states and exposes two + pneumatic flow ports. This public component maps those states onto the + framework's mass/internal-energy volume state and keeps the AMESim + heat-transfer contract `kth * sth * (extemp - T)`. + """ + + MODEL_TYPE = "amesim_pnch023" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + "cvol", + 0.057, + label="气室容积", + quantity="volume", + unit="m3", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "kth", + 0.0, + label="换热系数", + quantity="heat_transfer_coefficient", + unit="W/(m2*K)", + minimum=0.0, + ), + ParameterDefinition( + "sth", + 0.1, + label="换热面积", + quantity="area", + unit="m2", + minimum=0.0, + ), + ParameterDefinition( + "extemp", + 293.15, + label="外部温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "gi", + 1.0, + label="气体类型索引", + quantity="dimensionless", + unit="", + minimum=1.0, + maximum=99.0, + ), + ParameterDefinition( + "p0", + 100000.0, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 293.15, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + DISPLAY = ComponentDisplaySpec( + label="PNCH023 固定容积气室", + library_id="amesim", + category_id="storage", + symbol="tank", + ports=( + PortDisplaySpec("port_1", "left", order=10), + PortDisplaySpec("port_2", "right", order=20), + ), + order=10, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + *, + cvol: float = 0.057, + kth: float = 0.0, + sth: float = 0.1, + extemp: float = 293.15, + gi: float = 1.0, + p0: float = 100000.0, + T0: float = 293.15, + ) -> None: + super().__init__(name=name) + self.set_parameter_values( + { + "cvol": cvol, + "kth": kth, + "sth": sth, + "extemp": extemp, + "gi": gi, + "p0": p0, + "T0": T0, + } + ) + self.medium = medium + self.cvol = float(cvol) + self.kth = float(kth) + self.sth = float(sth) + self.extemp = float(extemp) + self.gi = self._integer_parameter("gi", gi) + self.p0 = float(p0) + self.T0 = float(T0) + m0 = self.p0 * self.cvol / (medium.R_gas * self.T0) + U0 = m0 * medium.specific_internal_energy(self.T0) + self.state = VolumeState(m=m0, U=U0) + initial_h = medium.specific_enthalpy(self.T0) + self.port_1 = self.register_declared_port("port_1") + self.port_1.p = self.p0 + self.port_1.h_outflow = initial_h + self.port_2 = self.register_declared_port("port_2") + self.port_2.p = self.p0 + self.port_2.h_outflow = initial_h + + @staticmethod + def _integer_parameter(name: str, value: float) -> int: + rounded = round(value) + if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12): + raise ValueError(f"PNCH023 parameter {name} must be an integer value.") + return int(rounded) + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> AmesimPnch023: + return cls( + name=name, + medium=medium, + cvol=parameters["cvol"], + kth=parameters["kth"], + sth=parameters["sth"], + extemp=parameters["extemp"], + gi=parameters["gi"], + p0=parameters["p0"], + T0=parameters["T0"], + ) + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.cvol) + self.port_1.p = props.p + self.port_1.h_outflow = props.h + self.port_2.p = props.p + self.port_2.h_outflow = props.h + return props + + def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: + return self.properties() + + def thermal_energy_flow_w(self, temperature: float) -> float: + return self.kth * self.sth * (self.extemp - temperature) + + def state_derivative_from_ports( + self, + connected_h: Mapping[str, float], + ) -> list[float]: + props = self.properties() + inlet_h_1 = self.connection_inlet_enthalpy( + port_m_flow=self.port_1.m_flow, + connected_h=connected_h["port_1"], + internal_h=props.h, + ) + inlet_h_2 = self.connection_inlet_enthalpy( + port_m_flow=self.port_2.m_flow, + connected_h=connected_h["port_2"], + internal_h=props.h, + ) + derivative = VolumeState( + m=self.port_1.m_flow + self.port_2.m_flow, + U=( + self.port_1.m_flow * inlet_h_1 + + self.port_2.m_flow * inlet_h_2 + + self.thermal_energy_flow_w(props.T) + ), + ) + return derivative.as_vector() + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + pressure = self.medium.properties_from_mU( + self.state.m, + self.state.U, + self.cvol, + ).p + return ( + EquationResidual( + id=f"{self.name}:port_1_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.port_1.p", f"{self.name}.state"), + role="effort", + value=self.port_1.p - pressure, + ), + EquationResidual( + id=f"{self.name}:port_2_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.port_2.p", f"{self.name}.state"), + role="effort", + value=self.port_2.p - pressure, + ), + ) diff --git a/app/simulation/components/example.md b/app/simulation/components/example.md new file mode 100644 index 0000000..a20f8f6 --- /dev/null +++ b/app/simulation/components/example.md @@ -0,0 +1,282 @@ +# 元件建模规范与示例 + +规范的权威版本位于 +[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。 +本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次 +修改中同步,不能让示例形成另一套规则。 + +本文档是 `app/simulation/components` 下新增元件的最小开发规范。当前 +`experimental` 是用于验证规范的临时组件库;后续正式模型应建立独立组件库, +不要继续堆放在 `experimental` 中。 + +目标是让元件的端口、输入参数和可展示结果都由元件类显式声明,避免 XML +校验、求解器和前端分别维护同一份含义。 + +## 一、元件类必须声明的内容 + +每个对外注册的元件类至少需要声明以下六个类属性: + +```python +MODEL_TYPE = "example_component" +MODEL_VERSION = "1.0.0" +PORTS = (...) +PARAMETERS = (...) +RESULT_VARIABLES = (...) +DISPLAY = ... +``` + +- `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。 +- `MODEL_VERSION`:模型契约版本,采用 `主版本.次版本.修订版本`。 +- `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。 +- `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。 +- `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。 +- `DISPLAY`:组件库名称、分类、图标、排序和端口画布位置,不参与物理求解。 + +元件构造函数还必须: + +1. 调用 `super().__init__(name)`。 +2. 使用 `set_parameter_values()` 保存规范化后的输入参数。 +3. 使用 `register_declared_port()` 创建已声明端口。 +4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。 +5. 实现统一的类方法 `create()`,接收规范化后的 SI 参数。 + +## 二、输入参数与结果变量 + +输入参数和仿真结果必须分开声明: + +- 输入参数描述一次仿真开始前由用户配置的量,例如 `volume`、`p0`、`T0`。 +- 结果变量描述随时间变化、允许绘图的量,例如 `p`、`T`、`m`、`m_flow`。 +- 求解器缓存、中间残差和调试字段不得自动暴露为结果变量。 +- 参数名和结果变量名使用稳定的英文机器标识;`label` 专门用于界面显示。 + +参数定义示例: + +```python +ParameterDefinition( + name="volume", + label="容积", + quantity="volume", + unit="m3", + default=0.1, + minimum=0.0, + minimum_exclusive=True, +) +``` + +结果变量定义示例: + +```python +ResultVariableDefinition( + name="p", + label="压力", + quantity="pressure", + unit="Pa", + category="thermodynamic", + order=30, +) +``` + +## 三、命名和单位约定 + +- 模型类型、参数、端口和变量名使用 `snake_case`,已有热力学惯例 `T`、`U` 可以保留。 +- 输入参数保存和计算统一使用 SI 基准值;界面单位换算不能改变后端存储值。 +- 无量纲参数的 `unit` 使用空字符串。 +- `quantity` 表示稳定的物理量类型,例如 `pressure`、`temperature`、`mass_flow`,不能使用界面文案代替。 +- 正质量流量统一定义为流入元件,即 `positiveFlowDirection="intoComponent"`。 +- 端口变量 `p`、`m_flow`、`h_outflow` 的连接规则由 `PortDefinition.pneumatic()` 统一提供。 + +## 四、完整示例:单端口储气容腔 + +下面的示例展示一个可直接接入当前框架的动态元件。真实新增元件时应放入独立的 `.py` 文件,并补充对应测试。 + +```python +from __future__ import annotations + +from collections.abc import Mapping + +from app.simulation.core.base import ThermodynamicVolumeComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ( + ParameterDefinition, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) +from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties +from app.simulation.core.ports import PortDefinition +from app.simulation.core.state import VolumeState + + +class ExampleVolume(ThermodynamicVolumeComponent): + MODEL_TYPE = "example_volume" + MODEL_VERSION = "1.0.0" + PORTS = ( + PortDefinition.pneumatic("port_a", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + name="volume", + label="容积", + quantity="volume", + unit="m3", + default=0.1, + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + name="p0", + label="初始压力", + quantity="pressure", + unit="Pa", + default=100000.0, + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + name="T0", + label="初始温度", + quantity="temperature", + unit="K", + default=300.0, + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + DISPLAY = ComponentDisplaySpec( + label="示例容腔", + library_id="experimental", + category_id="storage", + symbol="generic", + ports=(PortDisplaySpec("port_a", "left"),), + order=90, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + volume: float = 0.1, + p0: float = 100000.0, + T0: float = 300.0, + ) -> None: + super().__init__(name) + self.set_parameter_values( + {"volume": volume, "p0": p0, "T0": T0} + ) + self.medium = medium + self.V = volume + initial_mass = p0 * volume / (medium.R_gas * T0) + initial_energy = initial_mass * medium.specific_internal_energy(T0) + self.state = VolumeState(m=initial_mass, U=initial_energy) + self.port_a = self.register_declared_port("port_a") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> ExampleVolume: + return cls( + name=name, + medium=medium, + volume=parameters["volume"], + p0=parameters["p0"], + T0=parameters["T0"], + ) + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: + properties = self.medium.properties_from_mU( + self.state.m, self.state.U, self.V + ) + self.port_a.p = properties.p + self.port_a.h_outflow = properties.h + return properties + + def state_derivative_from_ports( + self, + connected_h: Mapping[str, float], + ) -> list[float]: + properties = self.refresh_thermodynamic_ports() + inlet_h = self.connection_inlet_enthalpy( + port_m_flow=self.port_a.m_flow, + connected_h=connected_h["port_a"], + internal_h=properties.h, + ) + return [self.port_a.m_flow, self.port_a.m_flow * inlet_h] + + def pressure_flow_equation_residuals( + self, + ) -> tuple[EquationResidual, ...]: + pressure = self.medium.properties_from_mU( + self.state.m, self.state.U, self.V + ).p + return ( + EquationResidual( + id=f"{self.name}:port_a_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.port_a.p", f"{self.name}.state"), + role="effort", + value=self.port_a.p - pressure, + ), + ) +``` + +模型文件不再直接修改全局注册表。完成模型类后,只把类路径加入所属库 +`library.py` 的 `models` 清单: + +```python +models=( + # ...已有模型 + "app.simulation.components.experimental.storage.example_volume:ExampleVolume", +) +``` + +后端会受控导入清单中的类,校验版本、分类、端口、参数、单位、显示信息和默认实例, +再自动建立注册表。校验通过后,`GET /api/components/catalog` 会输出该元件, +前端刷新时即可加载。 +当前 `experimental` 仅用于规范验证;正式模型应先建立新的库声明,再把 +`library_id` 指向正式库。 + +完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义: + +```json +{ + "key": "example_volume_1.port_a.m_flow", + "componentId": "example_volume_1", + "componentType": "example_volume", + "scope": "port", + "portName": "port_a", + "name": "m_flow", + "label": "质量流量", + "quantity": "mass_flow", + "unit": "kg/s", + "category": "flow", + "order": 20 +} +``` + +## 五、新增元件检查清单 + +1. `MODEL_TYPE` 是否唯一,并与 XML 的模型类型一致。 +2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。 +3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。 +4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。 +5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。 +6. 是否只暴露有工程意义的结果,而非内部计算变量。 +7. `MODEL_VERSION` 和 `DISPLAY` 是否完整,显示端口是否与物理端口完全一致。 +8. 是否实现统一的 `create()`,并能用默认参数创建模型。 +9. 模型类路径是否只加入所属库的 `library.py` 清单。 +10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。 + +组件库、分类和自动发现的完整规则参见 +[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。 diff --git a/app/simulation/components/experimental/__init__.py b/app/simulation/components/experimental/__init__.py new file mode 100644 index 0000000..f082f8c --- /dev/null +++ b/app/simulation/components/experimental/__init__.py @@ -0,0 +1,12 @@ +"""Temporary component library used to validate the model authoring contract.""" + +from app.simulation.components.experimental.library import LIBRARY + + +# Compatibility aliases for code written before the v1 library manifest. +LIBRARY_ID = LIBRARY.id +LIBRARY_LABEL = LIBRARY.label +LIBRARY_VERSION = LIBRARY.version +LIBRARY_ORDER = LIBRARY.order +LIBRARY_SOURCE_PACKAGE = LIBRARY.source_package +LIBRARY_TEMPORARY = LIBRARY.temporary diff --git a/app/simulation/components/experimental/flow/__init__.py b/app/simulation/components/experimental/flow/__init__.py new file mode 100644 index 0000000..551baf8 --- /dev/null +++ b/app/simulation/components/experimental/flow/__init__.py @@ -0,0 +1 @@ +"""Flow-path and resistance components.""" diff --git a/app/simulation/components/experimental/flow/orifice.py b/app/simulation/components/experimental/flow/orifice.py new file mode 100644 index 0000000..9984ba5 --- /dev/null +++ b/app/simulation/components/experimental/flow/orifice.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from collections.abc import Mapping +from math import sqrt + +from app.simulation.core.base import AlgebraicComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ParameterDefinition +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition + + +class Orifice(AlgebraicComponent): + """Python port of ModelicaModels.Myorifice.""" + + MODEL_TYPE = "orifice" + MODEL_VERSION = "1.0.0" + PORTS = ( + PortDefinition.pneumatic("port_a", nominal_role="inlet"), + PortDefinition.pneumatic("port_b", nominal_role="outlet"), + ) + PARAMETERS = ( + ParameterDefinition( + "K", + 1e-5, + label="流量系数", + quantity="flow_coefficient", + unit="kg/(s*Pa^0.5)", + minimum=0.0, + ), + ParameterDefinition( + "opening", + 1.0, + label="开度", + minimum=0.0, + maximum=1.0, + ), + ) + RESULT_VARIABLES = () + DISPLAY = ComponentDisplaySpec( + label="孔板/阀门", + library_id="experimental", + category_id="flow", + symbol="orifice", + ports=( + PortDisplaySpec("port_a", "left", order=10), + PortDisplaySpec("port_b", "right", order=20), + ), + order=40, + ) + + def __init__(self, name: str, opening: float = 1.0, K: float = 1e-5) -> None: + super().__init__(name=name) + self.set_parameter_values({"K": K, "opening": opening}) + self.opening = opening + self.K = K + self.port_a = self.register_declared_port("port_a") + self.port_b = self.register_declared_port("port_b") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> Orifice: + return cls( + name=name, + opening=parameters["opening"], + K=parameters["K"], + ) + + @property + def K_eff(self) -> float: + return self.K * max(self.opening, 0.001) + + def mass_flow(self, p_a: float, p_b: float) -> float: + dp = p_a - p_b + if dp == 0.0: + return 0.0 + return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0) + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + return ( + EquationResidual( + id=f"{self.name}:mass_flow_balance", + owner="component", + owner_id=self.name, + relation="sumToZero", + variables=( + f"{self.name}.port_a.m_flow", + f"{self.name}.port_b.m_flow", + ), + role="flow", + value=self.port_a.m_flow + self.port_b.m_flow, + ), + EquationResidual( + id=f"{self.name}:pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_a.p", + f"{self.name}.port_b.p", + f"{self.name}.port_a.m_flow", + ), + role="flow", + value=self.port_a.m_flow + - self.mass_flow(self.port_a.p, self.port_b.p), + ), + ) + + def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + self.port_a.h_outflow = connected_h["port_b"] + self.port_b.h_outflow = connected_h["port_a"] + diff --git a/app/simulation/components/experimental/flow/pipe.py b/app/simulation/components/experimental/flow/pipe.py new file mode 100644 index 0000000..005deb0 --- /dev/null +++ b/app/simulation/components/experimental/flow/pipe.py @@ -0,0 +1,10 @@ +"""Compatibility import for the TestModel-only dynamic pipe. + +The public ``pipe`` catalog model is ``ResistivePipe``. New code should import +this legacy dynamic model from ``app.simulation.examples.testmodel.dynamic_pipe``. +""" + +from app.simulation.examples.testmodel.dynamic_pipe import Pipe + + +__all__ = ("Pipe",) diff --git a/app/simulation/components/experimental/flow/resistive_pipe.py b/app/simulation/components/experimental/flow/resistive_pipe.py new file mode 100644 index 0000000..9d62d0d --- /dev/null +++ b/app/simulation/components/experimental/flow/resistive_pipe.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from collections.abc import Mapping +from math import pi + +from app.simulation.core.base import AlgebraicComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ParameterDefinition +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition + + +class ResistivePipe(AlgebraicComponent): + """Quasi-steady Darcy resistance used by topology-driven simulation.""" + + MODEL_TYPE = "pipe" + MODEL_VERSION = "1.0.0" + PORTS = ( + PortDefinition.pneumatic("port_a", nominal_role="inlet"), + PortDefinition.pneumatic("port_b", nominal_role="outlet"), + ) + PARAMETERS = ( + ParameterDefinition( + "length", + 5.0, + label="长度", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "diameter", + 0.02, + label="直径", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "lambda_darcy", + 0.02, + label="摩阻系数", + minimum=0.0, + ), + ParameterDefinition( + "p0", + 1e5, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 300.0, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = () + DISPLAY = ComponentDisplaySpec( + label="管段", + library_id="experimental", + category_id="flow", + symbol="pipe", + ports=( + PortDisplaySpec("port_a", "left", order=10), + PortDisplaySpec("port_b", "right", order=20), + ), + order=30, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + L: float = 5.0, + D: float = 0.02, + lambda_darcy: float = 0.02, + p0: float = 1e5, + T0: float = 300.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values( + { + "length": L, + "diameter": D, + "lambda_darcy": lambda_darcy, + "p0": p0, + "T0": T0, + } + ) + self.medium = medium + self.L = L + self.D = D + self.lambda_darcy = lambda_darcy + self.p0 = p0 + self.T0 = T0 + self.area = pi * D * D / 4.0 + initial_h = medium.specific_enthalpy(T0) + + self.port_a = self.register_declared_port("port_a") + self.port_a.p = p0 + self.port_a.h_outflow = initial_h + + self.port_b = self.register_declared_port("port_b") + self.port_b.p = p0 + self.port_b.h_outflow = initial_h + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> ResistivePipe: + return cls( + name=name, + medium=medium, + L=parameters["length"], + D=parameters["diameter"], + lambda_darcy=parameters["lambda_darcy"], + p0=parameters["p0"], + T0=parameters["T0"], + ) + + def pressure_drop(self, m_flow_a: float, p_a: float, p_b: float) -> float: + average_pressure = max(0.5 * (p_a + p_b), 1.0) + density = max(self.medium.density(average_pressure, self.T0), 1e-12) + resistance = self.lambda_darcy * (self.L / self.D) + return ( + resistance + * m_flow_a + * abs(m_flow_a) + / (2.0 * density * self.area * self.area) + ) + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + return ( + EquationResidual( + id=f"{self.name}:mass_flow_balance", + owner="component", + owner_id=self.name, + relation="sumToZero", + variables=( + f"{self.name}.port_a.m_flow", + f"{self.name}.port_b.m_flow", + ), + role="flow", + value=self.port_a.m_flow + self.port_b.m_flow, + ), + EquationResidual( + id=f"{self.name}:darcy_pressure_loss", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_a.p", + f"{self.name}.port_b.p", + f"{self.name}.port_a.m_flow", + ), + role="effort", + value=( + self.port_a.p + - self.port_b.p + - self.pressure_drop( + self.port_a.m_flow, + self.port_a.p, + self.port_b.p, + ) + ), + ), + ) + + def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + self.port_a.h_outflow = connected_h["port_b"] + self.port_b.h_outflow = connected_h["port_a"] diff --git a/app/simulation/components/experimental/junctions/__init__.py b/app/simulation/components/experimental/junctions/__init__.py new file mode 100644 index 0000000..ce8322d --- /dev/null +++ b/app/simulation/components/experimental/junctions/__init__.py @@ -0,0 +1 @@ +"""Flow junction components.""" diff --git a/app/simulation/components/experimental/junctions/tee.py b/app/simulation/components/experimental/junctions/tee.py new file mode 100644 index 0000000..6912530 --- /dev/null +++ b/app/simulation/components/experimental/junctions/tee.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from app.simulation.core.base import AlgebraicComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition + + +class Tee(AlgebraicComponent): + """Python port of ModelicaModels.Mytee.""" + + MODEL_TYPE = "tee" + MODEL_VERSION = "1.0.0" + PORTS = ( + PortDefinition.pneumatic("port_in", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"), + ) + PARAMETERS = () + RESULT_VARIABLES = () + DISPLAY = ComponentDisplaySpec( + label="三通", + library_id="experimental", + category_id="junctions", + symbol="tee", + ports=( + PortDisplaySpec("port_in", "left", order=10), + PortDisplaySpec("port_out1", "right", order=20), + PortDisplaySpec("port_out2", "right", order=30), + ), + order=50, + ) + + def __init__(self, name: str) -> None: + super().__init__(name=name) + self.set_parameter_values({}) + self.port_in = self.register_declared_port("port_in") + self.port_out1 = self.register_declared_port("port_out1") + self.port_out2 = self.register_declared_port("port_out2") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> Tee: + return cls(name=name) + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + return ( + EquationResidual( + id=f"{self.name}:common_pressure_out1", + owner="component", + owner_id=self.name, + relation="equal", + variables=(f"{self.name}.port_in.p", f"{self.name}.port_out1.p"), + role="effort", + value=self.port_in.p - self.port_out1.p, + ), + EquationResidual( + id=f"{self.name}:common_pressure_out2", + owner="component", + owner_id=self.name, + relation="equal", + variables=(f"{self.name}.port_in.p", f"{self.name}.port_out2.p"), + role="effort", + value=self.port_in.p - self.port_out2.p, + ), + EquationResidual( + id=f"{self.name}:mass_flow_balance", + owner="component", + owner_id=self.name, + relation="sumToZero", + variables=( + f"{self.name}.port_in.m_flow", + f"{self.name}.port_out1.m_flow", + f"{self.name}.port_out2.m_flow", + ), + role="flow", + value=( + self.port_in.m_flow + + self.port_out1.m_flow + + self.port_out2.m_flow + ), + ), + ) + + def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + incoming = [ + (port.m_flow, connected_h[name]) + for name, port in self.ports.items() + if port.m_flow > 1e-12 + ] + total_flow = sum(m_flow for m_flow, _ in incoming) + if total_flow > 1e-12: + mixed_h = sum( + m_flow * enthalpy for m_flow, enthalpy in incoming + ) / total_flow + else: + values = list(connected_h.values()) + mixed_h = sum(values) / len(values) if values else 0.0 + for port in self.ports.values(): + port.h_outflow = mixed_h + + def mixed_inlet_enthalpy( + self, + branch1_m_flow: float, + branch1_h: float, + branch2_m_flow: float, + branch2_h: float, + fallback_h: float = 0.0, + ) -> float: + positive_1 = max(branch1_m_flow, 0.0) + positive_2 = max(branch2_m_flow, 0.0) + total = positive_1 + positive_2 + if total <= 1e-9: + return fallback_h + return (positive_1 * branch1_h + positive_2 * branch2_h) / total + + def inlet_stream_enthalpy( + self, + branch1_m_flow: float, + branch1_h: float, + branch2_m_flow: float, + branch2_h: float, + fallback_h: float, + ) -> float: + """Approximate `inStream(port_in.h_outflow)` for the current tee topology.""" + + return self.mixed_inlet_enthalpy( + branch1_m_flow, + branch1_h, + branch2_m_flow, + branch2_h, + fallback_h=fallback_h, + ) + + def branch_actual_stream_enthalpy( + self, + branch_m_flow: float, + branch_h: float, + inlet_h: float, + ) -> float: + """Approximate `actualStream(branch.h_outflow)` for a tee branch port.""" + + return inlet_h if branch_m_flow > 0.0 else branch_h + + @staticmethod + def _solve_linear_2x2( + a11: float, + a12: float, + a21: float, + a22: float, + b1: float, + b2: float, + ) -> tuple[float, float] | None: + determinant = a11 * a22 - a12 * a21 + if abs(determinant) <= 1e-12: + return None + x1 = (b1 * a22 - b2 * a12) / determinant + x2 = (a11 * b2 - a21 * b1) / determinant + return x1, x2 + + def solve_branch_outlet_flows_from_energy_balance( + self, + *, + ratio_branch1: float, + ratio_branch2: float, + inlet_h_branch1: float, + inlet_h_branch2: float, + branch1_h: float, + branch2_h: float, + inlet_h: float, + q_in_branch1: float, + q_in_branch2: float, + tolerance: float = 1e-12, + ) -> tuple[float, float]: + """Solve branch outlet flows for the current three-port downstream tee use-case.""" + + rhs_branch1 = q_in_branch1 * inlet_h_branch1 + rhs_branch2 = q_in_branch2 * inlet_h_branch2 + + def solve_both_forward() -> tuple[float, float] | None: + return self._solve_linear_2x2( + (1.0 + ratio_branch1) * branch1_h, + ratio_branch1 * branch2_h, + ratio_branch2 * branch1_h, + (1.0 + ratio_branch2) * branch2_h, + rhs_branch1, + rhs_branch2, + ) + + def solve_one_reverse( + *, + branch1_reverse: bool, + ) -> tuple[float, float] | None: + if branch1_reverse: + return self._solve_linear_2x2( + inlet_h * (1.0 + ratio_branch1), + ratio_branch1 * inlet_h, + ratio_branch2 * inlet_h, + branch2_h + ratio_branch2 * inlet_h, + rhs_branch1, + rhs_branch2, + ) + + return self._solve_linear_2x2( + branch1_h + ratio_branch1 * inlet_h, + ratio_branch1 * inlet_h, + ratio_branch2 * inlet_h, + inlet_h * (1.0 + ratio_branch2), + rhs_branch1, + rhs_branch2, + ) + + def solve_both_reverse() -> tuple[float, float] | None: + return self._solve_linear_2x2( + inlet_h * (1.0 + ratio_branch1), + ratio_branch1 * inlet_h, + ratio_branch2 * inlet_h, + inlet_h * (1.0 + ratio_branch2), + rhs_branch1, + rhs_branch2, + ) + + candidate_solvers = ( + ( + solve_both_forward, + lambda q1, q2: q1 >= -tolerance and q2 >= -tolerance, + ), + ( + lambda: solve_one_reverse(branch1_reverse=True), + lambda q1, q2: q1 < -tolerance and q2 >= -tolerance and q1 + q2 > tolerance, + ), + ( + lambda: solve_one_reverse(branch1_reverse=True), + lambda q1, q2: q1 < -tolerance and q2 >= -tolerance and q1 + q2 <= tolerance, + ), + ( + lambda: solve_one_reverse(branch1_reverse=False), + lambda q1, q2: q2 < -tolerance and q1 >= -tolerance and q1 + q2 > tolerance, + ), + ( + lambda: solve_one_reverse(branch1_reverse=False), + lambda q1, q2: q2 < -tolerance and q1 >= -tolerance and q1 + q2 <= tolerance, + ), + ( + solve_both_reverse, + lambda q1, q2: q1 < -tolerance and q2 < -tolerance, + ), + ) + + for solver, predicate in candidate_solvers: + candidate = solver() + if candidate is None: + continue + q_out_branch1, q_out_branch2 = candidate + if predicate(q_out_branch1, q_out_branch2): + return q_out_branch1, q_out_branch2 + + return solve_both_forward() or (0.0, 0.0) diff --git a/app/simulation/components/experimental/library.py b/app/simulation/components/experimental/library.py new file mode 100644 index 0000000..f2dc973 --- /dev/null +++ b/app/simulation/components/experimental/library.py @@ -0,0 +1,28 @@ +"""Manifest for the temporary library used to validate component authoring.""" + +from app.simulation.core.catalog import ( + ComponentCategorySpec, + ComponentLibrarySpec, +) + + +LIBRARY = ComponentLibrarySpec( + id="experimental", + label="临时测试组件库", + version="0.1.0", + source_package="app.simulation.components.experimental", + temporary=True, + order=100, + categories=( + ComponentCategorySpec(id="storage", label="储能元件", order=10), + ComponentCategorySpec(id="flow", label="流动元件", order=20), + ComponentCategorySpec(id="junctions", label="连接元件", order=30), + ), + models=( + "app.simulation.components.experimental.storage.cylinder:Cylinder", + "app.simulation.components.experimental.storage.tank:Tank", + "app.simulation.components.experimental.flow.resistive_pipe:ResistivePipe", + "app.simulation.components.experimental.flow.orifice:Orifice", + "app.simulation.components.experimental.junctions.tee:Tee", + ), +) diff --git a/app/simulation/components/experimental/storage/__init__.py b/app/simulation/components/experimental/storage/__init__.py new file mode 100644 index 0000000..4235074 --- /dev/null +++ b/app/simulation/components/experimental/storage/__init__.py @@ -0,0 +1 @@ +"""Storage and thermodynamic volume components.""" diff --git a/app/simulation/components/experimental/storage/cylinder.py b/app/simulation/components/experimental/storage/cylinder.py new file mode 100644 index 0000000..ce72f85 --- /dev/null +++ b/app/simulation/components/experimental/storage/cylinder.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from app.simulation.core.base import ThermodynamicVolumeComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ( + ParameterDefinition, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) +from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties +from app.simulation.core.ports import PortDefinition +from app.simulation.core.state import VolumeState + + +class Cylinder(ThermodynamicVolumeComponent): + """Python port of ModelicaModels.Mycylinder.""" + + MODEL_TYPE = "cylinder" + MODEL_VERSION = "1.0.0" + PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),) + PARAMETERS = ( + ParameterDefinition( + "volume", + 0.01, + label="容积", + quantity="volume", + unit="m3", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "p0", + 35e6, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 300.0, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + DISPLAY = ComponentDisplaySpec( + label="气瓶", + library_id="experimental", + category_id="storage", + symbol="cylinder", + ports=(PortDisplaySpec("port_b", "right"),), + order=10, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + V: float = 0.01, + p0: float = 35e6, + T0: float = 300.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values({"volume": V, "p0": p0, "T0": T0}) + self.medium = medium + self.V = V + m0 = p0 * V / (medium.R_gas * T0) + U0 = m0 * medium.specific_internal_energy(T0) + self.state = VolumeState(m=m0, U=U0) + self.port_b = self.register_declared_port("port_b") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> Cylinder: + return cls( + name=name, + medium=medium, + V=parameters["volume"], + p0=parameters["p0"], + T0=parameters["T0"], + ) + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V) + self.port_b.p = props.p + self.port_b.h_outflow = props.h + return props + + def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: + return self.properties() + + def state_derivative_from_ports( + self, + connected_h: Mapping[str, float], + ) -> list[float]: + properties = self.properties() + derivative = self.derivatives_from_connection( + connected_h=connected_h["port_b"], + port_m_flow=self.port_b.m_flow, + internal_h=properties.h, + ) + return derivative.as_vector() + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + pressure = self.medium.properties_from_mU( + self.state.m, + self.state.U, + self.V, + ).p + return ( + EquationResidual( + id=f"{self.name}:port_b_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.port_b.p", f"{self.name}.state"), + role="effort", + value=self.port_b.p - pressure, + ), + ) + + def derivatives_from_connection( + self, + *, + connected_h: float, + port_m_flow: float, + internal_h: float, + ) -> VolumeState: + inlet_h = self.connection_inlet_enthalpy( + port_m_flow=port_m_flow, + connected_h=connected_h, + internal_h=internal_h, + ) + return self.derivatives(inlet_h, port_m_flow) + + def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState: + return VolumeState(m=m_flow, U=m_flow * inlet_h) diff --git a/app/simulation/components/experimental/storage/tank.py b/app/simulation/components/experimental/storage/tank.py new file mode 100644 index 0000000..6f3f336 --- /dev/null +++ b/app/simulation/components/experimental/storage/tank.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from app.simulation.core.base import ThermodynamicVolumeComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ( + ParameterDefinition, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) +from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties +from app.simulation.core.ports import PortDefinition +from app.simulation.core.state import VolumeState + + +class Tank(ThermodynamicVolumeComponent): + """Python port of ModelicaModels.Mytank.""" + + MODEL_TYPE = "tank" + MODEL_VERSION = "1.0.0" + PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),) + PARAMETERS = ( + ParameterDefinition( + "volume", + 0.1, + label="容积", + quantity="volume", + unit="m3", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "p0", + 1e5, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 300.0, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + DISPLAY = ComponentDisplaySpec( + label="贮箱", + library_id="experimental", + category_id="storage", + symbol="tank", + ports=(PortDisplaySpec("port_a", "left"),), + order=20, + ) + + def __init__( + self, + name: str, + medium: IdealGasMedium, + V: float = 0.1, + p0: float = 1e5, + T0: float = 300.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values({"volume": V, "p0": p0, "T0": T0}) + self.medium = medium + self.V = V + m0 = p0 * V / (medium.R_gas * T0) + U0 = m0 * medium.specific_internal_energy(T0) + self.state = VolumeState(m=m0, U=U0) + self.port_a = self.register_declared_port("port_a") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> Tank: + return cls( + name=name, + medium=medium, + V=parameters["volume"], + p0=parameters["p0"], + T0=parameters["T0"], + ) + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V) + self.port_a.p = props.p + self.port_a.h_outflow = props.h + return props + + def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: + return self.properties() + + def state_derivative_from_ports( + self, + connected_h: Mapping[str, float], + ) -> list[float]: + properties = self.properties() + derivative = self.derivatives_from_connection( + connected_h=connected_h["port_a"], + port_m_flow=self.port_a.m_flow, + internal_h=properties.h, + ) + return derivative.as_vector() + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + pressure = self.medium.properties_from_mU( + self.state.m, + self.state.U, + self.V, + ).p + return ( + EquationResidual( + id=f"{self.name}:port_a_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.port_a.p", f"{self.name}.state"), + role="effort", + value=self.port_a.p - pressure, + ), + ) + + def derivatives_from_connection( + self, + *, + connected_h: float, + port_m_flow: float, + internal_h: float, + ) -> VolumeState: + inlet_h = self.connection_inlet_enthalpy( + port_m_flow=port_m_flow, + connected_h=connected_h, + internal_h=internal_h, + ) + return self.derivatives(inlet_h, port_m_flow) + + def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState: + return VolumeState(m=m_flow, U=m_flow * inlet_h) diff --git a/app/simulation/core/__init__.py b/app/simulation/core/__init__.py new file mode 100644 index 0000000..ae12d41 --- /dev/null +++ b/app/simulation/core/__init__.py @@ -0,0 +1,2 @@ +"""Core abstractions for the Python system model.""" + diff --git a/app/simulation/core/base.py b/app/simulation/core/base.py new file mode 100644 index 0000000..93347aa --- /dev/null +++ b/app/simulation/core/base.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, ClassVar + +from app.simulation.core.catalog import ComponentDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ( + ParameterDefinition, + ResultVariableDefinition, + ResultVariableMetadata, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) +from app.simulation.core.ports import PortDefinition, PortState + +if TYPE_CHECKING: + from app.simulation.core.medium import IdealGasMedium + + +class Component(ABC): + MODEL_TYPE: ClassVar[str | None] = None + MODEL_VERSION: ClassVar[str | None] = None + PORTS: ClassVar[tuple[PortDefinition, ...]] = () + PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = () + RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = () + DISPLAY: ClassVar[ComponentDisplaySpec | None] = None + + def __init__(self, name: str) -> None: + self.name = name + self.model_type = self.MODEL_TYPE or self.__class__.__name__.lower() + self._ports: dict[str, PortState] = {} + self._parameter_values: dict[str, float] = {} + + @property + def ports(self) -> dict[str, PortState]: + return dict(self._ports) + + @property + def port_definitions(self) -> tuple[PortDefinition, ...]: + return tuple( + port.definition + for port in self._ports.values() + if port.definition is not None + ) + + def register_port(self, port: PortState) -> PortState: + definition = port.definition + if definition is None: + raise ValueError(f"Component {self.name} cannot register an undefined port.") + if definition.name in self._ports: + raise ValueError(f"Duplicate port {self.name}.{definition.name}.") + self._ports[definition.name] = port + return port + + def register_declared_port(self, name: str) -> PortState: + try: + definition = next(item for item in self.PORTS if item.name == name) + except StopIteration as exc: + raise ValueError( + f"Component model {self.model_type} does not declare port {name}." + ) from exc + return self.register_port(PortState(definition=definition)) + + def set_parameter_values(self, values: Mapping[str, float]) -> None: + definitions = {definition.name: definition for definition in self.PARAMETERS} + unknown = sorted(set(values) - set(definitions)) + if unknown: + raise ValueError( + f"Component {self.name} contains unsupported parameters: " + + ", ".join(unknown) + + "." + ) + missing = sorted(set(definitions) - set(values)) + if missing: + raise ValueError( + f"Component {self.name} is missing parameters: " + + ", ".join(missing) + + "." + ) + + resolved: dict[str, float] = {} + for name, definition in definitions.items(): + value = float(values[name]) + message = definition.validation_message(value) + if message is not None: + raise ValueError( + f"Parameter '{name}' on component '{self.name}' {message}." + ) + resolved[name] = value + self._parameter_values = resolved + + @property + def parameter_values(self) -> dict[str, float]: + return dict(self._parameter_values) + + def get_port(self, name: str) -> PortState: + try: + return self._ports[name] + except KeyError as exc: + raise ValueError(f"Component {self.name} has no port named {name}.") from exc + + def component_result_values(self) -> Mapping[str, float]: + return {} + + def result_values(self) -> dict[str, float]: + component_values = dict(self.component_result_values()) + declared = {definition.name: definition for definition in self.RESULT_VARIABLES} + unknown = sorted(set(component_values) - set(declared)) + if unknown: + raise ValueError( + f"Component {self.name} returned undeclared result variables: " + + ", ".join(unknown) + + "." + ) + + values: dict[str, float] = {} + for name, definition in declared.items(): + if not definition.visible: + continue + if name not in component_values: + raise ValueError( + f"Component {self.name} did not provide declared result variable {name}." + ) + values[name] = float(component_values[name]) + + for port_definition in self.port_definitions: + port = self.get_port(port_definition.name) + for variable in port_definition.variables: + if not variable.result_visible: + continue + values[f"{port_definition.name}.{variable.name}"] = float( + getattr(port, variable.name) + ) + return values + + def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]: + metadata = [ + ResultVariableMetadata( + key=f"{self.name}.{definition.name}", + component_id=self.name, + component_type=self.model_type, + scope="component", + name=definition.name, + label=definition.label, + quantity=definition.quantity, + unit=definition.unit, + category=definition.category, + order=definition.order, + ) + for definition in self.RESULT_VARIABLES + if definition.visible + ] + for port_definition in self.port_definitions: + for variable in port_definition.variables: + if not variable.result_visible: + continue + metadata.append( + ResultVariableMetadata( + key=f"{self.name}.{port_definition.name}.{variable.name}", + component_id=self.name, + component_type=self.model_type, + scope="port", + port_name=port_definition.name, + name=variable.name, + label=variable.label or variable.name, + quantity=variable.quantity or variable.name, + unit=variable.unit, + category=variable.role, + order=variable.order, + ) + ) + return tuple(metadata) + + def parameter_interface_dicts(self) -> list[dict[str, object]]: + return [ + definition.as_interface_dict( + value=self._parameter_values.get(definition.name) + ) + for definition in self.PARAMETERS + ] + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> Component: + """Create a catalog model from normalized SI parameters.""" + + raise NotImplementedError( + f"Component model {cls.__name__} must implement create()." + ) + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + """Return algebraic residuals after the network assigns port states.""" + + return () + + def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + """Update connector outflow properties from current flow directions.""" + + return None + + +class DynamicComponent(Component): + state_size = 2 + + @staticmethod + def actual_stream_enthalpy( + port_m_flow: float, + connected_h: float, + internal_h: float, + ) -> float: + """Approximate `actualStream(port.h_outflow)` for a mixed control volume port.""" + + return connected_h if port_m_flow > 0.0 else internal_h + + def connection_inlet_enthalpy( + self, + port_m_flow: float, + connected_h: float, + internal_h: float, + ) -> float: + """Resolve the enthalpy convected into this control volume through one port.""" + + return self.actual_stream_enthalpy( + port_m_flow=port_m_flow, + connected_h=connected_h, + internal_h=internal_h, + ) + + @abstractmethod + def get_state_vector(self) -> list[float]: + raise NotImplementedError + + @abstractmethod + def set_state_vector(self, values: list[float]) -> None: + raise NotImplementedError + + def refresh_thermodynamic_ports(self) -> Any: + raise NotImplementedError + + def state_derivative_from_ports( + self, + connected_h: Mapping[str, float], + ) -> list[float]: + raise NotImplementedError + + +class ThermodynamicVolumeComponent(DynamicComponent): + """Two-state gas volume exposing the shared thermodynamic result contract.""" + + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + + def component_result_values(self) -> Mapping[str, float]: + state = self.get_state_vector() + if len(state) < 2: + raise ValueError( + f"Thermodynamic component {self.name} must expose mass and energy states." + ) + properties = self.refresh_thermodynamic_ports() + return { + "m": float(state[0]), + "U": float(state[1]), + "p": float(properties.p), + "T": float(properties.T), + "rho": float(properties.rho), + "u": float(properties.u), + "h": float(properties.h), + } + + +class AlgebraicComponent(Component): + """Stateless element described by algebraic constraints only.""" diff --git a/app/simulation/core/catalog.py b/app/simulation/core/catalog.py new file mode 100644 index 0000000..bf92e72 --- /dev/null +++ b/app/simulation/core/catalog.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +PortDisplaySide = Literal["left", "right"] + + +@dataclass(frozen=True) +class ComponentCategorySpec: + """A presentation-only category declared by one component library.""" + + id: str + label: str + order: int = 0 + + def as_catalog_dict(self) -> dict[str, object]: + return { + "id": self.id, + "label": self.label, + "order": self.order, + } + + +@dataclass(frozen=True) +class PortDisplaySpec: + """Canvas placement for one port without changing its physical contract.""" + + name: str + side: PortDisplaySide + order: int = 0 + + +@dataclass(frozen=True) +class ComponentDisplaySpec: + """Frontend metadata co-located with a component implementation.""" + + label: str + library_id: str + category_id: str + symbol: str + ports: tuple[PortDisplaySpec, ...] + order: int = 0 + + @property + def port_by_name(self) -> dict[str, PortDisplaySpec]: + return {port.name: port for port in self.ports} + + +@dataclass(frozen=True) +class ComponentLibrarySpec: + """Manifest for one explicitly enabled component library.""" + + id: str + label: str + version: str + source_package: str + categories: tuple[ComponentCategorySpec, ...] + models: tuple[str, ...] + temporary: bool = False + order: int = 0 + + @property + def category_by_id(self) -> dict[str, ComponentCategorySpec]: + return {category.id: category for category in self.categories} + + def as_catalog_dict(self) -> dict[str, object]: + return { + "id": self.id, + "label": self.label, + "version": self.version, + "sourcePackage": self.source_package, + "temporary": self.temporary, + "order": self.order, + "categories": [ + category.as_catalog_dict() + for category in sorted( + self.categories, + key=lambda item: (item.order, item.id), + ) + ], + } diff --git a/app/simulation/core/equations.py b/app/simulation/core/equations.py new file mode 100644 index 0000000..7c28b48 --- /dev/null +++ b/app/simulation/core/equations.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from app.simulation.core.ports import VariableRole + + +EquationOwner = Literal["connection", "component"] +EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"] + + +@dataclass(frozen=True) +class EquationResidual: + """One executable scalar equation in the pressure-flow subsystem.""" + + id: str + owner: EquationOwner + owner_id: str + relation: EquationRelation + variables: tuple[str, ...] + value: float + role: VariableRole | None = None + + def as_definition_dict(self) -> dict[str, object]: + return { + "id": self.id, + "owner": self.owner, + "ownerId": self.owner_id, + "relation": self.relation, + "variables": list(self.variables), + "role": self.role, + } + + def as_interface_dict(self) -> dict[str, object]: + return {**self.as_definition_dict(), "residual": self.value} diff --git a/app/simulation/core/medium.py b/app/simulation/core/medium.py new file mode 100644 index 0000000..3410590 --- /dev/null +++ b/app/simulation/core/medium.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ThermodynamicProperties: + p: float + T: float + rho: float + u: float + h: float + + +@dataclass(frozen=True) +class IdealGasMedium: + """Temperature-dependent ideal-gas air approximation. + + This is still not a strict clone of `Modelica.Media.Air.SimpleAir`. + The small linear `cp(T)` term is kept configurable for calibration, but the + current default is calibrated against the committed Testmodel baseline and + therefore falls back to the constant-heat-capacity limit. + """ + + name: str = "SimpleAirApprox" + R_gas: float = 287.0 + cp_ref: float = 1005.0 + T_ref: float = 300.0 + cp_slope: float = 0.0 + + @property + def cv(self) -> float: + return self.cv_at_temperature(self.T_ref) + + @property + def gamma(self) -> float: + return self.cp_at_temperature(self.T_ref) / self.cv + + def cp_at_temperature(self, T: float) -> float: + return self.cp_ref + self.cp_slope * (T - self.T_ref) + + def cv_at_temperature(self, T: float) -> float: + return self.cp_at_temperature(T) - self.R_gas + + def density(self, p: float, T: float) -> float: + return p / (self.R_gas * T) + + def specific_internal_energy(self, T: float) -> float: + delta_T = T - self.T_ref + return ( + self.cv * self.T_ref + + self.cv * delta_T + + 0.5 * self.cp_slope * delta_T * delta_T + ) + + def specific_enthalpy(self, T: float) -> float: + delta_T = T - self.T_ref + return ( + self.cp_ref * self.T_ref + + self.cp_ref * delta_T + + 0.5 * self.cp_slope * delta_T * delta_T + ) + + def temperature_from_internal_energy(self, u: float) -> float: + reference_internal_energy = self.cv * self.T_ref + delta_u = u - reference_internal_energy + + if abs(self.cp_slope) <= 1e-15: + return self.T_ref + delta_u / self.cv + + a = 0.5 * self.cp_slope + b = self.cv + c = -delta_u + discriminant = max(b * b - 4.0 * a * c, 0.0) + positive_root = (-b + discriminant**0.5) / (2.0 * a) + negative_root = (-b - discriminant**0.5) / (2.0 * a) + delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root + return self.T_ref + delta_T + + def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: + if m <= 0.0: + raise ValueError("Mass must stay positive when recovering temperature.") + return self.temperature_from_internal_energy(U / m) + + def pressure(self, m: float, T: float, V: float) -> float: + if V <= 0.0: + raise ValueError("Volume must stay positive.") + return m * self.R_gas * T / V + + def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties: + T = self.temperature_from_mass_internal_energy(m, U) + p = self.pressure(m, T, V) + rho = m / V + u = U / m + h = self.specific_enthalpy(T) + return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h) diff --git a/app/simulation/core/metadata.py b/app/simulation/core/metadata.py new file mode 100644 index 0000000..5fa7150 --- /dev/null +++ b/app/simulation/core/metadata.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +from typing import Literal + + +ResultVariableScope = Literal["component", "port"] + + +SI_UNIT_BY_QUANTITY: dict[str, str] = { + "area": "m2", + "dimensionless": "", + "density": "kg/m³", + "flow_coefficient": "kg/(s*Pa^0.5)", + "heat_transfer_coefficient": "W/(m2*K)", + "internal_energy": "J", + "length": "m", + "mass": "kg", + "mass_flow": "kg/s", + "pressure": "Pa", + "specific_enthalpy": "J/kg", + "specific_internal_energy": "J/kg", + "temperature": "K", + "velocity": "m/s", + "volume": "m3", +} + + +@dataclass(frozen=True) +class ParameterDefinition: + """User-configurable model input expressed in the backend SI contract.""" + + name: str + default: float + label: str = "" + quantity: str = "dimensionless" + unit: str = "" + minimum: float | None = None + maximum: float | None = None + minimum_exclusive: bool = False + + def validation_message(self, value: float) -> str | None: + if not isfinite(value): + return "must be finite" + if self.minimum is not None: + if self.minimum_exclusive and value <= self.minimum: + return f"must be greater than {self.minimum:g}" + if not self.minimum_exclusive and value < self.minimum: + return f"must be at least {self.minimum:g}" + if self.maximum is not None and value > self.maximum: + return f"must be at most {self.maximum:g}" + return None + + def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]: + payload: dict[str, object] = { + "name": self.name, + "label": self.label or self.name, + "quantity": self.quantity, + "unit": self.unit, + "default": self.default, + "minimumExclusive": self.minimum_exclusive, + } + if self.minimum is not None: + payload["minimum"] = self.minimum + if self.maximum is not None: + payload["maximum"] = self.maximum + if value is not None: + payload["value"] = value + return payload + + +@dataclass(frozen=True) +class ResultVariableDefinition: + """Component-relative declaration of a user-visible simulation result.""" + + name: str + label: str + quantity: str + unit: str = "" + category: str = "derived" + order: int = 0 + visible: bool = True + + +@dataclass(frozen=True) +class ResultVariableMetadata: + """A result declaration bound to one concrete component instance.""" + + key: str + component_id: str + component_type: str + scope: ResultVariableScope + name: str + label: str + quantity: str + unit: str + category: str + order: int + port_name: str | None = None + + def as_dict(self) -> dict[str, object]: + return { + "key": self.key, + "componentId": self.component_id, + "componentType": self.component_type, + "scope": self.scope, + "portName": self.port_name, + "name": self.name, + "label": self.label, + "quantity": self.quantity, + "unit": self.unit, + "category": self.category, + "order": self.order, + } + + +THERMODYNAMIC_VOLUME_RESULT_VARIABLES = ( + ResultVariableDefinition( + name="m", + label="质量", + quantity="mass", + unit="kg", + category="state", + order=10, + ), + ResultVariableDefinition( + name="U", + label="内能", + quantity="internal_energy", + unit="J", + category="state", + order=20, + ), + ResultVariableDefinition( + name="p", + label="压力", + quantity="pressure", + unit="Pa", + category="thermodynamic", + order=30, + ), + ResultVariableDefinition( + name="T", + label="温度", + quantity="temperature", + unit="K", + category="thermodynamic", + order=40, + ), + ResultVariableDefinition( + name="rho", + label="密度", + quantity="density", + unit="kg/m³", + category="thermodynamic", + order=50, + ), + ResultVariableDefinition( + name="u", + label="比内能", + quantity="specific_internal_energy", + unit="J/kg", + category="thermodynamic", + order=60, + ), + ResultVariableDefinition( + name="h", + label="比焓", + quantity="specific_enthalpy", + unit="J/kg", + category="thermodynamic", + order=70, + ), +) diff --git a/app/simulation/core/ports.py b/app/simulation/core/ports.py new file mode 100644 index 0000000..401fe52 --- /dev/null +++ b/app/simulation/core/ports.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + + +PortKind = Literal["physical", "signal"] +PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"] +ActualFlowDirection = Literal["in", "out", "stagnant"] +VariableRole = Literal["effort", "flow", "stream", "signal"] +ConnectionRule = Literal["equal", "sumToZero", "streamMix", "directed"] + + +@dataclass(frozen=True) +class PortVariableDefinition: + name: str + role: VariableRole + connection_rule: ConnectionRule + label: str = field(default="", compare=False) + quantity: str = field(default="", compare=False) + unit: str = field(default="", compare=False) + result_visible: bool = field(default=True, compare=False) + order: int = field(default=0, compare=False) + + def as_interface_dict(self) -> dict[str, object]: + return { + "name": self.name, + "role": self.role, + "connectionRule": self.connection_rule, + "label": self.label or self.name, + "quantity": self.quantity or self.name, + "unit": self.unit, + "resultVisible": self.result_visible, + "order": self.order, + } + + +@dataclass(frozen=True) +class PortDefinition: + """Stable connector contract shared by components, XML, and the compiler.""" + + name: str + kind: PortKind + domain: str + nominal_role: PortNominalRole + positive_flow_direction: Literal["intoComponent"] | None = None + variables: tuple[PortVariableDefinition, ...] = () + + @classmethod + def pneumatic( + cls, + name: str, + *, + nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional", + ) -> PortDefinition: + return cls( + name=name, + kind="physical", + domain="pneumatic", + nominal_role=nominal_role, + positive_flow_direction="intoComponent", + variables=( + PortVariableDefinition( + "p", + "effort", + "equal", + label="压力", + quantity="pressure", + unit="Pa", + order=10, + ), + PortVariableDefinition( + "m_flow", + "flow", + "sumToZero", + label="质量流量", + quantity="mass_flow", + unit="kg/s", + order=20, + ), + PortVariableDefinition( + "h_outflow", + "stream", + "streamMix", + label="流出比焓", + quantity="specific_enthalpy", + unit="J/kg", + order=30, + ), + ), + ) + + def as_interface_dict(self) -> dict[str, object]: + return { + "name": self.name, + "kind": self.kind, + "domain": self.domain, + "nominalRole": self.nominal_role, + "positiveFlowDirection": self.positive_flow_direction, + "variables": [variable.as_interface_dict() for variable in self.variables], + } + + +@dataclass +class PortState: + """Python-side analogue of a Modelica fluid port.""" + + p: float = 0.0 + m_flow: float = 0.0 + h_outflow: float = 0.0 + definition: PortDefinition | None = field(default=None, repr=False, compare=False) + + @classmethod + def pneumatic( + cls, + name: str, + *, + nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional", + ) -> PortState: + return cls(definition=PortDefinition.pneumatic(name, nominal_role=nominal_role)) + + @property + def inflow_rate(self) -> float: + return max(self.m_flow, 0.0) + + @property + def outflow_rate(self) -> float: + return max(-self.m_flow, 0.0) + + def actual_direction(self, tolerance: float = 1e-12) -> ActualFlowDirection: + if self.m_flow > tolerance: + return "in" + if self.m_flow < -tolerance: + return "out" + return "stagnant" + diff --git a/app/simulation/core/state.py b/app/simulation/core/state.py new file mode 100644 index 0000000..e4cf3b3 --- /dev/null +++ b/app/simulation/core/state.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class VolumeState: + """Primary dynamic state for rigid adiabatic control volumes.""" + + m: float + U: float + + def as_vector(self) -> list[float]: + return [self.m, self.U] + + @classmethod + def from_vector(cls, values: list[float]) -> "VolumeState": + if len(values) != 2: + raise ValueError("VolumeState requires exactly two values: [m, U].") + return cls(m=values[0], U=values[1]) + diff --git a/app/simulation/examples/__init__.py b/app/simulation/examples/__init__.py new file mode 100644 index 0000000..a8eeb1e --- /dev/null +++ b/app/simulation/examples/__init__.py @@ -0,0 +1 @@ +"""Reference systems and regression examples.""" diff --git a/app/simulation/examples/test_mql/__init__.py b/app/simulation/examples/test_mql/__init__.py new file mode 100644 index 0000000..bf8d45d --- /dev/null +++ b/app/simulation/examples/test_mql/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from app.simulation.examples.test_mql.run import ( + PreparedTestMqlRun, + TestMqlRunResult, + prepare_test_mql_run, + run_prepared_test_mql, + run_test_mql, +) + +__all__ = [ + "PreparedTestMqlRun", + "TestMqlRunResult", + "prepare_test_mql_run", + "run_prepared_test_mql", + "run_test_mql", +] diff --git a/app/simulation/examples/test_mql/run.py b/app/simulation/examples/test_mql/run.py new file mode 100644 index 0000000..9fb3342 --- /dev/null +++ b/app/simulation/examples/test_mql/run.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR +from PythonModels.scripts.run_test_mql import format_test_mql_summary +from PythonModels.systems.test_mql import ( + TestMqlRunConfig, + TestMqlSimulationResult, + TestMqlSystem, +) + + +@dataclass(frozen=True) +class PreparedTestMqlRun: + run_config: TestMqlRunConfig + repo_root: Path + output_dir: Path + + +@dataclass(frozen=True) +class TestMqlRunResult: + run_config: TestMqlRunConfig + prepared_run: PreparedTestMqlRun + system: TestMqlSystem + result: TestMqlSimulationResult + summary_path: Path + + +def _default_run_output_dir() -> Path: + timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f") + return SIMULATION_RUNS_DIR / timestamp + + +def prepare_test_mql_run( + *, + run_config: TestMqlRunConfig | None = None, + output_dir: Path | None = None, +) -> PreparedTestMqlRun: + return PreparedTestMqlRun( + run_config=run_config or TestMqlRunConfig(), + repo_root=PROJECT_ROOT, + output_dir=output_dir or _default_run_output_dir(), + ) + + +def run_prepared_test_mql(prepared_run: PreparedTestMqlRun) -> TestMqlRunResult: + system = TestMqlSystem() + result = system.simulate(prepared_run.run_config) + prepared_run.output_dir.mkdir(parents=True, exist_ok=True) + summary_path = prepared_run.output_dir / "test_mql_model_summary.txt" + summary_path.write_text(format_test_mql_summary(system), encoding="utf-8") + return TestMqlRunResult( + run_config=prepared_run.run_config, + prepared_run=prepared_run, + system=system, + result=result, + summary_path=summary_path, + ) + + +def run_test_mql( + *, + run_config: TestMqlRunConfig | None = None, + output_dir: Path | None = None, +) -> TestMqlRunResult: + return run_prepared_test_mql( + prepare_test_mql_run(run_config=run_config, output_dir=output_dir) + ) + + +def main() -> None: + run = run_test_mql() + print(format_test_mql_summary(run.system), end="") + print(f"Samples: {len(run.result.t)}") + print(f"Output directory: {run.prepared_run.output_dir}") + + +if __name__ == "__main__": + main() diff --git a/app/simulation/examples/testmodel/__init__.py b/app/simulation/examples/testmodel/__init__.py new file mode 100644 index 0000000..027fceb --- /dev/null +++ b/app/simulation/examples/testmodel/__init__.py @@ -0,0 +1 @@ +"""Legacy TestModel reference system.""" diff --git a/app/simulation/examples/testmodel/closure.py b/app/simulation/examples/testmodel/closure.py new file mode 100644 index 0000000..142f40e --- /dev/null +++ b/app/simulation/examples/testmodel/closure.py @@ -0,0 +1,668 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +from app.simulation.components.experimental.flow.orifice import Orifice +from app.simulation.examples.testmodel.dynamic_pipe import Pipe +from app.simulation.components.experimental.junctions.tee import Tee +from app.simulation.components.experimental.storage.cylinder import Cylinder +from app.simulation.components.experimental.storage.tank import Tank +from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties +from app.simulation.core.state import VolumeState + + +@dataclass(frozen=True) +class BranchInletFlowDiagnostics: + converged: bool + iterations: int + residual: float + m_flow: float + inlet_pressure: float + + +@dataclass(frozen=True) +class DownstreamPressureDiagnostics: + converged: bool + iterations: int + residual: float + pressure: float + target_total_internal_energy: float + + +@dataclass(frozen=True) +class TestModelSolveDiagnostics: + upper_branch_inlet: BranchInletFlowDiagnostics + lower_branch_inlet: BranchInletFlowDiagnostics + downstream_pressure_projection: DownstreamPressureDiagnostics | None + + +@dataclass(frozen=True) +class BranchClosureComponents: + name: str + orifice: Orifice + pipe: Pipe + + +@dataclass(frozen=True) +class BranchClosureState: + name: str + pipe: ThermodynamicProperties + inlet_flow: float + outlet_flow: float + inlet_h: float + inlet_flow_diagnostics: BranchInletFlowDiagnostics + + +@dataclass(frozen=True) +class BranchSnapshot: + name: str + pipe: ThermodynamicProperties + inlet_flow: float + outlet_flow: float + inlet_h: float + inlet_flow_diagnostics: BranchInletFlowDiagnostics + + +@dataclass(frozen=True) +class TestModelSnapshot: + cylinder: ThermodynamicProperties + tank: ThermodynamicProperties + tee_upstream_h: float + tee_downstream_h: float + branches: tuple[BranchSnapshot, ...] = field(default_factory=tuple) + solve_diagnostics: TestModelSolveDiagnostics | None = None + + @property + def pipe_upper(self) -> ThermodynamicProperties: + return self.branches[0].pipe + + @property + def pipe_lower(self) -> ThermodynamicProperties: + return self.branches[1].pipe + + @property + def branch_inlet_flows(self) -> tuple[float, ...]: + return tuple(branch.inlet_flow for branch in self.branches) + + @property + def branch_outlet_flows(self) -> tuple[float, ...]: + return tuple(branch.outlet_flow for branch in self.branches) + + +@dataclass(frozen=True) +class InitializationDiagnostics: + converged: bool + iterations: int + max_state_delta: float + max_flow_delta: float + max_enthalpy_delta: float + downstream_pressure_spread: float + state_vector: tuple[float, ...] + + +@dataclass(frozen=True) +class TestModelClosureComponents: + cylinder: Cylinder + upstream_tee: Tee + upper_branch: BranchClosureComponents + lower_branch: BranchClosureComponents + downstream_tee: Tee + tank: Tank + + def branches(self) -> tuple[BranchClosureComponents, BranchClosureComponents]: + return (self.upper_branch, self.lower_branch) + + +class TestModelClosure: + """Owns Testmodel-specific closure, projection and port-writeback logic.""" + + def __init__( + self, + *, + medium: IdealGasMedium, + components: TestModelClosureComponents, + initial_state_vector: Callable[[], list[float]], + apply_state_vector: Callable[[list[float]], None], + ) -> None: + self.medium = medium + self.components = components + self._initial_state_vector = initial_state_vector + self._apply_state_vector = apply_state_vector + self.last_solve_diagnostics: TestModelSolveDiagnostics | None = None + self.last_downstream_pressure_diagnostics: DownstreamPressureDiagnostics | None = None + + @staticmethod + def _downstream_pressure_spread(snapshot: TestModelSnapshot) -> float: + downstream_pressures = tuple(branch.pipe.p for branch in snapshot.branches) + ( + snapshot.tank.p, + ) + return max(downstream_pressures) - min(downstream_pressures) + + @staticmethod + def _initialization_flow_delta( + previous_snapshot: TestModelSnapshot | None, + current_snapshot: TestModelSnapshot, + ) -> float: + if previous_snapshot is None: + return max(abs(branch.outlet_flow) for branch in current_snapshot.branches) + return max( + abs(curr - prev) + for curr, prev in zip( + current_snapshot.branch_outlet_flows, + previous_snapshot.branch_outlet_flows, + ) + ) + + @staticmethod + def _initialization_enthalpy_delta( + previous_snapshot: TestModelSnapshot | None, + current_snapshot: TestModelSnapshot, + ) -> float: + if previous_snapshot is None: + return abs(current_snapshot.tee_downstream_h - current_snapshot.tank.h) + return max( + abs(current_snapshot.tee_upstream_h - previous_snapshot.tee_upstream_h), + abs(current_snapshot.tee_downstream_h - previous_snapshot.tee_downstream_h), + ) + + def consistent_initial_state_vector(self) -> list[float]: + return list(self.initialize_consistent_state().state_vector) + + def initialize_consistent_state( + self, + max_iterations: int = 12, + state_tolerance: float = 1e-9, + flow_tolerance: float = 1e-9, + enthalpy_tolerance: float = 1e-6, + pressure_tolerance: float = 1e-6, + strict_internal_solvers: bool = False, + ) -> InitializationDiagnostics: + raw_state = self._initial_state_vector() + previous_snapshot: TestModelSnapshot | None = None + diagnostics: InitializationDiagnostics | None = None + + for iteration in range(1, max_iterations + 1): + state_before_projection = self._initial_state_vector() + self.snapshot(state_before_projection, strict=strict_internal_solvers) + + self.project_downstream_pressure_constraints(strict=strict_internal_solvers) + + state_after_projection = self._initial_state_vector() + snapshot_after_projection = self.snapshot( + state_after_projection, + strict=strict_internal_solvers, + ) + + max_state_delta = max( + abs(after - before) + for before, after in zip(state_before_projection, state_after_projection) + ) + max_flow_delta = self._initialization_flow_delta( + previous_snapshot, + snapshot_after_projection, + ) + max_enthalpy_delta = self._initialization_enthalpy_delta( + previous_snapshot, + snapshot_after_projection, + ) + downstream_pressure_spread = self._downstream_pressure_spread( + snapshot_after_projection, + ) + + diagnostics = InitializationDiagnostics( + converged=( + max_state_delta <= state_tolerance + and max_flow_delta <= flow_tolerance + and max_enthalpy_delta <= enthalpy_tolerance + and downstream_pressure_spread <= pressure_tolerance + ), + iterations=iteration, + max_state_delta=max_state_delta, + max_flow_delta=max_flow_delta, + max_enthalpy_delta=max_enthalpy_delta, + downstream_pressure_spread=downstream_pressure_spread, + state_vector=tuple(state_after_projection), + ) + previous_snapshot = snapshot_after_projection + + if diagnostics.converged: + self._apply_state_vector(raw_state) + return diagnostics + + assert diagnostics is not None + self._apply_state_vector(raw_state) + return diagnostics + + def _solve_branch_inlet_flow( + self, + orifice: Orifice, + pipe: Pipe, + p_upstream: float, + pipe_props: ThermodynamicProperties, + *, + strict: bool = False, + ) -> tuple[float, BranchInletFlowDiagnostics]: + m_flow = orifice.mass_flow(p_upstream, pipe_props.p) + rho = max(pipe_props.rho, 1e-9) + p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p) + residual = abs(orifice.mass_flow(p_upstream, p_inlet) - m_flow) + converged = False + iterations = 0 + for iteration in range(1, 9): + p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p) + next_m_flow = orifice.mass_flow(p_upstream, p_inlet) + residual = abs(next_m_flow - m_flow) + iterations = iteration + if residual <= 1e-9 * max(1.0, abs(next_m_flow)): + m_flow = next_m_flow + converged = True + break + m_flow = next_m_flow + diagnostics = BranchInletFlowDiagnostics( + converged=converged, + iterations=iterations, + residual=residual, + m_flow=m_flow, + inlet_pressure=p_inlet, + ) + if strict and not diagnostics.converged: + raise RuntimeError( + f"Branch inlet flow solve did not converge for {pipe.name}: residual={residual:.6e}" + ) + return m_flow, diagnostics + + def _solve_downstream_branch_flows( + self, + cylinder: ThermodynamicProperties, + tank: ThermodynamicProperties, + branch_states: tuple[BranchClosureState, BranchClosureState], + ) -> tuple[float, float]: + return self._solve_downstream_branch_flows_from_state( + inlet_h_upper=branch_states[0].inlet_h, + inlet_h_lower=branch_states[1].inlet_h, + pipe_upper_h=max(branch_states[0].pipe.h, 1e-9), + pipe_lower_h=max(branch_states[1].pipe.h, 1e-9), + tank_h=max(tank.h, 1e-9), + q_in_upper=branch_states[0].inlet_flow, + q_in_lower=branch_states[1].inlet_flow, + ) + + def _project_volume_energy_to_pressure( + self, + component: Pipe | Tank, + target_pressure: float, + ) -> None: + target_temperature = target_pressure * component.V / ( + max(component.state.m, 1e-12) * self.medium.R_gas + ) + target_internal_energy = ( + component.state.m * self.medium.specific_internal_energy(target_temperature) + ) + component.state = VolumeState(m=component.state.m, U=target_internal_energy) + + def _downstream_total_internal_energy_for_pressure( + self, + target_pressure: float, + downstream_components: tuple[Pipe | Tank, ...], + ) -> float: + total_internal_energy = 0.0 + for component in downstream_components: + target_temperature = target_pressure * component.V / ( + max(component.state.m, 1e-12) * self.medium.R_gas + ) + total_internal_energy += ( + component.state.m * self.medium.specific_internal_energy(target_temperature) + ) + return total_internal_energy + + def _solve_downstream_common_pressure( + self, + downstream_components: tuple[Pipe | Tank, ...], + target_total_internal_energy: float, + *, + strict: bool = False, + ) -> tuple[float, DownstreamPressureDiagnostics]: + lower_pressure = 1.0 + upper_pressure = max(component.properties().p for component in downstream_components) + upper_pressure = max(upper_pressure, 1e5) + + def residual(pressure: float) -> float: + return ( + self._downstream_total_internal_energy_for_pressure( + pressure, + downstream_components, + ) + - target_total_internal_energy + ) + + upper_residual = residual(upper_pressure) + iteration_count = 0 + + while upper_residual < 0.0: + upper_pressure *= 2.0 + upper_residual = residual(upper_pressure) + + final_pressure = 0.5 * (lower_pressure + upper_pressure) + final_residual = residual(final_pressure) + converged = False + for iteration in range(1, 81): + middle_pressure = 0.5 * (lower_pressure + upper_pressure) + middle_residual = residual(middle_pressure) + iteration_count = iteration + final_pressure = middle_pressure + final_residual = middle_residual + if abs(middle_residual) <= 1e-12 * max(1.0, target_total_internal_energy): + converged = True + break + if middle_residual > 0.0: + upper_pressure = middle_pressure + else: + lower_pressure = middle_pressure + + diagnostics = DownstreamPressureDiagnostics( + converged=converged, + iterations=iteration_count, + residual=final_residual, + pressure=final_pressure, + target_total_internal_energy=target_total_internal_energy, + ) + if strict and not diagnostics.converged: + raise RuntimeError( + "Downstream common-pressure solve did not converge: " + f"residual={final_residual:.6e}" + ) + return final_pressure, diagnostics + + def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None: + downstream_components = ( + self.components.upper_branch.pipe, + self.components.lower_branch.pipe, + self.components.tank, + ) + total_internal_energy = sum(component.state.U for component in downstream_components) + common_pressure, diagnostics = self._solve_downstream_common_pressure( + downstream_components, + total_internal_energy, + strict=strict, + ) + self.last_downstream_pressure_diagnostics = diagnostics + + for component in downstream_components: + self._project_volume_energy_to_pressure(component, common_pressure) + + def _downstream_connection_enthalpy( + self, + q_out_upper: float, + q_out_lower: float, + pipe_upper_h: float, + pipe_lower_h: float, + tank_h: float, + ) -> float: + return self.components.downstream_tee.inlet_stream_enthalpy( + q_out_lower, + pipe_lower_h, + q_out_upper, + pipe_upper_h, + fallback_h=tank_h, + ) + + def _solve_downstream_branch_flows_from_state( + self, + *, + inlet_h_upper: float, + inlet_h_lower: float, + pipe_upper_h: float, + pipe_lower_h: float, + tank_h: float, + q_in_upper: float, + q_in_lower: float, + ) -> tuple[float, float]: + return self.components.downstream_tee.solve_branch_outlet_flows_from_energy_balance( + ratio_branch1=self.components.upper_branch.pipe.V / self.components.tank.V, + ratio_branch2=self.components.lower_branch.pipe.V / self.components.tank.V, + inlet_h_branch1=inlet_h_upper, + inlet_h_branch2=inlet_h_lower, + branch1_h=pipe_upper_h, + branch2_h=pipe_lower_h, + inlet_h=tank_h, + q_in_branch1=q_in_upper, + q_in_branch2=q_in_lower, + ) + + def _evaluate_branch_states( + self, + cylinder: ThermodynamicProperties, + ) -> tuple[BranchClosureState, BranchClosureState]: + states: list[BranchClosureState] = [] + for branch in self.components.branches(): + pipe_properties = branch.pipe.properties() + inlet_flow, inlet_flow_diagnostics = self._solve_branch_inlet_flow( + branch.orifice, + branch.pipe, + cylinder.p, + pipe_properties, + ) + inlet_h = branch.pipe.port_a_inlet_enthalpy( + port_a_m_flow=inlet_flow, + connected_h=cylinder.h, + internal_h=pipe_properties.h, + ) + states.append( + BranchClosureState( + name=branch.name, + pipe=pipe_properties, + inlet_flow=inlet_flow, + outlet_flow=0.0, + inlet_h=inlet_h, + inlet_flow_diagnostics=inlet_flow_diagnostics, + ) + ) + return (states[0], states[1]) + + @staticmethod + def _with_branch_outlet_flows( + branch_states: tuple[BranchClosureState, BranchClosureState], + outlet_flows: tuple[float, float], + ) -> tuple[BranchClosureState, BranchClosureState]: + return ( + BranchClosureState( + name=branch_states[0].name, + pipe=branch_states[0].pipe, + inlet_flow=branch_states[0].inlet_flow, + outlet_flow=outlet_flows[0], + inlet_h=branch_states[0].inlet_h, + inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics, + ), + BranchClosureState( + name=branch_states[1].name, + pipe=branch_states[1].pipe, + inlet_flow=branch_states[1].inlet_flow, + outlet_flow=outlet_flows[1], + inlet_h=branch_states[1].inlet_h, + inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics, + ), + ) + + @staticmethod + def _branch_snapshots( + branch_states: tuple[BranchClosureState, BranchClosureState], + ) -> tuple[BranchSnapshot, BranchSnapshot]: + return ( + BranchSnapshot( + name=branch_states[0].name, + pipe=branch_states[0].pipe, + inlet_flow=branch_states[0].inlet_flow, + outlet_flow=branch_states[0].outlet_flow, + inlet_h=branch_states[0].inlet_h, + inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics, + ), + BranchSnapshot( + name=branch_states[1].name, + pipe=branch_states[1].pipe, + inlet_flow=branch_states[1].inlet_flow, + outlet_flow=branch_states[1].outlet_flow, + inlet_h=branch_states[1].inlet_h, + inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics, + ), + ) + + def snapshot( + self, + state_vector: list[float] | None = None, + *, + strict: bool = False, + ) -> TestModelSnapshot: + if state_vector is not None: + self._apply_state_vector(list(state_vector)) + + cylinder = self.components.cylinder.properties() + tank = self.components.tank.properties() + branch_states = self._evaluate_branch_states(cylinder) + if strict: + for branch_state in branch_states: + if not branch_state.inlet_flow_diagnostics.converged: + raise RuntimeError( + "Branch inlet flow solve did not converge for " + f"{branch_state.name}: residual=" + f"{branch_state.inlet_flow_diagnostics.residual:.6e}" + ) + outlet_flows = self._solve_downstream_branch_flows(cylinder, tank, branch_states) + branch_states = self._with_branch_outlet_flows(branch_states, outlet_flows) + + tee_upstream_h = self.components.upstream_tee.inlet_stream_enthalpy( + -branch_states[0].inlet_flow, + branch_states[0].pipe.h, + -branch_states[1].inlet_flow, + branch_states[1].pipe.h, + fallback_h=cylinder.h, + ) + tee_downstream_h = self._downstream_connection_enthalpy( + branch_states[0].outlet_flow, + branch_states[1].outlet_flow, + branch_states[0].pipe.h, + branch_states[1].pipe.h, + tank.h, + ) + + self._write_port_states( + cylinder, + tank, + branch_states, + tee_upstream_h, + tee_downstream_h, + ) + + solve_diagnostics = TestModelSolveDiagnostics( + upper_branch_inlet=branch_states[0].inlet_flow_diagnostics, + lower_branch_inlet=branch_states[1].inlet_flow_diagnostics, + downstream_pressure_projection=self.last_downstream_pressure_diagnostics, + ) + self.last_solve_diagnostics = solve_diagnostics + branch_snapshots = self._branch_snapshots(branch_states) + + return TestModelSnapshot( + cylinder=cylinder, + tank=tank, + tee_upstream_h=tee_upstream_h, + tee_downstream_h=tee_downstream_h, + branches=branch_snapshots, + solve_diagnostics=solve_diagnostics, + ) + + def _write_port_states( + self, + cylinder: ThermodynamicProperties, + tank: ThermodynamicProperties, + branch_states: tuple[BranchClosureState, BranchClosureState], + tee_upstream_h: float, + tee_downstream_h: float, + ) -> None: + cylinder_m_flow = -sum(branch_state.inlet_flow for branch_state in branch_states) + tank_m_flow = sum(branch_state.outlet_flow for branch_state in branch_states) + + self.components.cylinder.port_b.m_flow = cylinder_m_flow + + self.components.upstream_tee.port_in.p = cylinder.p + self.components.upstream_tee.port_out1.p = cylinder.p + self.components.upstream_tee.port_out2.p = cylinder.p + self.components.upstream_tee.port_in.m_flow = -cylinder_m_flow + self.components.upstream_tee.port_in.h_outflow = tee_upstream_h + self.components.upstream_tee.port_out1.h_outflow = cylinder.h + self.components.upstream_tee.port_out2.h_outflow = cylinder.h + self.components.upstream_tee.port_out1.m_flow = -branch_states[0].inlet_flow + self.components.upstream_tee.port_out2.m_flow = -branch_states[1].inlet_flow + + for branch_components, branch_state in zip(self.components.branches(), branch_states): + branch_components.orifice.port_a.p = cylinder.p + branch_components.orifice.port_b.p = branch_components.pipe.inlet_pressure( + branch_state.inlet_flow, + max(branch_state.pipe.rho, 1e-9), + branch_state.pipe.p, + ) + branch_components.orifice.port_a.m_flow = branch_state.inlet_flow + branch_components.orifice.port_b.m_flow = -branch_state.inlet_flow + branch_components.orifice.port_a.h_outflow = cylinder.h + branch_components.orifice.port_b.h_outflow = branch_state.pipe.h + + branch_components.pipe.port_a.p = branch_components.orifice.port_b.p + branch_components.pipe.port_a.m_flow = branch_state.inlet_flow + branch_components.pipe.port_b.m_flow = -branch_state.outlet_flow + branch_components.pipe.port_b.p = branch_state.pipe.p + + self.components.downstream_tee.port_in.p = tank.p + self.components.downstream_tee.port_out1.p = tank.p + self.components.downstream_tee.port_out2.p = tank.p + self.components.downstream_tee.port_in.m_flow = -tank_m_flow + self.components.downstream_tee.port_out1.m_flow = branch_states[1].outlet_flow + self.components.downstream_tee.port_out2.m_flow = branch_states[0].outlet_flow + self.components.downstream_tee.port_in.h_outflow = tee_downstream_h + self.components.downstream_tee.port_out1.h_outflow = tank.h + self.components.downstream_tee.port_out2.h_outflow = tank.h + + self.components.tank.port_a.m_flow = tank_m_flow + + def _branch_derivative_states( + self, + snapshot: TestModelSnapshot, + ) -> tuple[VolumeState, VolumeState]: + derivative_states: list[VolumeState] = [] + for branch_components, branch_snapshot in zip(self.components.branches(), snapshot.branches): + derivative_states.append( + branch_components.pipe.derivatives_from_connections( + port_a_m_flow=branch_snapshot.inlet_flow, + connected_h_a=snapshot.cylinder.h, + port_b_m_flow=-branch_snapshot.outlet_flow, + connected_h_b=snapshot.tank.h, + internal_h=branch_snapshot.pipe.h, + ) + ) + return (derivative_states[0], derivative_states[1]) + + def rhs(self, state_vector: list[float]) -> list[float]: + snapshot = self.snapshot(state_vector) + + cylinder_m_flow = -sum(branch.inlet_flow for branch in snapshot.branches) + tank_m_flow = sum(branch.outlet_flow for branch in snapshot.branches) + d_cylinder = self.components.cylinder.derivatives_from_connection( + connected_h=snapshot.tee_upstream_h, + port_m_flow=cylinder_m_flow, + internal_h=snapshot.cylinder.h, + ) + branch_derivatives = self._branch_derivative_states(snapshot) + d_tank = self.components.tank.derivatives_from_connection( + connected_h=snapshot.tee_downstream_h, + port_m_flow=tank_m_flow, + internal_h=snapshot.tank.h, + ) + + return [ + d_cylinder.m, + d_cylinder.U, + branch_derivatives[0].m, + branch_derivatives[0].U, + branch_derivatives[1].m, + branch_derivatives[1].U, + d_tank.m, + d_tank.U, + ] diff --git a/app/simulation/examples/testmodel/dynamic_pipe.py b/app/simulation/examples/testmodel/dynamic_pipe.py new file mode 100644 index 0000000..7f388e6 --- /dev/null +++ b/app/simulation/examples/testmodel/dynamic_pipe.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from app.simulation.core.base import ThermodynamicVolumeComponent +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ( + ParameterDefinition, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) +from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties +from app.simulation.core.ports import PortDefinition +from app.simulation.core.state import VolumeState + + +class Pipe(ThermodynamicVolumeComponent): + """Dynamic pipe retained for the fixed TestModel compatibility example.""" + + MODEL_TYPE = "pipe" + MODEL_VERSION = "0.1.0" + PORTS = ( + PortDefinition.pneumatic("port_a", nominal_role="inlet"), + PortDefinition.pneumatic("port_b", nominal_role="outlet"), + ) + PARAMETERS = ( + ParameterDefinition( + "length", + 5.0, + label="长度", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "diameter", + 0.02, + label="直径", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "lambda_darcy", + 0.02, + label="摩阻系数", + minimum=0.0, + ), + ParameterDefinition( + "p0", + 1e5, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 300.0, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + + def __init__( + self, + name: str, + medium: IdealGasMedium, + L: float = 5.0, + D: float = 0.02, + lambda_darcy: float = 0.02, + p0: float = 1e5, + T0: float = 300.0, + ) -> None: + super().__init__(name=name) + self.set_parameter_values( + { + "length": L, + "diameter": D, + "lambda_darcy": lambda_darcy, + "p0": p0, + "T0": T0, + } + ) + self.medium = medium + self.L = L + self.D = D + self.lambda_darcy = lambda_darcy + self.area = 3.141592653589793 * D * D / 4.0 + self.V = self.area * L + m0 = p0 * self.V / (medium.R_gas * T0) + U0 = m0 * medium.specific_internal_energy(T0) + self.state = VolumeState(m=m0, U=U0) + self.port_a = self.register_declared_port("port_a") + self.port_b = self.register_declared_port("port_b") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> Pipe: + return cls( + name=name, + medium=medium, + L=parameters["length"], + D=parameters["diameter"], + lambda_darcy=parameters["lambda_darcy"], + p0=parameters["p0"], + T0=parameters["T0"], + ) + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def properties(self) -> ThermodynamicProperties: + props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V) + self.port_b.p = props.p + self.port_a.h_outflow = props.h + self.port_b.h_outflow = props.h + return props + + def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: + return self.properties() + + def state_derivative_from_ports( + self, + connected_h: Mapping[str, float], + ) -> list[float]: + properties = self.properties() + derivative = self.derivatives_from_connections( + port_a_m_flow=self.port_a.m_flow, + connected_h_a=connected_h["port_a"], + port_b_m_flow=self.port_b.m_flow, + connected_h_b=connected_h["port_b"], + internal_h=properties.h, + ) + return derivative.as_vector() + + def inlet_pressure(self, m_flow_a: float, rho: float, core_pressure: float) -> float: + resistance = self.lambda_darcy * (self.L / self.D) + dynamic_term = m_flow_a * abs(m_flow_a) / (2.0 * rho * self.area * self.area) + return core_pressure + resistance * dynamic_term + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + properties = self.medium.properties_from_mU( + self.state.m, + self.state.U, + self.V, + ) + expected_inlet_pressure = self.inlet_pressure( + self.port_a.m_flow, + max(properties.rho, 1e-12), + properties.p, + ) + return ( + EquationResidual( + id=f"{self.name}:darcy_pressure_loss", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_a.p", + f"{self.name}.port_a.m_flow", + f"{self.name}.state", + ), + role="effort", + value=self.port_a.p - expected_inlet_pressure, + ), + EquationResidual( + id=f"{self.name}:port_b_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.port_b.p", f"{self.name}.state"), + role="effort", + value=self.port_b.p - properties.p, + ), + ) + + def port_a_inlet_enthalpy( + self, + *, + port_a_m_flow: float, + connected_h: float, + internal_h: float, + ) -> float: + return self.connection_inlet_enthalpy( + port_m_flow=port_a_m_flow, + connected_h=connected_h, + internal_h=internal_h, + ) + + def port_b_inlet_enthalpy( + self, + *, + port_b_m_flow: float, + connected_h: float, + internal_h: float, + ) -> float: + return self.connection_inlet_enthalpy( + port_m_flow=port_b_m_flow, + connected_h=connected_h, + internal_h=internal_h, + ) + + def connection_inlet_enthalpies( + self, + *, + port_a_m_flow: float, + connected_h_a: float, + port_b_m_flow: float, + connected_h_b: float, + internal_h: float, + ) -> tuple[float, float]: + return ( + self.port_a_inlet_enthalpy( + port_a_m_flow=port_a_m_flow, + connected_h=connected_h_a, + internal_h=internal_h, + ), + self.port_b_inlet_enthalpy( + port_b_m_flow=port_b_m_flow, + connected_h=connected_h_b, + internal_h=internal_h, + ), + ) + + def derivatives_from_connections( + self, + *, + port_a_m_flow: float, + connected_h_a: float, + port_b_m_flow: float, + connected_h_b: float, + internal_h: float, + ) -> VolumeState: + inlet_h_a, inlet_h_b = self.connection_inlet_enthalpies( + port_a_m_flow=port_a_m_flow, + connected_h_a=connected_h_a, + port_b_m_flow=port_b_m_flow, + connected_h_b=connected_h_b, + internal_h=internal_h, + ) + return self.derivatives( + inlet_h_a=inlet_h_a, + inlet_h_b=inlet_h_b, + m_flow_a=port_a_m_flow, + m_flow_b=port_b_m_flow, + ) + + def derivatives( + self, + inlet_h_a: float, + inlet_h_b: float, + m_flow_a: float, + m_flow_b: float, + ) -> VolumeState: + dm_dt = m_flow_a + m_flow_b + dU_dt = m_flow_a * inlet_h_a + m_flow_b * inlet_h_b + return VolumeState(m=dm_dt, U=dU_dt) diff --git a/app/simulation/examples/testmodel/run.py b/app/simulation/examples/testmodel/run.py new file mode 100644 index 0000000..7a7a0f0 --- /dev/null +++ b/app/simulation/examples/testmodel/run.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path + +from app.simulation.examples.testmodel.closure import TestModelSolveDiagnostics +from app.simulation.examples.testmodel.system import ( + InitializationDiagnostics, + TestModelConfig, + TestModelSystem, +) +from app.simulation.paths import ( + MODELICA_TESTMODEL_RESULT_PATH, + PROJECT_ROOT, + SIMULATION_RUNS_DIR, +) +from app.simulation.reporting import ( + COMPARISON_KEYS, + PRIMARY_KEYS, + TestModelArtifacts, + export_testmodel_artifacts, + format_testmodel_run_report, + load_modelica_series, + write_testmodel_run_report, +) +from app.simulation.solvers.solver import SolveIVPConfig + + +@dataclass(frozen=True) +class TestModelSamplingConfig: + step: float = 0.1 + + +@dataclass(frozen=True) +class TestModelPathConfig: + output_dir: Path | None = None + modelica_result_path: Path | None = None + + +@dataclass(frozen=True) +class TestModelExecutionConfig: + use_modelica_reference_if_available: bool = True + + +@dataclass(frozen=True) +class TestModelRunConfig: + model: TestModelConfig = field(default_factory=TestModelConfig) + solver: SolveIVPConfig = field(default_factory=SolveIVPConfig) + sampling: TestModelSamplingConfig = field(default_factory=TestModelSamplingConfig) + paths: TestModelPathConfig = field(default_factory=TestModelPathConfig) + execution: TestModelExecutionConfig = field(default_factory=TestModelExecutionConfig) + + @property + def sample_step(self) -> float: + return self.sampling.step + + def sample_times(self) -> list[float]: + return _sample_times( + self.solver.t_start, + self.solver.t_stop, + step=self.sampling.step, + ) + + +@dataclass(frozen=True) +class PreparedTestModelRun: + run_config: TestModelRunConfig + repo_root: Path + output_dir: Path + modelica_result_path: Path + t_eval: tuple[float, ...] + use_modelica_reference_if_available: bool + modelica_reference_exists: bool + + +@dataclass(frozen=True) +class TestModelRunResult: + run_config: TestModelRunConfig + prepared_run: PreparedTestModelRun + system: TestModelSystem + initialization: InitializationDiagnostics + raw_initial_state: tuple[float, ...] + consistent_initial_state: tuple[float, ...] + solution: object + series: dict[str, list[float]] + solve_diagnostics: TestModelSolveDiagnostics | None + artifacts: TestModelArtifacts + comparison_summary: dict[str, tuple[float, float]] | None + used_modelica_reference: bool + + +def _sample_times(t_start: float, t_stop: float, step: float) -> list[float]: + point_count = int(round((t_stop - t_start) / step)) + return [t_start + index * step for index in range(point_count + 1)] + + +def _default_run_output_dir() -> Path: + timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f") + return SIMULATION_RUNS_DIR / timestamp + + +def prepare_testmodel_run( + *, + run_config: TestModelRunConfig | None = None, + output_dir: Path | None = None, + modelica_result_path: Path | None = None, +) -> PreparedTestModelRun: + run_config = run_config or TestModelRunConfig() + resolved_output_dir = ( + output_dir + or run_config.paths.output_dir + or _default_run_output_dir() + ) + resolved_modelica_result_path = ( + modelica_result_path + or run_config.paths.modelica_result_path + or MODELICA_TESTMODEL_RESULT_PATH + ) + t_eval = tuple(run_config.sample_times()) + return PreparedTestModelRun( + run_config=run_config, + repo_root=PROJECT_ROOT, + output_dir=resolved_output_dir, + modelica_result_path=resolved_modelica_result_path, + t_eval=t_eval, + use_modelica_reference_if_available=run_config.execution.use_modelica_reference_if_available, + modelica_reference_exists=resolved_modelica_result_path.exists(), + ) + + +def run_prepared_testmodel(prepared_run: PreparedTestModelRun) -> TestModelRunResult: + run_config = prepared_run.run_config + system = TestModelSystem(config=run_config.model) + raw_initial_state = tuple(system.initial_state_vector()) + initialization = system.initialize_consistent_state() + consistent_initial_state = tuple(initialization.state_vector) + solution = system.simulate(config=run_config.solver, t_eval=list(prepared_run.t_eval)) + series = system.evaluate_solution(solution) + solve_diagnostics = system.last_solve_diagnostics + + modelica_series = None + used_modelica_reference = False + if ( + prepared_run.use_modelica_reference_if_available + and prepared_run.modelica_reference_exists + ): + modelica_series = load_modelica_series( + prepared_run.modelica_result_path, + COMPARISON_KEYS, + ) + used_modelica_reference = True + + artifacts, comparison_summary = export_testmodel_artifacts( + output_dir=prepared_run.output_dir, + series=series, + modelica_series=modelica_series, + ) + report_text = format_testmodel_run_report( + network_summary=system.network.summary(), + initialization=initialization, + raw_initial_state=raw_initial_state, + consistent_initial_state=consistent_initial_state, + solution=solution, + series=series, + solve_diagnostics=solve_diagnostics, + artifacts=artifacts, + comparison_summary=comparison_summary, + ) + write_testmodel_run_report(prepared_run.output_dir, report_text) + + return TestModelRunResult( + run_config=run_config, + prepared_run=prepared_run, + system=system, + initialization=initialization, + raw_initial_state=raw_initial_state, + consistent_initial_state=consistent_initial_state, + solution=solution, + series=series, + solve_diagnostics=solve_diagnostics, + artifacts=artifacts, + comparison_summary=comparison_summary, + used_modelica_reference=used_modelica_reference, + ) + + +def run_testmodel( + *, + run_config: TestModelRunConfig | None = None, + output_dir: Path | None = None, + modelica_result_path: Path | None = None, +) -> TestModelRunResult: + prepared_run = prepare_testmodel_run( + run_config=run_config, + output_dir=output_dir, + modelica_result_path=modelica_result_path, + ) + return run_prepared_testmodel(prepared_run) + + +def main() -> None: + run_config = TestModelRunConfig() + result = run_testmodel(run_config=run_config) + print( + format_testmodel_run_report( + network_summary=result.system.network.summary(), + initialization=result.initialization, + raw_initial_state=result.raw_initial_state, + consistent_initial_state=result.consistent_initial_state, + solution=result.solution, + series=result.series, + solve_diagnostics=result.solve_diagnostics, + artifacts=result.artifacts, + comparison_summary=result.comparison_summary, + ), + end="", + ) + + +if __name__ == "__main__": + main() diff --git a/app/simulation/examples/testmodel/system.py b/app/simulation/examples/testmodel/system.py new file mode 100644 index 0000000..8553450 --- /dev/null +++ b/app/simulation/examples/testmodel/system.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from app.simulation.components.experimental.flow.orifice import Orifice +from app.simulation.components.experimental.junctions.tee import Tee +from app.simulation.components.experimental.storage.cylinder import Cylinder +from app.simulation.components.experimental.storage.tank import Tank +from app.simulation.core.medium import IdealGasMedium +from app.simulation.examples.testmodel.closure import ( + BranchClosureComponents, + InitializationDiagnostics, + TestModelClosure, + TestModelClosureComponents, + TestModelSnapshot, +) +from app.simulation.examples.testmodel.dynamic_pipe import Pipe +from app.simulation.solvers.solver import SolveIVPConfig, integrate_ode +from app.simulation.systems.network import SimulationNetwork + + +@dataclass(frozen=True) +class CylinderConfig: + volume: float = 0.01 + p0: float = 35e6 + T0: float = 300.0 + + +@dataclass(frozen=True) +class OrificeConfig: + K: float = 1e-5 + + +@dataclass(frozen=True) +class TankConfig: + volume: float = 0.1 + p0: float = 1e5 + T0: float = 300.0 + + +@dataclass(frozen=True) +class PipeConfig: + length: float = 5.0 + diameter: float = 0.02 + lambda_darcy: float = 0.02 + p0: float = 1e5 + T0: float = 300.0 + + +@dataclass(frozen=True) +class BranchConfig: + orifice: OrificeConfig = field(default_factory=OrificeConfig) + pipe: PipeConfig = field(default_factory=PipeConfig) + + +@dataclass(frozen=True) +class TestModelConfig: + cylinder: CylinderConfig = field(default_factory=CylinderConfig) + upper_branch: BranchConfig = field(default_factory=BranchConfig) + lower_branch: BranchConfig = field(default_factory=BranchConfig) + tank: TankConfig = field(default_factory=TankConfig) + + +class TestModelSystem: + """Runnable first-pass Python system for the current Testmodel topology. + + This version keeps the component split from the Modelica model while keeping + the downstream tee-tank pressure coupling in the ODE framework. The original + Modelica system is a tighter DAE because both pipe outlets discharge into an + ideal lossless junction directly connected to the tank. Here the branch + outlet flows are solved from a pressure-consistent energy balance so the + outlet is no longer driven by an arbitrary conductance parameter. + """ + + def __init__( + self, + medium: IdealGasMedium | None = None, + config: TestModelConfig | None = None, + ) -> None: + self.medium = medium or IdealGasMedium() + self.config = config or TestModelConfig() + + self.mycylinder = Cylinder( + name="mycylinder", + medium=self.medium, + V=self.config.cylinder.volume, + p0=self.config.cylinder.p0, + T0=self.config.cylinder.T0, + ) + self.mytee = Tee(name="mytee") + self.myorifice = Orifice(name="myorifice", K=self.config.upper_branch.orifice.K) + self.mypipe = Pipe( + name="mypipe", + medium=self.medium, + L=self.config.upper_branch.pipe.length, + D=self.config.upper_branch.pipe.diameter, + lambda_darcy=self.config.upper_branch.pipe.lambda_darcy, + p0=self.config.upper_branch.pipe.p0, + T0=self.config.upper_branch.pipe.T0, + ) + self.myorifice1 = Orifice(name="myorifice1", K=self.config.lower_branch.orifice.K) + self.mypipe1 = Pipe( + name="mypipe1", + medium=self.medium, + L=self.config.lower_branch.pipe.length, + D=self.config.lower_branch.pipe.diameter, + lambda_darcy=self.config.lower_branch.pipe.lambda_darcy, + p0=self.config.lower_branch.pipe.p0, + T0=self.config.lower_branch.pipe.T0, + ) + self.mytee1 = Tee(name="mytee1") + self.mytank = Tank( + name="mytank", + medium=self.medium, + V=self.config.tank.volume, + p0=self.config.tank.p0, + T0=self.config.tank.T0, + ) + + self.network = SimulationNetwork(name="Testmodel") + for component in ( + self.mycylinder, + self.mytee, + self.myorifice, + self.mypipe, + self.myorifice1, + self.mypipe1, + self.mytee1, + self.mytank, + ): + self.network.add_component(component) + + self.network.connect("mycylinder", "port_b", "mytee", "port_in") + self.network.connect("mytee", "port_out1", "myorifice", "port_a") + self.network.connect("myorifice", "port_b", "mypipe", "port_a") + self.network.connect("mypipe", "port_b", "mytee1", "port_out2") + self.network.connect("mytee", "port_out2", "myorifice1", "port_a") + self.network.connect("myorifice1", "port_b", "mypipe1", "port_a") + self.network.connect("mypipe1", "port_b", "mytee1", "port_out1") + self.network.connect("mytee1", "port_in", "mytank", "port_a") + + self.closure = TestModelClosure( + medium=self.medium, + components=TestModelClosureComponents( + cylinder=self.mycylinder, + upstream_tee=self.mytee, + upper_branch=BranchClosureComponents( + name="upper_branch", + orifice=self.myorifice, + pipe=self.mypipe, + ), + lower_branch=BranchClosureComponents( + name="lower_branch", + orifice=self.myorifice1, + pipe=self.mypipe1, + ), + downstream_tee=self.mytee1, + tank=self.mytank, + ), + initial_state_vector=self.initial_state_vector, + apply_state_vector=self.apply_state_vector, + ) + + def initial_state_vector(self) -> list[float]: + return self.network.initial_state_vector() + + def apply_state_vector(self, values: list[float]) -> None: + self.network.apply_state_vector(values) + + def consistent_initial_state_vector(self) -> list[float]: + return self.closure.consistent_initial_state_vector() + + @property + def last_solve_diagnostics(self): + return self.closure.last_solve_diagnostics + + def initialize_consistent_state( + self, + max_iterations: int = 12, + state_tolerance: float = 1e-9, + flow_tolerance: float = 1e-9, + enthalpy_tolerance: float = 1e-6, + pressure_tolerance: float = 1e-6, + strict_internal_solvers: bool = False, + ) -> InitializationDiagnostics: + return self.closure.initialize_consistent_state( + max_iterations=max_iterations, + state_tolerance=state_tolerance, + flow_tolerance=flow_tolerance, + enthalpy_tolerance=enthalpy_tolerance, + pressure_tolerance=pressure_tolerance, + strict_internal_solvers=strict_internal_solvers, + ) + + def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None: + self.closure.project_downstream_pressure_constraints(strict=strict) + + def snapshot( + self, + state_vector: list[float] | None = None, + *, + strict: bool = False, + ) -> TestModelSnapshot: + return self.closure.snapshot(state_vector, strict=strict) + + def rhs(self, _t: float, state_vector: list[float]) -> list[float]: + return self.closure.rhs(state_vector) + + @staticmethod + def _legacy_branch_series_key_map() -> tuple[tuple[str, str, str], tuple[str, str, str]]: + return ( + ("upper_branch", "branch_upper.in", "branch_upper.out"), + ("lower_branch", "branch_lower.in", "branch_lower.out"), + ) + + @classmethod + def _legacy_branch_series_keys_by_name(cls) -> dict[str, tuple[str, str]]: + return { + branch_name: (inlet_key, outlet_key) + for branch_name, inlet_key, outlet_key in cls._legacy_branch_series_key_map() + } + + @staticmethod + def _generic_branch_series_keys(branch_name: str) -> tuple[str, str, str]: + return ( + f"branch.{branch_name}.p", + f"branch.{branch_name}.in", + f"branch.{branch_name}.out", + ) + + @staticmethod + def _legacy_branch_pressure_keys_by_name() -> dict[str, str]: + return { + "upper_branch": "mypipe.p", + "lower_branch": "mypipe1.p", + } + + @classmethod + def _append_legacy_branch_series_aliases( + cls, + series: dict[str, list[float]], + ) -> dict[str, list[float]]: + legacy_branch_series_keys = cls._legacy_branch_series_keys_by_name() + legacy_branch_pressure_keys = cls._legacy_branch_pressure_keys_by_name() + for branch_name, (legacy_inlet_key, legacy_outlet_key) in legacy_branch_series_keys.items(): + pressure_key, generic_inlet_key, generic_outlet_key = cls._generic_branch_series_keys( + branch_name + ) + series[legacy_branch_pressure_keys[branch_name]] = list(series[pressure_key]) + series[legacy_inlet_key] = list(series[generic_inlet_key]) + series[legacy_outlet_key] = list(series[generic_outlet_key]) + return series + + def simulate( + self, + config: SolveIVPConfig | None = None, + t_eval: list[float] | None = None, + ) -> Any: + return integrate_ode( + rhs=self.rhs, + initial_state=self.consistent_initial_state_vector(), + config=config or SolveIVPConfig(), + t_eval=t_eval, + ) + + def evaluate_solution(self, solution: Any) -> dict[str, list[float]]: + series = { + "time": [], + "mycylinder.p": [], + "mycylinder.T": [], + "mytank.p": [], + "mytank.T": [], + } + for branch_name, _, _ in self._legacy_branch_series_key_map(): + pressure_key, inlet_key, outlet_key = self._generic_branch_series_keys(branch_name) + series[pressure_key] = [] + series[inlet_key] = [] + series[outlet_key] = [] + + for index, time_value in enumerate(solution.t): + state_vector = [row[index] for row in solution.y] + snapshot = self.snapshot(state_vector) + series["time"].append(float(time_value)) + series["mycylinder.p"].append(snapshot.cylinder.p) + series["mycylinder.T"].append(snapshot.cylinder.T) + series["mytank.p"].append(snapshot.tank.p) + series["mytank.T"].append(snapshot.tank.T) + for branch in snapshot.branches: + pressure_key, generic_inlet_key, generic_outlet_key = self._generic_branch_series_keys( + branch.name + ) + series[pressure_key].append(branch.pipe.p) + series[generic_inlet_key].append(branch.inlet_flow) + series[generic_outlet_key].append(branch.outlet_flow) + + return self._append_legacy_branch_series_aliases(series) + + +def build_testmodel() -> SimulationNetwork: + """Compatibility helper for callers that only need the topology.""" + + return TestModelSystem().network diff --git a/app/simulation/paths.py b/app/simulation/paths.py new file mode 100644 index 0000000..15ed5e4 --- /dev/null +++ b/app/simulation/paths.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +APP_DIR = Path(__file__).resolve().parent.parent +PROJECT_ROOT = APP_DIR.parent + +_configured_data_dir = os.getenv("SIMULATIONAPP_DATA_DIR") +DATA_DIR = ( + Path(_configured_data_dir).expanduser().resolve() + if _configured_data_dir + else APP_DIR / "data" +) + +SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs" +SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation" +MODELICA_TESTMODEL_RESULT_PATH = ( + PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv" +) diff --git a/app/simulation/registry.py b/app/simulation/registry.py new file mode 100644 index 0000000..63f7421 --- /dev/null +++ b/app/simulation/registry.py @@ -0,0 +1,718 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from importlib import import_module +from math import isfinite +import re +from typing import cast + +from app.simulation.core.base import Component +from app.simulation.core.catalog import ( + ComponentCategorySpec, + ComponentDisplaySpec, + ComponentLibrarySpec, + PortDisplaySpec, +) +from app.simulation.core.metadata import ( + SI_UNIT_BY_QUANTITY, + ParameterDefinition, + ResultVariableDefinition, +) +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition, PortVariableDefinition + + +ParameterSpec = ParameterDefinition + +ENABLED_COMPONENT_LIBRARIES = ( + "app.simulation.components.experimental.library:LIBRARY", + "app.simulation.components.amesim.library:LIBRARY", +) + +_MACHINE_ID_PATTERN = re.compile(r"[a-z][a-z0-9_]*") +_MEMBER_ID_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_]*") +_SEMANTIC_VERSION_PATTERN = re.compile(r"\d+\.\d+\.\d+") + + +@dataclass(frozen=True) +class ComponentModelSpec: + """Validated registry entry backed by one component implementation class.""" + + component_class: type[Component] + library: ComponentLibrarySpec + + @property + def model_type(self) -> str: + return cast(str, self.component_class.MODEL_TYPE) + + @property + def model_version(self) -> str: + return cast(str, self.component_class.MODEL_VERSION) + + @property + def ports(self) -> tuple[PortDefinition, ...]: + return self.component_class.PORTS + + @property + def parameters(self) -> tuple[ParameterDefinition, ...]: + return self.component_class.PARAMETERS + + @property + def result_variables(self) -> tuple[ResultVariableDefinition, ...]: + return self.component_class.RESULT_VARIABLES + + @property + def display(self) -> ComponentDisplaySpec: + return cast(ComponentDisplaySpec, self.component_class.DISPLAY) + + @property + def parameter_by_name(self) -> dict[str, ParameterDefinition]: + return {parameter.name: parameter for parameter in self.parameters} + + def as_catalog_dict(self) -> dict[str, object]: + category = self.library.category_by_id[self.display.category_id] + display_ports = self.display.port_by_name + ports: list[dict[str, object]] = [] + for port in self.ports: + payload = port.as_interface_dict() + payload["side"] = display_ports[port.name].side + payload["order"] = display_ports[port.name].order + ports.append(payload) + ports.sort(key=lambda item: (int(item["order"]), str(item["name"]))) + + return { + "type": self.model_type, + "modelType": self.model_type, + "modelVersion": self.model_version, + "label": self.display.label, + "symbol": self.display.symbol, + "order": self.display.order, + "category": category.as_catalog_dict(), + "ports": ports, + "parameters": [ + parameter.as_interface_dict() for parameter in self.parameters + ], + } + + def create( + self, + name: str, + medium: IdealGasMedium, + values: Mapping[str, float], + ) -> Component: + unknown = sorted(set(values) - set(self.parameter_by_name)) + if unknown: + raise ValueError( + f"Component '{name}' contains unsupported parameters: " + + ", ".join(unknown) + + "." + ) + + resolved = { + parameter.name: values.get(parameter.name, parameter.default) + for parameter in self.parameters + } + for parameter in self.parameters: + message = parameter.validation_message(resolved[parameter.name]) + if message is not None: + raise ValueError( + f"Parameter '{parameter.name}' on component '{name}' {message}." + ) + + component = self.component_class.create( + name=name, + medium=medium, + parameters=resolved, + ) + self._validate_instance(component, resolved) + return component + + def _validate_instance( + self, + component: Component, + resolved_parameters: Mapping[str, float], + ) -> None: + if not isinstance(component, self.component_class): + raise ValueError( + f"Component model '{self.model_type}' create() returned " + f"{type(component).__name__}, expected {self.component_class.__name__}." + ) + if component.model_type != self.model_type: + raise ValueError( + f"Component implementation '{self.model_type}' created instance " + f"with model type '{component.model_type}'." + ) + if component.port_definitions != self.ports: + raise ValueError( + f"Component implementation '{self.model_type}' does not match " + "its declared ports." + ) + if component.parameter_values != dict(resolved_parameters): + raise ValueError( + f"Component implementation '{self.model_type}' did not preserve " + "its normalized parameters." + ) + + +def _load_reference(reference: str) -> object: + try: + module_name, attribute_name = reference.rsplit(":", maxsplit=1) + except ValueError as exc: + raise ValueError( + f"Component reference '{reference}' must use 'module:attribute'." + ) from exc + if not module_name or not attribute_name: + raise ValueError( + f"Component reference '{reference}' must use 'module:attribute'." + ) + + try: + module = import_module(module_name) + except Exception as exc: + raise ValueError( + f"Could not import component module '{module_name}' from '{reference}'." + ) from exc + try: + return getattr(module, attribute_name) + except AttributeError as exc: + raise ValueError( + f"Component module '{module_name}' has no attribute '{attribute_name}'." + ) from exc + + +def _validate_machine_id(value: object, *, field: str) -> str: + if not isinstance(value, str) or _MACHINE_ID_PATTERN.fullmatch(value) is None: + raise ValueError( + f"{field} must start with a lowercase letter and contain only " + "lowercase letters, digits, and underscores." + ) + return value + + +def _validate_member_id(value: object, *, field: str) -> str: + if not isinstance(value, str) or _MEMBER_ID_PATTERN.fullmatch(value) is None: + raise ValueError( + f"{field} must start with a letter and contain only letters, " + "digits, and underscores." + ) + return value + + +def _validate_version(value: object, *, field: str) -> str: + if ( + not isinstance(value, str) + or _SEMANTIC_VERSION_PATTERN.fullmatch(value) is None + ): + raise ValueError(f"{field} must use 'major.minor.patch'.") + return value + + +def _validate_label(value: object, *, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field} must not be empty.") + return value + + +def _validate_order(value: object, *, field: str) -> int: + if type(value) is not int: + raise ValueError(f"{field} must be an integer.") + return value + + +def _validate_quantity_unit( + quantity: object, + unit: object, + *, + field: str, +) -> None: + if not isinstance(quantity, str) or not quantity: + raise ValueError(f"{field} quantity must not be empty.") + if not isinstance(unit, str): + raise ValueError(f"{field} unit must be a string.") + try: + expected_unit = SI_UNIT_BY_QUANTITY[quantity] + except KeyError as exc: + raise ValueError( + f"{field} uses unsupported quantity '{quantity}'." + ) from exc + if unit != expected_unit: + raise ValueError( + f"{field} uses unit '{unit}' for quantity '{quantity}', " + f"expected SI unit '{expected_unit}'." + ) + + +def _validate_unique_names( + names: Sequence[str], + *, + field: str, +) -> None: + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise ValueError(f"{field} contains duplicate names: {', '.join(duplicates)}.") + + +def _validate_category( + category: ComponentCategorySpec, + *, + library_id: str, +) -> None: + if not isinstance(category, ComponentCategorySpec): + raise ValueError( + f"Library '{library_id}' categories must use ComponentCategorySpec." + ) + _validate_machine_id( + category.id, + field=f"Library '{library_id}' category id", + ) + _validate_label( + category.label, + field=f"Library '{library_id}' category '{category.id}' label", + ) + _validate_order( + category.order, + field=f"Library '{library_id}' category '{category.id}' order", + ) + + +def validate_component_library(library: ComponentLibrarySpec) -> None: + if not isinstance(library, ComponentLibrarySpec): + raise ValueError("Enabled component libraries must use ComponentLibrarySpec.") + library_id = _validate_machine_id(library.id, field="Component library id") + _validate_label(library.label, field=f"Library '{library_id}' label") + _validate_version(library.version, field=f"Library '{library_id}' version") + _validate_order(library.order, field=f"Library '{library_id}' order") + if type(library.temporary) is not bool: + raise ValueError(f"Library '{library_id}' temporary must be a boolean.") + if ( + not isinstance(library.source_package, str) + or not library.source_package.strip() + ): + raise ValueError(f"Library '{library_id}' source package must not be empty.") + if not library.categories: + raise ValueError(f"Library '{library_id}' must declare at least one category.") + if not library.models: + raise ValueError(f"Library '{library_id}' must declare at least one model.") + + for category in library.categories: + _validate_category(category, library_id=library_id) + category_ids = [category.id for category in library.categories] + _validate_unique_names( + category_ids, + field=f"Library '{library_id}' categories", + ) + + _validate_unique_names( + list(library.models), + field=f"Library '{library_id}' model references", + ) + for reference in library.models: + if not isinstance(reference, str) or ":" not in reference: + raise ValueError( + f"Library '{library_id}' contains invalid model reference " + f"'{reference}'." + ) + module_name = reference.rsplit(":", maxsplit=1)[0] + if not ( + module_name == library.source_package + or module_name.startswith(f"{library.source_package}.") + ): + raise ValueError( + f"Library '{library_id}' model '{reference}' is outside source " + f"package '{library.source_package}'." + ) + + +def _validate_port_variable( + variable: PortVariableDefinition, + *, + model_type: str, + port_name: str, +) -> None: + field = f"Component '{model_type}' port '{port_name}' variable" + if not isinstance(variable, PortVariableDefinition): + raise ValueError(f"{field} must use PortVariableDefinition.") + _validate_member_id(variable.name, field=f"{field} name") + _validate_label(variable.label or variable.name, field=f"{field} label") + _validate_quantity_unit( + variable.quantity or variable.name, + variable.unit, + field=f"{field} '{variable.name}'", + ) + _validate_order(variable.order, field=f"{field} '{variable.name}' order") + if variable.role not in {"effort", "flow", "stream", "signal"}: + raise ValueError(f"{field} '{variable.name}' has invalid role.") + if variable.connection_rule not in { + "equal", + "sumToZero", + "streamMix", + "directed", + }: + raise ValueError( + f"{field} '{variable.name}' has invalid connection rule." + ) + if type(variable.result_visible) is not bool: + raise ValueError( + f"{field} '{variable.name}' result_visible must be a boolean." + ) + + +def _validate_port(port: PortDefinition, *, model_type: str) -> None: + field = f"Component '{model_type}' port" + if not isinstance(port, PortDefinition): + raise ValueError(f"{field} declarations must use PortDefinition.") + _validate_machine_id(port.name, field=f"{field} name") + _validate_machine_id(port.domain, field=f"{field} '{port.name}' domain") + if port.kind not in {"physical", "signal"}: + raise ValueError(f"{field} '{port.name}' has invalid kind.") + if port.nominal_role not in { + "inlet", + "outlet", + "bidirectional", + "input", + "output", + }: + raise ValueError(f"{field} '{port.name}' has invalid nominal role.") + if port.kind == "physical" and port.positive_flow_direction != "intoComponent": + raise ValueError( + f"{field} '{port.name}' must use positive flow into the component." + ) + if port.kind == "physical" and port.nominal_role in {"input", "output"}: + raise ValueError( + f"{field} '{port.name}' uses a signal-only nominal role." + ) + if port.kind == "signal" and port.nominal_role not in {"input", "output"}: + raise ValueError( + f"{field} '{port.name}' must use input or output as its nominal role." + ) + + variable_names = [variable.name for variable in port.variables] + _validate_unique_names( + variable_names, + field=f"{field} '{port.name}' variables", + ) + for variable in port.variables: + _validate_port_variable( + variable, + model_type=model_type, + port_name=port.name, + ) + + +def _validate_parameter( + parameter: ParameterDefinition, + *, + model_type: str, +) -> None: + field = f"Component '{model_type}' parameter" + if not isinstance(parameter, ParameterDefinition): + raise ValueError(f"{field} declarations must use ParameterDefinition.") + _validate_member_id(parameter.name, field=f"{field} name") + _validate_label(parameter.label, field=f"{field} '{parameter.name}' label") + _validate_quantity_unit( + parameter.quantity, + parameter.unit, + field=f"{field} '{parameter.name}'", + ) + if not isinstance(parameter.default, (int, float)) or not isfinite( + parameter.default + ): + raise ValueError(f"{field} '{parameter.name}' default must be finite.") + for boundary_name, boundary in ( + ("minimum", parameter.minimum), + ("maximum", parameter.maximum), + ): + if boundary is not None and ( + not isinstance(boundary, (int, float)) or not isfinite(boundary) + ): + raise ValueError( + f"{field} '{parameter.name}' {boundary_name} must be finite." + ) + if type(parameter.minimum_exclusive) is not bool: + raise ValueError( + f"{field} '{parameter.name}' minimum_exclusive must be a boolean." + ) + if ( + parameter.minimum is not None + and parameter.maximum is not None + and parameter.minimum > parameter.maximum + ): + raise ValueError( + f"{field} '{parameter.name}' minimum exceeds its maximum." + ) + message = parameter.validation_message(parameter.default) + if message is not None: + raise ValueError( + f"{field} '{parameter.name}' default value {message}." + ) + + +def _validate_result_variable( + variable: ResultVariableDefinition, + *, + model_type: str, +) -> None: + field = f"Component '{model_type}' result variable" + if not isinstance(variable, ResultVariableDefinition): + raise ValueError( + f"{field} declarations must use ResultVariableDefinition." + ) + _validate_member_id(variable.name, field=f"{field} name") + _validate_label(variable.label, field=f"{field} '{variable.name}' label") + _validate_quantity_unit( + variable.quantity, + variable.unit, + field=f"{field} '{variable.name}'", + ) + _validate_machine_id( + variable.category, + field=f"{field} '{variable.name}' category", + ) + _validate_order(variable.order, field=f"{field} '{variable.name}' order") + if type(variable.visible) is not bool: + raise ValueError( + f"{field} '{variable.name}' visible must be a boolean." + ) + + +def validate_component_model_class( + component_class: type[Component], + *, + library: ComponentLibrarySpec, +) -> None: + if not isinstance(component_class, type) or not issubclass( + component_class, + Component, + ): + raise ValueError( + f"Library '{library.id}' model entries must be Component subclasses." + ) + + required_declarations = ( + "MODEL_TYPE", + "MODEL_VERSION", + "PORTS", + "PARAMETERS", + "RESULT_VARIABLES", + "DISPLAY", + "create", + ) + missing = [ + name for name in required_declarations if name not in component_class.__dict__ + ] + if missing: + raise ValueError( + f"Component class '{component_class.__name__}' must declare: " + + ", ".join(missing) + + "." + ) + if not isinstance(component_class.__dict__["create"], classmethod): + raise ValueError( + f"Component class '{component_class.__name__}' create must be a classmethod." + ) + + model_type = _validate_machine_id( + component_class.MODEL_TYPE, + field=f"Component class '{component_class.__name__}' model type", + ) + _validate_version( + component_class.MODEL_VERSION, + field=f"Component '{model_type}' model version", + ) + + display = component_class.DISPLAY + if not isinstance(display, ComponentDisplaySpec): + raise ValueError( + f"Component '{model_type}' DISPLAY must use ComponentDisplaySpec." + ) + _validate_label(display.label, field=f"Component '{model_type}' display label") + _validate_machine_id( + display.symbol, + field=f"Component '{model_type}' display symbol", + ) + _validate_order(display.order, field=f"Component '{model_type}' display order") + if not isinstance(display.ports, tuple): + raise ValueError(f"Component '{model_type}' DISPLAY ports must be a tuple.") + if display.library_id != library.id: + raise ValueError( + f"Component '{model_type}' references library '{display.library_id}', " + f"expected '{library.id}'." + ) + if display.category_id not in library.category_by_id: + raise ValueError( + f"Component '{model_type}' references unknown category " + f"'{display.category_id}' in library '{library.id}'." + ) + + ports = component_class.PORTS + parameters = component_class.PARAMETERS + result_variables = component_class.RESULT_VARIABLES + if not isinstance(ports, tuple): + raise ValueError(f"Component '{model_type}' PORTS must be a tuple.") + if not isinstance(parameters, tuple): + raise ValueError(f"Component '{model_type}' PARAMETERS must be a tuple.") + if not isinstance(result_variables, tuple): + raise ValueError( + f"Component '{model_type}' RESULT_VARIABLES must be a tuple." + ) + + for port in ports: + _validate_port(port, model_type=model_type) + for parameter in parameters: + _validate_parameter(parameter, model_type=model_type) + for variable in result_variables: + _validate_result_variable(variable, model_type=model_type) + for port in display.ports: + if not isinstance(port, PortDisplaySpec): + raise ValueError( + f"Component '{model_type}' display ports must use PortDisplaySpec." + ) + _validate_machine_id( + port.name, + field=f"Component '{model_type}' display port name", + ) + if port.side not in {"left", "right"}: + raise ValueError( + f"Component '{model_type}' display port '{port.name}' " + "must use side 'left' or 'right'." + ) + _validate_order( + port.order, + field=f"Component '{model_type}' display port '{port.name}' order", + ) + + port_names = [port.name for port in ports] + parameter_names = [parameter.name for parameter in parameters] + result_names = [variable.name for variable in result_variables] + display_port_names = [port.name for port in display.ports] + _validate_unique_names(port_names, field=f"Component '{model_type}' ports") + _validate_unique_names( + parameter_names, + field=f"Component '{model_type}' parameters", + ) + _validate_unique_names( + result_names, + field=f"Component '{model_type}' result variables", + ) + _validate_unique_names( + display_port_names, + field=f"Component '{model_type}' display ports", + ) + if set(port_names) != set(display_port_names): + raise ValueError( + f"Component '{model_type}' display ports must exactly match " + "its physical port declarations." + ) + + +def discover_component_registries( + library_references: Sequence[str] = ENABLED_COMPONENT_LIBRARIES, +) -> tuple[ + dict[str, ComponentLibrarySpec], + dict[str, ComponentModelSpec], +]: + libraries: dict[str, ComponentLibrarySpec] = {} + models: dict[str, ComponentModelSpec] = {} + + for library_reference in library_references: + library = _load_reference(library_reference) + if not isinstance(library, ComponentLibrarySpec): + raise ValueError( + f"Enabled library '{library_reference}' must reference " + "ComponentLibrarySpec." + ) + validate_component_library(library) + if library.id in libraries: + raise ValueError(f"Duplicate component library id: {library.id}.") + libraries[library.id] = library + + for model_reference in library.models: + component_class = _load_reference(model_reference) + if not isinstance(component_class, type) or not issubclass( + component_class, + Component, + ): + raise ValueError( + f"Library '{library.id}' model '{model_reference}' is not " + "a Component subclass." + ) + validate_component_model_class(component_class, library=library) + model_type = cast(str, component_class.MODEL_TYPE) + if model_type in models: + existing = models[model_type].component_class + raise ValueError( + f"Duplicate component model type '{model_type}' from " + f"{existing.__module__}.{existing.__name__} and " + f"{component_class.__module__}.{component_class.__name__}." + ) + spec = ComponentModelSpec( + component_class=component_class, + library=library, + ) + try: + spec.create( + f"__catalog_validation_{model_type}", + IdealGasMedium(), + {}, + ) + except Exception as exc: + raise ValueError( + f"Component model '{model_type}' cannot be created with " + "its declared defaults." + ) from exc + models[model_type] = spec + + if not libraries: + raise ValueError("At least one component library must be enabled.") + return libraries, models + + +COMPONENT_LIBRARY_REGISTRY, COMPONENT_MODEL_REGISTRY = ( + discover_component_registries() +) + + +def validate_component_registries() -> None: + """Re-run public registry invariants for tests and startup diagnostics.""" + + discovered_libraries, discovered_models = discover_component_registries() + if tuple(discovered_libraries) != tuple(COMPONENT_LIBRARY_REGISTRY): + raise ValueError("Component library registry differs from discovery output.") + if tuple(discovered_models) != tuple(COMPONENT_MODEL_REGISTRY): + raise ValueError("Component model registry differs from discovery output.") + + +def build_component_catalog() -> dict[str, object]: + components_by_library: dict[str, list[ComponentModelSpec]] = { + library_id: [] for library_id in COMPONENT_LIBRARY_REGISTRY + } + for component in COMPONENT_MODEL_REGISTRY.values(): + components_by_library[component.library.id].append(component) + + libraries: list[dict[str, object]] = [] + for library in sorted( + COMPONENT_LIBRARY_REGISTRY.values(), + key=lambda item: (item.order, item.id), + ): + payload = library.as_catalog_dict() + payload["components"] = [ + component.as_catalog_dict() + for component in sorted( + components_by_library[library.id], + key=lambda item: (item.display.order, item.model_type), + ) + ] + libraries.append(payload) + + return { + "schemaVersion": 1, + "libraries": libraries, + } + + +def get_component_model_spec(model_type: str) -> ComponentModelSpec: + try: + return COMPONENT_MODEL_REGISTRY[model_type] + except KeyError as exc: + raise ValueError(f"Unsupported model type: {model_type}.") from exc diff --git a/app/simulation/reporting/__init__.py b/app/simulation/reporting/__init__.py new file mode 100644 index 0000000..3196560 --- /dev/null +++ b/app/simulation/reporting/__init__.py @@ -0,0 +1,23 @@ +from app.simulation.reporting.testmodel_outputs import ( + COMPARISON_KEYS, + MODELICA_COMPARISON_COLUMNS, + PRIMARY_KEYS, + TestModelArtifacts, + export_testmodel_artifacts, + format_testmodel_run_report, + load_modelica_series, + write_testmodel_run_report, + write_modelica_comparison, +) + +__all__ = [ + "COMPARISON_KEYS", + "MODELICA_COMPARISON_COLUMNS", + "PRIMARY_KEYS", + "TestModelArtifacts", + "export_testmodel_artifacts", + "format_testmodel_run_report", + "load_modelica_series", + "write_testmodel_run_report", + "write_modelica_comparison", +] diff --git a/app/simulation/reporting/testmodel_outputs.py b/app/simulation/reporting/testmodel_outputs.py new file mode 100644 index 0000000..9338b1c --- /dev/null +++ b/app/simulation/reporting/testmodel_outputs.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +from bisect import bisect_left +import csv +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +PRIMARY_KEYS = ( + "mytank.p", + "mytank.T", + "mycylinder.p", + "mycylinder.T", +) + +MODELICA_COMPARISON_COLUMNS = { + "mytank.p": "mytank.p", + "mytank.T": "mytank.T", + "mycylinder.p": "mycylinder.p", + "mycylinder.T": "mycylinder.T", + "branch.upper_branch.p": "mypipe.p", + "branch.upper_branch.in": "myorifice.port_a.m_flow", + "branch.upper_branch.out": "mytee1.port_out2.m_flow", + "branch.lower_branch.p": "mypipe1.p", + "branch.lower_branch.in": "myorifice1.port_a.m_flow", + "branch.lower_branch.out": "mytee1.port_out1.m_flow", +} + +COMPARISON_KEYS = tuple(MODELICA_COMPARISON_COLUMNS.keys()) + + +def _branch_series_values( + series: dict[str, list[float]], + branch_name: str, + legacy_key: str, +) -> list[float]: + generic_key = f"branch.{branch_name}.{legacy_key.split('.')[-1]}" + if generic_key in series: + return series[generic_key] + return series[legacy_key] + + +@dataclass(frozen=True) +class TestModelArtifacts: + primary_csv_path: Path + temperature_csv_path: Path + temperature_svg_path: Path + run_report_path: Path + comparison_csv_path: Path | None = None + comparison_summary_path: Path | None = None + + +def format_testmodel_run_report( + *, + network_summary: str, + initialization: Any, + raw_initial_state: tuple[float, ...], + consistent_initial_state: tuple[float, ...], + solution: Any, + series: dict[str, list[float]], + solve_diagnostics: Any, + artifacts: TestModelArtifacts, + comparison_summary: dict[str, tuple[float, float]] | None, +) -> str: + lines = [ + network_summary, + "", + f"Initialization converged: {initialization.converged}", + f"Initialization iterations: {initialization.iterations}", + f"Initialization max state delta: {initialization.max_state_delta:.6e}", + f"Initialization max flow delta: {initialization.max_flow_delta:.6e}", + f"Initialization max enthalpy delta: {initialization.max_enthalpy_delta:.6e}", + ( + "Initialization downstream pressure spread: " + f"{initialization.downstream_pressure_spread:.6e}" + ), + "", + "Raw initial state vector:", + str(list(raw_initial_state)), + "", + "Constraint-consistent initial state vector:", + str(list(consistent_initial_state)), + "", + f"Solver success: {solution.success}", + f"Solver message: {solution.message}", + f"Final time: {solution.t[-1]:.2f} s", + f"Final tank pressure: {series['mytank.p'][-1]:.3f} Pa", + f"Final tank temperature: {series['mytank.T'][-1]:.3f} K", + f"Final cylinder pressure: {series['mycylinder.p'][-1]:.3f} Pa", + ( + "Final branch inflow: " + f"{_branch_series_values(series, 'upper_branch', 'branch_upper.in')[-1] + _branch_series_values(series, 'lower_branch', 'branch_lower.in')[-1]:.6f} kg/s" + ), + ] + + if solve_diagnostics is not None: + lines.extend( + [ + "", + "Final closure solve diagnostics:", + ( + "Upper branch inlet solve: " + f"converged={solve_diagnostics.upper_branch_inlet.converged}, " + f"iterations={solve_diagnostics.upper_branch_inlet.iterations}, " + f"residual={solve_diagnostics.upper_branch_inlet.residual:.6e}" + ), + ( + "Lower branch inlet solve: " + f"converged={solve_diagnostics.lower_branch_inlet.converged}, " + f"iterations={solve_diagnostics.lower_branch_inlet.iterations}, " + f"residual={solve_diagnostics.lower_branch_inlet.residual:.6e}" + ), + ] + ) + if solve_diagnostics.downstream_pressure_projection is not None: + lines.append( + "Downstream pressure projection: " + f"converged={solve_diagnostics.downstream_pressure_projection.converged}, " + f"iterations={solve_diagnostics.downstream_pressure_projection.iterations}, " + f"residual={solve_diagnostics.downstream_pressure_projection.residual:.6e}" + ) + + lines.extend( + [ + f"Primary series CSV: {artifacts.primary_csv_path}", + f"Temperature CSV: {artifacts.temperature_csv_path}", + f"Temperature plot: {artifacts.temperature_svg_path}", + f"Run report TXT: {artifacts.run_report_path}", + ] + ) + + if ( + artifacts.comparison_csv_path is not None + and artifacts.comparison_summary_path is not None + ): + lines.extend( + [ + f"Modelica comparison CSV: {artifacts.comparison_csv_path}", + f"Modelica comparison summary: {artifacts.comparison_summary_path}", + ] + ) + if comparison_summary is not None: + for key, (max_abs_error, max_rel_error) in comparison_summary.items(): + lines.append( + f"{key} max abs error: {max_abs_error:.6f}, " + f"max rel error: {max_rel_error:.6%}" + ) + + return "\n".join(lines) + "\n" + + +def write_testmodel_run_report(output_dir: Path, report_text: str) -> Path: + report_path = output_dir / "testmodel_run_report.txt" + report_path.write_text(report_text, encoding="utf-8") + return report_path + + +def _write_primary_series_csv(output_dir: Path, series: dict[str, list[float]]) -> Path: + csv_path = output_dir / "testmodel_primary_series.csv" + with csv_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(["time_s", *PRIMARY_KEYS]) + for index, time_value in enumerate(series["time"]): + writer.writerow([time_value, *(series[key][index] for key in PRIMARY_KEYS)]) + return csv_path + + +def _write_temperature_csv(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path: + csv_path = output_dir / "testmodel_tank_temperature.csv" + with csv_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(["time_s", "mytank_T_K"]) + writer.writerows(zip(time_values, temperatures)) + return csv_path + + +def _write_temperature_svg(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path: + svg_path = output_dir / "testmodel_tank_temperature.svg" + + width = 900 + height = 520 + left = 90 + right = 40 + top = 60 + bottom = 70 + plot_width = width - left - right + plot_height = height - top - bottom + + min_time = min(time_values) + max_time = max(time_values) + min_temp = min(temperatures) + max_temp = max(temperatures) + temp_padding = max(1.0, (max_temp - min_temp) * 0.08) + min_temp -= temp_padding + max_temp += temp_padding + + def scale_x(value: float) -> float: + return left + (value - min_time) / max(max_time - min_time, 1e-12) * plot_width + + def scale_y(value: float) -> float: + return top + (max_temp - value) / max(max_temp - min_temp, 1e-12) * plot_height + + points = " ".join( + f"{scale_x(time_value):.2f},{scale_y(temperature):.2f}" + for time_value, temperature in zip(time_values, temperatures) + ) + + x_ticks = 5 + y_ticks = 5 + x_tick_markup = [] + y_tick_markup = [] + + for index in range(x_ticks + 1): + fraction = index / x_ticks + time_value = min_time + fraction * (max_time - min_time) + x = left + fraction * plot_width + x_tick_markup.append( + f'' + ) + x_tick_markup.append( + f'' + f"{time_value:.1f}" + ) + + for index in range(y_ticks + 1): + fraction = index / y_ticks + temp_value = min_temp + fraction * (max_temp - min_temp) + y = top + plot_height - fraction * plot_height + y_tick_markup.append( + f'' + ) + y_tick_markup.append( + f'' + f"{temp_value:.1f}" + ) + + svg_content = f""" + + Python Testmodel Tank Temperature + Time (s) + Temperature (K) + + {''.join(x_tick_markup)} + {''.join(y_tick_markup)} + + +""" + svg_path.write_text(svg_content, encoding="utf-8") + return svg_path + + +def load_modelica_series(csv_path: Path, variable_names: tuple[str, ...]) -> dict[str, list[float]]: + series = {"time": []} + for variable_name in variable_names: + series[variable_name] = [] + + with csv_path.open("r", newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + available_variable_names = tuple( + variable_name + for variable_name in variable_names + if MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name) in (reader.fieldnames or ()) + ) + for row in reader: + series["time"].append(float(row["time"])) + for variable_name in available_variable_names: + modelica_column = MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name) + series[variable_name].append(float(row[modelica_column])) + + return series + + +def _interpolate_series_value(time_values: list[float], values: list[float], target_time: float) -> float: + if target_time <= time_values[0]: + return values[0] + if target_time >= time_values[-1]: + return values[-1] + + right_index = bisect_left(time_values, target_time) + if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1e-12: + return values[right_index] + + left_index = right_index - 1 + left_time = time_values[left_index] + right_time = time_values[right_index] + fraction = (target_time - left_time) / (right_time - left_time) + return values[left_index] + fraction * (values[right_index] - values[left_index]) + + +def write_modelica_comparison( + output_dir: Path, + python_series: dict[str, list[float]], + modelica_series: dict[str, list[float]], +) -> tuple[Path, Path, dict[str, tuple[float, float]]]: + comparison_csv_path = output_dir / "testmodel_modelica_comparison.csv" + summary_path = output_dir / "testmodel_modelica_comparison_summary.txt" + summary: dict[str, tuple[float, float]] = {} + + with comparison_csv_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + header = ["time_s"] + comparison_keys = tuple( + key + for key in COMPARISON_KEYS + if key in python_series and key in modelica_series and modelica_series[key] + ) + for key in comparison_keys: + header.extend( + [ + f"python.{key}", + f"modelica.{key}", + f"abs_error.{key}", + f"rel_error.{key}", + ] + ) + writer.writerow(header) + + max_abs_errors = {key: 0.0 for key in comparison_keys} + max_rel_errors = {key: 0.0 for key in comparison_keys} + + for index, time_value in enumerate(python_series["time"]): + row = [time_value] + for key in comparison_keys: + python_value = python_series[key][index] + modelica_value = _interpolate_series_value( + modelica_series["time"], + modelica_series[key], + time_value, + ) + abs_error = abs(python_value - modelica_value) + rel_error = abs_error / max(abs(modelica_value), 1e-9) + max_abs_errors[key] = max(max_abs_errors[key], abs_error) + max_rel_errors[key] = max(max_rel_errors[key], rel_error) + row.extend([python_value, modelica_value, abs_error, rel_error]) + writer.writerow(row) + + summary_lines = [] + for key in comparison_keys: + summary[key] = (max_abs_errors[key], max_rel_errors[key]) + summary_lines.append( + f"{key}: max_abs_error={max_abs_errors[key]:.6f}, " + f"max_rel_error={max_rel_errors[key]:.6%}" + ) + summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8") + return comparison_csv_path, summary_path, summary + + +def export_testmodel_artifacts( + *, + output_dir: Path, + series: dict[str, list[float]], + modelica_series: dict[str, list[float]] | None = None, +) -> tuple[TestModelArtifacts, dict[str, tuple[float, float]] | None]: + output_dir.mkdir(parents=True, exist_ok=True) + + primary_csv_path = _write_primary_series_csv(output_dir, series) + temperature_csv_path = _write_temperature_csv( + output_dir, + series["time"], + series["mytank.T"], + ) + temperature_svg_path = _write_temperature_svg( + output_dir, + series["time"], + series["mytank.T"], + ) + + comparison_csv_path = None + comparison_summary_path = None + comparison_summary = None + if modelica_series is not None: + ( + comparison_csv_path, + comparison_summary_path, + comparison_summary, + ) = write_modelica_comparison(output_dir, series, modelica_series) + + return ( + TestModelArtifacts( + primary_csv_path=primary_csv_path, + temperature_csv_path=temperature_csv_path, + temperature_svg_path=temperature_svg_path, + run_report_path=output_dir / "testmodel_run_report.txt", + comparison_csv_path=comparison_csv_path, + comparison_summary_path=comparison_summary_path, + ), + comparison_summary, + ) diff --git a/app/simulation/solvers/__init__.py b/app/simulation/solvers/__init__.py new file mode 100644 index 0000000..8d4841c --- /dev/null +++ b/app/simulation/solvers/__init__.py @@ -0,0 +1 @@ +"""Numerical solvers used by simulation systems.""" diff --git a/app/simulation/solvers/algebraic.py b/app/simulation/solvers/algebraic.py new file mode 100644 index 0000000..bd3216d --- /dev/null +++ b/app/simulation/solvers/algebraic.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import sqrt + +from app.simulation.core.ports import PortState, VariableRole +from app.simulation.systems.network import SimulationNetwork + + +class AlgebraicSolveError(RuntimeError): + def __init__(self, message: str, diagnostics: "AlgebraicSolveDiagnostics") -> None: + super().__init__(message) + self.diagnostics = diagnostics + + +@dataclass(frozen=True) +class AlgebraicUnknown: + component: str + port: str + variable: str + role: VariableRole + state: PortState + + @property + def id(self) -> str: + return f"{self.component}.{self.port}.{self.variable}" + + def read(self) -> float: + return float(getattr(self.state, self.variable)) + + def write(self, value: float) -> None: + setattr(self.state, self.variable, float(value)) + + +@dataclass(frozen=True) +class AlgebraicSolveDiagnostics: + success: bool + message: str + evaluations: int + pressure_scale: float + flow_scale: float + max_scaled_residual: float + max_raw_residual: float + + def as_dict(self) -> dict[str, object]: + return { + "success": self.success, + "message": self.message, + "evaluations": self.evaluations, + "pressureScale": self.pressure_scale, + "flowScale": self.flow_scale, + "maxScaledResidual": self.max_scaled_residual, + "maxRawResidual": self.max_raw_residual, + } + + +class PressureFlowSolver: + """Solve the acausal pressure-flow subsystem for a compiled network.""" + + def __init__( + self, + network: SimulationNetwork, + *, + residual_tolerance: float = 1e-7, + max_evaluations: int = 500, + ) -> None: + self.network = network + self.residual_tolerance = residual_tolerance + self.max_evaluations = max_evaluations + self.unknowns = self._build_unknowns() + self.last_diagnostics: AlgebraicSolveDiagnostics | None = None + + def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]: + unknowns: list[AlgebraicUnknown] = [] + for component in self.network.components.values(): + for definition in component.port_definitions: + if definition.kind != "physical": + continue + state = component.get_port(definition.name) + for variable in definition.variables: + if variable.role not in {"effort", "flow"}: + continue + unknowns.append( + AlgebraicUnknown( + component=component.name, + port=definition.name, + variable=variable.name, + role=variable.role, + state=state, + ) + ) + return tuple(unknowns) + + def _seed_equal_pressures(self) -> None: + for _ in range(max(2, len(self.network.connections))): + changed = False + for connection in self.network.connections: + if connection.kind != "physical": + continue + first = self.network.components[ + connection.endpoint_a.component + ].get_port(connection.endpoint_a.port) + second = self.network.components[ + connection.endpoint_b.component + ].get_port(connection.endpoint_b.port) + if first.p > 0.0 and second.p <= 0.0: + second.p = first.p + changed = True + elif second.p > 0.0 and first.p <= 0.0: + first.p = second.p + changed = True + + for component in self.network.components.values(): + equal_pressure_equations = [ + equation + for equation in component.pressure_flow_equation_residuals() + if equation.relation == "equal" and equation.role == "effort" + ] + for equation in equal_pressure_equations: + states = [] + for variable in equation.variables: + _, port_name, variable_name = variable.rsplit(".", 2) + if variable_name == "p": + states.append(component.get_port(port_name)) + if len(states) != 2: + continue + first, second = states + if first.p > 0.0 and second.p <= 0.0: + second.p = first.p + changed = True + elif second.p > 0.0 and first.p <= 0.0: + first.p = second.p + changed = True + if not changed: + break + + def _scales(self) -> tuple[float, float]: + pressure_scale = max( + [ + abs(unknown.read()) + for unknown in self.unknowns + if unknown.role == "effort" and unknown.read() > 0.0 + ] + + [1e5] + ) + estimated_flows = [ + abs(float(getattr(component, "K_eff"))) * sqrt(pressure_scale) + for component in self.network.components.values() + if hasattr(component, "K_eff") + ] + flow_scale = max( + estimated_flows + + [ + abs(unknown.read()) + for unknown in self.unknowns + if unknown.role == "flow" + ] + + [1e-3] + ) + return pressure_scale, flow_scale + + def solve(self) -> AlgebraicSolveDiagnostics: + try: + import numpy as np + from scipy.optimize import least_squares + except ImportError as exc: + raise RuntimeError( + "Topology-driven simulation requires SciPy; install requirements.txt." + ) from exc + + self._seed_equal_pressures() + pressure_scale, flow_scale = self._scales() + positive_pressures = [ + unknown.read() + for unknown in self.unknowns + if unknown.role == "effort" and unknown.read() > 0.0 + ] + fallback_pressure = ( + sum(positive_pressures) / len(positive_pressures) + if positive_pressures + else pressure_scale + ) + + def variable_scale(unknown: AlgebraicUnknown) -> float: + return pressure_scale if unknown.role == "effort" else flow_scale + + x0 = np.asarray( + [ + ( + unknown.read() + if unknown.role != "effort" or unknown.read() > 0.0 + else fallback_pressure + ) + / variable_scale(unknown) + for unknown in self.unknowns + ], + dtype=float, + ) + lower = np.asarray( + [ + 1.0 / pressure_scale if unknown.role == "effort" else -np.inf + for unknown in self.unknowns + ] + ) + upper = np.full(len(self.unknowns), np.inf) + + def assign(values) -> None: + for unknown, value in zip(self.unknowns, values): + unknown.write(float(value) * variable_scale(unknown)) + + def scaled_residuals(values): + assign(values) + equations = self.network.pressure_flow_equation_residuals() + return np.asarray( + [ + equation.value + / (pressure_scale if equation.role == "effort" else flow_scale) + for equation in equations + ], + dtype=float, + ) + + result = least_squares( + scaled_residuals, + x0, + bounds=(lower, upper), + x_scale="jac", + ftol=1e-10, + xtol=1e-10, + gtol=1e-10, + max_nfev=self.max_evaluations, + ) + assign(result.x) + equations = self.network.pressure_flow_equation_residuals() + scaled = [ + abs( + equation.value + / (pressure_scale if equation.role == "effort" else flow_scale) + ) + for equation in equations + ] + success = bool(result.success) and max(scaled, default=0.0) <= self.residual_tolerance + diagnostics = AlgebraicSolveDiagnostics( + success=success, + message=str(result.message), + evaluations=int(result.nfev), + pressure_scale=pressure_scale, + flow_scale=flow_scale, + max_scaled_residual=max(scaled, default=0.0), + max_raw_residual=max((abs(item.value) for item in equations), default=0.0), + ) + self.last_diagnostics = diagnostics + if not success: + raise AlgebraicSolveError( + "Pressure-flow equations did not converge to the requested tolerance.", + diagnostics, + ) + return diagnostics diff --git a/app/simulation/solvers/solver.py b/app/simulation/solvers/solver.py new file mode 100644 index 0000000..086a5df --- /dev/null +++ b/app/simulation/solvers/solver.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Literal + + +CancellationCheck = Callable[[], bool] +AcceptedStepCallback = Callable[[float], None] +IntegrationStatus = Literal["completed", "cancelled", "failed"] + + +class _IntegrationCancelled(Exception): + pass + + +@dataclass(frozen=True) +class SolveIVPConfig: + t_start: float = 0.0 + t_stop: float = 20.0 + method: str = "BDF" + rtol: float = 1e-6 + atol: float = 1e-8 + max_step: float = 1e-3 + + +@dataclass(frozen=True) +class ODESolution: + t: list[float] + y: list[list[float]] + success: bool + message: str + status: IntegrationStatus = "completed" + error: Exception | None = None + + +def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]: + return [x + scale * y for x, y in zip(a, b)] + + +def _append_solution_sample( + times: list[float], + states: list[list[float]], + time: float, + state: list[float], +) -> None: + if times and time <= times[-1] + 1e-12: + return + times.append(float(time)) + for index, value in enumerate(state): + states[index].append(float(value)) + + +def _runge_kutta_4( + rhs: Callable[[float, list[float]], list[float]], + initial_state: list[float], + config: SolveIVPConfig, + t_eval: list[float] | None, + cancel_check: CancellationCheck | None = None, + accepted_step_callback: AcceptedStepCallback | None = None, +) -> ODESolution: + if t_eval is None: + point_count = max( + 2, + int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1, + ) + step = (config.t_stop - config.t_start) / (point_count - 1) + t_eval = [config.t_start + index * step for index in range(point_count)] + + state = list(initial_state) + states = [[value] for value in state] + times = [float(t_eval[0])] + current_time = float(t_eval[0]) + status: IntegrationStatus = "completed" + message = "Integrated with built-in RK4 fallback because SciPy is unavailable." + error: Exception | None = None + + try: + for target_time in t_eval[1:]: + while current_time < target_time - 1e-15: + if cancel_check is not None and cancel_check(): + raise _IntegrationCancelled + dt = min(config.max_step, target_time - current_time) + k1 = rhs(current_time, state) + k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt)) + k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt)) + k4 = rhs(current_time + dt, _vector_add(state, k3, dt)) + state = [ + value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d) + for value, a, b, c, d in zip(state, k1, k2, k3, k4) + ] + current_time += dt + if accepted_step_callback is not None: + accepted_step_callback(current_time) + + _append_solution_sample(times, states, target_time, state) + except _IntegrationCancelled: + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + _append_solution_sample(times, states, current_time, state) + except Exception as exc: + status = "failed" + message = str(exc) + error = exc + _append_solution_sample(times, states, current_time, state) + + return ODESolution( + t=times, + y=states, + success=status == "completed", + message=message, + status=status, + error=error, + ) + + +def _integrate_scipy_stepwise( + rhs: Callable[[float, list[float]], list[float]], + initial_state: list[float], + config: SolveIVPConfig, + t_eval: list[float] | None, + cancel_check: CancellationCheck, + accepted_step_callback: AcceptedStepCallback | None, +) -> ODESolution: + import numpy as np + from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau + + solver_types = { + "BDF": BDF, + "DOP853": DOP853, + "LSODA": LSODA, + "RK23": RK23, + "RK45": RK45, + "Radau": Radau, + } + solver_type = solver_types.get(config.method) + if solver_type is None: + raise ValueError(f"Unsupported integration method: {config.method}") + + times = [float(config.t_start)] + states = [[float(value)] for value in initial_state] + last_accepted_time = float(config.t_start) + last_accepted_state = [float(value) for value in initial_state] + sample_times = list(t_eval or []) + sample_index = 0 + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= config.t_start + 1e-12 + ): + sample_index += 1 + + def cancellable_rhs(time, state): + if cancel_check(): + raise _IntegrationCancelled + return rhs(float(time), [float(value) for value in state]) + + if cancel_check(): + return ODESolution( + t=times, + y=states, + success=False, + message="Simulation was stopped before integration started.", + status="cancelled", + ) + + try: + solver = solver_type( + cancellable_rhs, + config.t_start, + np.asarray(initial_state, dtype=float), + config.t_stop, + rtol=config.rtol, + atol=config.atol, + max_step=config.max_step, + ) + except _IntegrationCancelled: + return ODESolution( + t=times, + y=states, + success=False, + message="Simulation was stopped before integration started.", + status="cancelled", + ) + except Exception as exc: + return ODESolution( + t=times, + y=states, + success=False, + message=str(exc), + status="failed", + error=exc, + ) + + status: IntegrationStatus = "completed" + message = "The solver successfully reached the end of the integration interval." + error: Exception | None = None + + while solver.status == "running": + if cancel_check(): + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + break + try: + step_message = solver.step() + except _IntegrationCancelled: + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + break + except Exception as exc: + status = "failed" + message = str(exc) + error = exc + break + + if solver.status == "failed": + status = "failed" + message = str(step_message or "Integration step failed.") + break + + last_accepted_time = float(solver.t) + last_accepted_state = [float(value) for value in solver.y] + if sample_times: + dense_output = solver.dense_output() + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= last_accepted_time + 1e-12 + ): + sample_time = float(sample_times[sample_index]) + sample_state = [float(value) for value in dense_output(sample_time)] + _append_solution_sample(times, states, sample_time, sample_state) + sample_index += 1 + else: + _append_solution_sample( + times, + states, + last_accepted_time, + last_accepted_state, + ) + if accepted_step_callback is not None: + accepted_step_callback(last_accepted_time) + + if status != "completed": + _append_solution_sample( + times, + states, + last_accepted_time, + last_accepted_state, + ) + + return ODESolution( + t=times, + y=states, + success=status == "completed", + message=message, + status=status, + error=error, + ) + + +def integrate_ode( + rhs: Callable[[float, list[float]], list[float]], + initial_state: list[float], + config: SolveIVPConfig, + t_eval: list[float] | None = None, + cancel_check: CancellationCheck | None = None, + accepted_step_callback: AcceptedStepCallback | None = None, +): + """Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback.""" + + if abs(config.t_stop - config.t_start) <= 1e-15: + return ODESolution( + t=[float(config.t_start)], + y=[[value] for value in initial_state], + success=True, + message="Skipped integration because t_start equals t_stop.", + ) + + try: + from scipy.integrate import solve_ivp + except ImportError: + return _runge_kutta_4( + rhs, + initial_state, + config, + t_eval, + cancel_check, + accepted_step_callback, + ) + + if cancel_check is not None: + return _integrate_scipy_stepwise( + rhs, + initial_state, + config, + t_eval, + cancel_check, + accepted_step_callback, + ) + + return solve_ivp( + fun=rhs, + t_span=(config.t_start, config.t_stop), + y0=initial_state, + method=config.method, + rtol=config.rtol, + atol=config.atol, + max_step=config.max_step, + t_eval=t_eval, + ) diff --git a/app/simulation/solvers/stream.py b/app/simulation/solvers/stream.py new file mode 100644 index 0000000..573d4d1 --- /dev/null +++ b/app/simulation/solvers/stream.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.simulation.core.base import DynamicComponent +from app.simulation.systems.network import Endpoint, SimulationNetwork + + +class StreamSolveError(RuntimeError): + def __init__(self, message: str, diagnostics: "StreamSolveDiagnostics") -> None: + super().__init__(message) + self.diagnostics = diagnostics + + +@dataclass(frozen=True) +class StreamSolveDiagnostics: + converged: bool + iterations: int + max_delta: float + + def as_dict(self) -> dict[str, object]: + return { + "converged": self.converged, + "iterations": self.iterations, + "maxDelta": self.max_delta, + } + + +class StreamResolver: + """Resolve outflow enthalpy propagation after pressure and flow are known.""" + + def __init__( + self, + network: SimulationNetwork, + *, + relative_tolerance: float = 1e-9, + max_iterations: int = 100, + ) -> None: + self.network = network + self.relative_tolerance = relative_tolerance + self.max_iterations = max_iterations + self._connected_endpoint = self._build_connection_map() + self.last_diagnostics: StreamSolveDiagnostics | None = None + + def _build_connection_map(self) -> dict[Endpoint, Endpoint]: + result: dict[Endpoint, Endpoint] = {} + for connection in self.network.connections: + if connection.kind != "physical": + continue + first, second = connection.endpoints + result[first] = second + result[second] = first + return result + + def connected_enthalpies(self) -> dict[str, dict[str, float]]: + values: dict[str, dict[str, float]] = { + component.name: {} for component in self.network.components.values() + } + for endpoint, connected in self._connected_endpoint.items(): + connected_port = self.network.components[connected.component].get_port( + connected.port + ) + values[endpoint.component][endpoint.port] = connected_port.h_outflow + return values + + def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]: + dynamic_components = [ + component + for component in self.network.components.values() + if isinstance(component, DynamicComponent) + ] + for component in dynamic_components: + component.refresh_thermodynamic_ports() + + max_delta = 0.0 + for iteration in range(1, self.max_iterations + 1): + previous = { + (component.name, port_name): port.h_outflow + for component in self.network.components.values() + for port_name, port in component.ports.items() + } + connected = self.connected_enthalpies() + for component in self.network.components.values(): + if isinstance(component, DynamicComponent): + component.refresh_thermodynamic_ports() + else: + component.update_stream_outflows(connected[component.name]) + + deltas = [ + abs(port.h_outflow - previous[(component.name, port_name)]) + for component in self.network.components.values() + for port_name, port in component.ports.items() + ] + magnitudes = [ + abs(port.h_outflow) + for component in self.network.components.values() + for port in component.ports.values() + ] + max_delta = max(deltas, default=0.0) + scale = max(magnitudes + [1.0]) + if max_delta <= self.relative_tolerance * scale: + diagnostics = StreamSolveDiagnostics( + converged=True, + iterations=iteration, + max_delta=max_delta, + ) + self.last_diagnostics = diagnostics + return diagnostics, self.connected_enthalpies() + + diagnostics = StreamSolveDiagnostics( + converged=False, + iterations=self.max_iterations, + max_delta=max_delta, + ) + self.last_diagnostics = diagnostics + raise StreamSolveError( + "Stream enthalpy propagation did not converge.", + diagnostics, + ) diff --git a/app/simulation/systems/__init__.py b/app/simulation/systems/__init__.py new file mode 100644 index 0000000..187800c --- /dev/null +++ b/app/simulation/systems/__init__.py @@ -0,0 +1,2 @@ +"""System assembly modules.""" + diff --git a/app/simulation/systems/generic.py b/app/simulation/systems/generic.py new file mode 100644 index 0000000..d3ae1ea --- /dev/null +++ b/app/simulation/systems/generic.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from math import floor, isfinite +from typing import Literal + +from app.simulation.core.base import DynamicComponent +from app.simulation.core.metadata import ResultVariableMetadata +from app.simulation.solvers.algebraic import PressureFlowSolver +from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode +from app.simulation.solvers.stream import StreamResolver +from app.simulation.systems.network import Endpoint, SimulationNetwork + + +SimulationProgressCallback = Callable[[float, str], None] +SimulationCancellationCheck = Callable[[], bool] +SimulationRunStatus = Literal["completed", "cancelled", "failed"] + + +@dataclass(frozen=True) +class SimulationPreparationIssue: + code: str + message: str + + def as_dict(self) -> dict[str, str]: + return {"code": self.code, "message": self.message} + + +class SimulationPreparationError(ValueError): + def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None: + super().__init__("The compiled model is not ready for simulation.") + self.issues = issues + + +@dataclass(frozen=True) +class GenericSimulationResult: + success: bool + status: SimulationRunStatus + message: str + simulated_until: float + requested_stop_time: float + variables: tuple[ResultVariableMetadata, ...] + series: dict[str, list[float]] + final: dict[str, float] + diagnostics: dict[str, object] + + def as_dict(self) -> dict[str, object]: + return { + "success": self.success, + "status": self.status, + "partial": self.status != "completed", + "message": self.message, + "simulatedUntil": self.simulated_until, + "requestedStopTime": self.requested_stop_time, + "variables": [variable.as_dict() for variable in self.variables], + "series": self.series, + "final": self.final, + "diagnostics": self.diagnostics, + } + + +class _UnionFind: + def __init__(self, items: set[Endpoint]) -> None: + self.parent = {item: item for item in items} + + def find(self, item: Endpoint) -> Endpoint: + parent = self.parent[item] + if parent != item: + self.parent[item] = self.find(parent) + return self.parent[item] + + def union(self, first: Endpoint, second: Endpoint) -> None: + first_root = self.find(first) + second_root = self.find(second) + if first_root != second_root: + self.parent[second_root] = first_root + + +def _equation_port(component_name: str, variable: str) -> Endpoint | None: + parts = variable.rsplit(".", 2) + if len(parts) != 3: + return None + prefix, port_name, variable_name = parts + if prefix != component_name or variable_name != "p": + return None + return Endpoint(component_name, port_name) + + +def simulation_preparation_issues( + network: SimulationNetwork, +) -> tuple[SimulationPreparationIssue, ...]: + issues: list[SimulationPreparationIssue] = [] + physical_endpoints = { + Endpoint(component.name, definition.name) + for component in network.components.values() + for definition in component.port_definitions + if definition.kind == "physical" + } + connected_endpoints = { + endpoint + for connection in network.connections + if connection.kind == "physical" + for endpoint in connection.endpoints + } + for endpoint in sorted(physical_endpoints - connected_endpoints, key=str): + issues.append( + SimulationPreparationIssue( + "PORT_UNCONNECTED", + f"Physical port {endpoint} must be connected before simulation.", + ) + ) + + if any( + definition.kind == "signal" + for component in network.components.values() + for definition in component.port_definitions + ): + issues.append( + SimulationPreparationIssue( + "SIGNAL_PORT_UNSUPPORTED", + "Signal-port simulation is not implemented in the current MVP solver.", + ) + ) + + structure = network.pressure_flow_structure_dict() + if not structure["isSquare"]: + issues.append( + SimulationPreparationIssue( + "PRESSURE_FLOW_SYSTEM_NOT_SQUARE", + "Pressure-flow equation count does not match the unknown count: " + f"{structure['equationCount']} equations for {structure['unknownCount']} unknowns.", + ) + ) + + dynamic_names = { + component.name + for component in network.components.values() + if isinstance(component, DynamicComponent) + } + if not dynamic_names: + issues.append( + SimulationPreparationIssue( + "DYNAMIC_STATE_MISSING", + "Each simulated network requires at least one storage component.", + ) + ) + + adjacency = {name: set() for name in network.components} + for connection in network.connections: + first, second = connection.endpoints + adjacency[first.component].add(second.component) + adjacency[second.component].add(first.component) + remaining = set(adjacency) + while remaining: + start = remaining.pop() + group = {start} + stack = [start] + while stack: + current = stack.pop() + for neighbour in adjacency[current] - group: + group.add(neighbour) + remaining.discard(neighbour) + stack.append(neighbour) + if not (group & dynamic_names): + issues.append( + SimulationPreparationIssue( + "ALGEBRAIC_ISLAND_HAS_NO_STORAGE", + "A connected physical network has no pressure/enthalpy storage anchor: " + + ", ".join(sorted(group)) + + ".", + ) + ) + + if physical_endpoints: + effort_groups = _UnionFind(physical_endpoints) + for connection in network.connections: + if connection.kind == "physical": + effort_groups.union(*connection.endpoints) + storage_ports: dict[Endpoint, str] = {} + for component in network.components.values(): + for equation in component.pressure_flow_equation_residuals(): + pressure_ports = [ + endpoint + for variable in equation.variables + if (endpoint := _equation_port(component.name, variable)) is not None + ] + if equation.relation == "equal" and len(pressure_ports) == 2: + effort_groups.union(pressure_ports[0], pressure_ports[1]) + if equation.relation == "state": + for endpoint in pressure_ports: + storage_ports[endpoint] = component.name + + storages_by_group: dict[Endpoint, set[str]] = {} + for endpoint, component_name in storage_ports.items(): + storages_by_group.setdefault(effort_groups.find(endpoint), set()).add( + component_name + ) + for storage_names in storages_by_group.values(): + if len(storage_names) > 1: + issues.append( + SimulationPreparationIssue( + "IDEAL_STORAGE_COUPLING_UNSUPPORTED", + "Storage components are connected without a resistance: " + + ", ".join(sorted(storage_names)) + + ". Insert an orifice or pipe between them.", + ) + ) + + return tuple(issues) + + +def simulation_sample_times( + config: SolveIVPConfig, + step: float, + *, + max_points: int = 10001, +) -> list[float]: + if step <= 0.0 or not isfinite(step): + raise ValueError("Simulation sample step must be finite and greater than zero.") + duration = config.t_stop - config.t_start + if duration <= 0.0: + raise ValueError("Simulation stop time must be greater than start time.") + interval_count = int(floor(duration / step + 1e-12)) + times = [config.t_start + index * step for index in range(interval_count + 1)] + if times[-1] < config.t_stop - 1e-12: + times.append(config.t_stop) + else: + times[-1] = config.t_stop + if len(times) > max_points: + raise ValueError( + f"Simulation requests {len(times)} samples; the limit is {max_points}." + ) + return times + + +class GenericFluidSystem: + """Topology-driven, semi-explicit fluid simulation for registered components.""" + + def __init__(self, network: SimulationNetwork) -> None: + issues = simulation_preparation_issues(network) + if issues: + raise SimulationPreparationError(issues) + self.network = network + self.dynamic_components = network.dynamic_components() + self.pressure_flow_solver = PressureFlowSolver(network) + self.stream_resolver = StreamResolver(network) + self.algebraic_solve_count = 0 + self.max_algebraic_residual = 0.0 + self.max_algebraic_evaluations = 0 + self.max_stream_iterations = 0 + + def initial_state_vector(self) -> list[float]: + return self.network.initial_state_vector() + + def apply_state_vector(self, values: list[float]) -> None: + self.network.apply_state_vector(values) + + def _close_current_state(self) -> dict[str, dict[str, float]]: + for component in self.dynamic_components: + component.refresh_thermodynamic_ports() + algebraic = self.pressure_flow_solver.solve() + stream, connected_h = self.stream_resolver.solve() + self.algebraic_solve_count += 1 + self.max_algebraic_residual = max( + self.max_algebraic_residual, + algebraic.max_scaled_residual, + ) + self.max_algebraic_evaluations = max( + self.max_algebraic_evaluations, + algebraic.evaluations, + ) + self.max_stream_iterations = max( + self.max_stream_iterations, + stream.iterations, + ) + return connected_h + + def consistent_initial_state_vector(self) -> list[float]: + state = self.initial_state_vector() + self.apply_state_vector(state) + self._close_current_state() + return state + + def rhs(self, _time: float, state_vector: list[float]) -> list[float]: + self.apply_state_vector(state_vector) + connected_h = self._close_current_state() + derivatives: list[float] = [] + for component in self.dynamic_components: + derivatives.extend( + component.state_derivative_from_ports(connected_h[component.name]) + ) + return derivatives + + def _append_current_state(self, series: dict[str, list[float]]) -> None: + for component in self.network.components.values(): + for relative_key, value in component.result_values().items(): + series.setdefault( + f"{component.name}.{relative_key}", [] + ).append(value) + + def simulate( + self, + config: SolveIVPConfig, + *, + sample_step: float, + progress_callback: SimulationProgressCallback | None = None, + cancel_check: SimulationCancellationCheck | None = None, + ) -> GenericSimulationResult: + last_reported_progress = -1.0 + last_reported_phase = "" + + def report_progress( + progress: float, + phase: str, + *, + force: bool = False, + ) -> None: + nonlocal last_reported_phase, last_reported_progress + if progress_callback is None: + return + bounded_progress = min(1.0, max(0.0, progress)) + if ( + force + or phase != last_reported_phase + or bounded_progress - last_reported_progress >= 0.0025 + ): + last_reported_phase = phase + last_reported_progress = max( + last_reported_progress, + bounded_progress, + ) + progress_callback(last_reported_progress, phase) + + report_progress(0.0, "initializing", force=True) + t_eval = simulation_sample_times(config, sample_step) + initial_state = self.consistent_initial_state_vector() + report_progress(0.0, "integrating", force=True) + duration = config.t_stop - config.t_start + furthest_solver_time = config.t_start + + def report_solver_time(time: float) -> None: + nonlocal furthest_solver_time + furthest_solver_time = max(furthest_solver_time, float(time)) + time_fraction = ( + (furthest_solver_time - config.t_start) / duration + if duration > 0.0 + else 1.0 + ) + report_progress(time_fraction, "integrating") + + def monitored_rhs(time: float, state_vector: list[float]) -> list[float]: + if cancel_check is None: + report_solver_time(time) + return self.rhs(time, state_vector) + + solution = integrate_ode( + rhs=monitored_rhs, + initial_state=initial_state, + config=config, + t_eval=t_eval, + cancel_check=cancel_check, + accepted_step_callback=( + report_solver_time if cancel_check is not None else None + ), + ) + if isinstance(solution, ODESolution): + run_status: SimulationRunStatus = solution.status + integration_error = solution.error + else: + run_status = "completed" if bool(solution.success) else "failed" + integration_error = None + result_message = str(solution.message) + postprocess_progress = ( + 1.0 + if run_status == "completed" + else max(0.0, last_reported_progress) + ) + report_progress(postprocess_progress, "postprocessing", force=True) + times = [float(value) for value in solution.t] + series: dict[str, list[float]] = {"time": []} + postprocessing_error: Exception | None = None + for time_index in range(len(times)): + if ( + run_status == "completed" + and cancel_check is not None + and cancel_check() + ): + run_status = "cancelled" + result_message = "Simulation was stopped while preparing partial results." + break + state = [ + float(solution.y[state_index][time_index]) + for state_index in range(len(solution.y)) + ] + try: + self.apply_state_vector(state) + self._close_current_state() + self._append_current_state(series) + series["time"].append(times[time_index]) + except Exception as exc: + run_status = "failed" + result_message = str(exc) + postprocessing_error = exc + break + if len(series["time"]) < 2: + if postprocessing_error is not None: + raise postprocessing_error + if integration_error is not None: + raise integration_error + + final = { + key: values[-1] + for key, values in series.items() + if key != "time" and values + } + diagnostics = { + "pressureFlow": { + "solveCount": self.algebraic_solve_count, + "maxScaledResidual": self.max_algebraic_residual, + "maxEvaluationsPerSolve": self.max_algebraic_evaluations, + "last": ( + self.pressure_flow_solver.last_diagnostics.as_dict() + if self.pressure_flow_solver.last_diagnostics is not None + else None + ), + }, + "stream": { + "maxIterationsPerSolve": self.max_stream_iterations, + "last": ( + self.stream_resolver.last_diagnostics.as_dict() + if self.stream_resolver.last_diagnostics is not None + else None + ), + }, + "stateCount": len(initial_state), + "sampleCount": len(series["time"]), + } + variables = tuple( + variable + for variable in self.network.result_variable_metadata() + if variable.key in series + ) + report_progress( + 1.0 if run_status == "completed" else max(0.0, last_reported_progress), + "complete" if run_status == "completed" else run_status, + force=True, + ) + return GenericSimulationResult( + success=run_status == "completed" and bool(solution.success), + status=run_status, + message=result_message, + simulated_until=( + float(series["time"][-1]) + if series["time"] + else float(config.t_start) + ), + requested_stop_time=float(config.t_stop), + variables=variables, + series=series, + final=final, + diagnostics=diagnostics, + ) diff --git a/app/simulation/systems/network.py b/app/simulation/systems/network.py new file mode 100644 index 0000000..e0eb908 --- /dev/null +++ b/app/simulation/systems/network.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.simulation.core.base import Component, DynamicComponent +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ResultVariableMetadata +from app.simulation.core.ports import PortState + + +@dataclass(frozen=True) +class Endpoint: + component: str + port: str + + @property + def key(self) -> tuple[str, str]: + return self.component, self.port + + def __str__(self) -> str: + return f"{self.component}.{self.port}" + + +@dataclass(frozen=True) +class Connection: + id: str + kind: str + domain: str + endpoint_a: Endpoint + endpoint_b: Endpoint + + @property + def endpoints(self) -> tuple[Endpoint, Endpoint]: + return self.endpoint_a, self.endpoint_b + + @property + def undirected_key(self) -> tuple[tuple[str, str], tuple[str, str]]: + first, second = sorted((self.endpoint_a.key, self.endpoint_b.key)) + return first, second + + # Compatibility accessors for existing reports. They do not imply physical flow. + @property + def source_component(self) -> str: + return self.endpoint_a.component + + @property + def source_port(self) -> str: + return self.endpoint_a.port + + @property + def target_component(self) -> str: + return self.endpoint_b.component + + @property + def target_port(self) -> str: + return self.endpoint_b.port + + def as_interface_dict(self) -> dict[str, object]: + return { + "id": self.id, + "kind": self.kind, + "domain": self.domain, + "endpoints": [ + {"component": endpoint.component, "port": endpoint.port} + for endpoint in self.endpoints + ], + } + + +class SimulationNetwork: + """Container for components, topology, and state-vector bookkeeping.""" + + def __init__(self, name: str) -> None: + self.name = name + self.components: dict[str, Component] = {} + self.connections: list[Connection] = [] + + def add_component(self, component: Component) -> None: + if component.name in self.components: + raise ValueError(f"Duplicate component name: {component.name}") + self.components[component.name] = component + + def connect( + self, + endpoint_a_component: str, + endpoint_a_port: str, + endpoint_b_component: str, + endpoint_b_port: str, + *, + connection_id: str | None = None, + ) -> Connection: + endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port) + endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port) + if endpoint_a == endpoint_b: + raise ValueError(f"Cannot connect endpoint {endpoint_a} to itself.") + + first_port = self._port_for(endpoint_a) + second_port = self._port_for(endpoint_b) + first_definition = first_port.definition + second_definition = second_port.definition + if first_definition is None or second_definition is None: + raise ValueError("Connected ports must expose interface definitions.") + if first_definition.kind != second_definition.kind: + raise ValueError(f"Connection mixes physical and signal ports: {endpoint_a}, {endpoint_b}.") + if first_definition.domain != second_definition.domain: + raise ValueError(f"Connection domains do not match: {endpoint_a}, {endpoint_b}.") + if first_definition.variables != second_definition.variables: + raise ValueError( + f"Connection variable contracts do not match: {endpoint_a}, {endpoint_b}." + ) + if first_definition.kind == "signal" and { + first_definition.nominal_role, + second_definition.nominal_role, + } != {"input", "output"}: + raise ValueError("A signal connection must contain one output and one input.") + + occupied_endpoints = { + endpoint + for item in self.connections + for endpoint in item.endpoints + } + if first_definition.kind == "physical": + occupied = [ + str(endpoint) + for endpoint in (endpoint_a, endpoint_b) + if endpoint in occupied_endpoints + ] + if occupied: + raise ValueError( + "Physical ports accept one connection; already connected: " + + ", ".join(occupied) + + ". Use a junction component for branching." + ) + + if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key: + endpoint_a, endpoint_b = endpoint_b, endpoint_a + + connection = Connection( + id=connection_id or f"connection_{len(self.connections) + 1}", + kind=first_definition.kind, + domain=first_definition.domain, + endpoint_a=endpoint_a, + endpoint_b=endpoint_b, + ) + if any(item.undirected_key == connection.undirected_key for item in self.connections): + raise ValueError(f"Duplicate connection between {endpoint_a} and {endpoint_b}.") + if any(item.id == connection.id for item in self.connections): + raise ValueError(f"Duplicate connection id: {connection.id}.") + self.connections.append(connection) + return connection + + def _port_for(self, endpoint: Endpoint) -> PortState: + try: + component = self.components[endpoint.component] + except KeyError as exc: + raise ValueError(f"Unknown component: {endpoint.component}.") from exc + return component.get_port(endpoint.port) + + def connection_equation_residuals(self) -> tuple[EquationResidual, ...]: + """Evaluate connector equations that have a direct scalar residual. + + Stream variables are resolved by the stream-mixing layer and therefore do + not incorrectly appear here as an equality between outflow properties. + """ + + residuals: list[EquationResidual] = [] + for connection in self.connections: + if connection.kind != "physical": + continue + + first_port = self._port_for(connection.endpoint_a) + second_port = self._port_for(connection.endpoint_b) + definition = first_port.definition + if definition is None: + raise ValueError( + f"Connected port {connection.endpoint_a} has no interface definition." + ) + + for variable in definition.variables: + if variable.connection_rule == "equal": + value = float(getattr(first_port, variable.name)) - float( + getattr(second_port, variable.name) + ) + elif variable.connection_rule == "sumToZero": + value = float(getattr(first_port, variable.name)) + float( + getattr(second_port, variable.name) + ) + else: + continue + residuals.append( + EquationResidual( + id=f"{connection.id}:{variable.name}", + owner="connection", + owner_id=connection.id, + relation=variable.connection_rule, + variables=( + f"{connection.endpoint_a}.{variable.name}", + f"{connection.endpoint_b}.{variable.name}", + ), + role=variable.role, + value=value, + ) + ) + return tuple(residuals) + + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + """Evaluate the complete algebraic pressure-flow equation subsystem.""" + + component_residuals = tuple( + residual + for component in self.components.values() + for residual in component.pressure_flow_equation_residuals() + ) + return component_residuals + self.connection_equation_residuals() + + def pressure_flow_unknowns(self) -> tuple[str, ...]: + return tuple( + f"{component.name}.{definition.name}.{variable.name}" + for component in self.components.values() + for definition in component.port_definitions + if definition.kind == "physical" + for variable in definition.variables + if variable.role in {"effort", "flow"} + ) + + def pressure_flow_structure_dict(self) -> dict[str, object]: + unknowns = self.pressure_flow_unknowns() + equations = self.pressure_flow_equation_residuals() + return { + "unknownCount": len(unknowns), + "equationCount": len(equations), + "isSquare": len(unknowns) == len(equations), + "unknowns": list(unknowns), + "equations": [ + equation.as_definition_dict() for equation in equations + ], + } + + def dynamic_components(self) -> list[DynamicComponent]: + return [ + component + for component in self.components.values() + if isinstance(component, DynamicComponent) + ] + + def initial_state_vector(self) -> list[float]: + values: list[float] = [] + for component in self.dynamic_components(): + values.extend(component.get_state_vector()) + return values + + def apply_state_vector(self, values: list[float]) -> None: + cursor = 0 + for component in self.dynamic_components(): + next_cursor = cursor + component.state_size + component.set_state_vector(values[cursor:next_cursor]) + cursor = next_cursor + if cursor != len(values): + raise ValueError("State vector length does not match dynamic components.") + + def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]: + return tuple( + variable + for component in self.components.values() + for variable in component.result_variable_metadata() + ) + + def summary(self) -> str: + lines = [f"Network: {self.name}", "Components:"] + for name, component in self.components.items(): + lines.append(f" - {name}: {component.__class__.__name__}") + lines.append("Connections:") + for conn in self.connections: + connector = "<->" if conn.kind == "physical" else "->" + lines.append( + f" - {conn.endpoint_a} {connector} {conn.endpoint_b}" + ) + return "\n".join(lines) + + def as_interface_dict(self) -> dict[str, object]: + connected_endpoints = { + endpoint.key + for connection in self.connections + for endpoint in connection.endpoints + } + return { + "name": self.name, + "components": [ + { + "id": component.name, + "type": component.model_type, + "parameters": component.parameter_interface_dicts(), + "ports": [ + definition.as_interface_dict() + for definition in component.port_definitions + ], + "resultVariables": [ + variable.as_dict() + for variable in component.result_variable_metadata() + ], + } + for component in self.components.values() + ], + "connections": [ + connection.as_interface_dict() for connection in self.connections + ], + "pressureFlowSystem": self.pressure_flow_structure_dict(), + "unconnectedPorts": [ + {"component": component.name, "port": definition.name} + for component in self.components.values() + for definition in component.port_definitions + if (component.name, definition.name) not in connected_endpoints + ], + } + diff --git a/app/system_xml.py b/app/system_xml.py new file mode 100644 index 0000000..7c94e83 --- /dev/null +++ b/app/system_xml.py @@ -0,0 +1,844 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from functools import lru_cache +from math import isfinite +from pathlib import Path +from typing import Literal + +from lxml import etree + +from app.simulation.core.ports import PortDefinition +from app.simulation.registry import ( + COMPONENT_MODEL_REGISTRY, + ParameterSpec, +) + + +ValidationLayer = Literal["xml", "schema", "semantic"] +ValidationSeverity = Literal["error", "warning"] +SYSTEM_XML_MAX_BYTES = 5 * 1024 * 1024 +SYSTEM_XML_V2_SCHEMA_PATH = ( + Path(__file__).resolve().parent.parent / "schemas" / "system-simulation-v2.xsd" +) +SUPPORTED_SOLVER_METHODS = {"RK45", "RK23", "DOP853", "Radau", "BDF", "LSODA"} + + +@dataclass(frozen=True) +class ValidationIssue: + layer: ValidationLayer + code: str + message: str + severity: ValidationSeverity = "error" + path: str | None = None + line: int | None = None + + def as_dict(self) -> dict[str, object]: + result: dict[str, object] = { + "severity": self.severity, + "layer": self.layer, + "code": self.code, + "message": self.message, + } + if self.path is not None: + result["path"] = self.path + if self.line is not None: + result["line"] = self.line + return result + + +@dataclass(frozen=True) +class SystemXmlSimulation: + t_start: float + t_stop: float + step: float + max_step: float + method: str + line: int | None = None + + +@dataclass(frozen=True) +class SystemXmlPort: + name: str + kind: str + domain: str + nominal_role: str + positive_flow_direction: str | None + side: str + line: int | None = None + + def as_project_data(self) -> dict[str, object]: + data: dict[str, object] = { + "name": self.name, + "kind": self.kind, + "domain": self.domain, + "nominalRole": self.nominal_role, + "side": self.side, + } + if self.positive_flow_direction is not None: + data["positiveFlowDirection"] = self.positive_flow_direction + return data + + +@dataclass(frozen=True) +class SystemXmlParameter: + name: str + value: float + line: int | None = None + + +@dataclass(frozen=True) +class SystemXmlComponent: + id: str + name: str + model_type: str + component_type: str + x: float + y: float + rotation: int + mirrored: bool + ports: tuple[SystemXmlPort, ...] + parameters: tuple[SystemXmlParameter, ...] + line: int | None = None + + @property + def port_by_name(self) -> dict[str, SystemXmlPort]: + return {port.name: port for port in self.ports} + + +@dataclass(frozen=True) +class SystemXmlEndpoint: + component: str + port: str + role: str | None + line: int | None = None + + @property + def key(self) -> tuple[str, str]: + return self.component, self.port + + +@dataclass(frozen=True) +class SystemXmlConnection: + id: str + kind: str + domain: str + endpoints: tuple[SystemXmlEndpoint, SystemXmlEndpoint] + line: int | None = None + + @property + def undirected_key(self) -> tuple[tuple[str, str], tuple[str, str]]: + first, second = sorted(endpoint.key for endpoint in self.endpoints) + return first, second + + +@dataclass(frozen=True) +class SystemXmlDocument: + name: str + schema_version: str + unit_system: str + simulation: SystemXmlSimulation + components: tuple[SystemXmlComponent, ...] + connections: tuple[SystemXmlConnection, ...] + + def summary(self) -> dict[str, object]: + return { + "name": self.name, + "schemaVersion": self.schema_version, + "unitSystem": self.unit_system, + "componentCount": len(self.components), + "connectionCount": len(self.connections), + } + + def as_project_data(self) -> dict[str, object]: + edges = [] + for connection in self.connections: + first, second = connection.endpoints + if connection.kind == "signal": + by_role = {endpoint.role: endpoint for endpoint in connection.endpoints} + first = by_role.get("source", first) + second = by_role.get("target", second) + edges.append( + { + "id": connection.id, + "source": first.component, + "sourceHandle": first.port, + "target": second.component, + "targetHandle": second.port, + } + ) + + return { + "name": self.name, + "nodes": [ + { + "id": component.id, + "type": "simulationComponent", + "position": {"x": component.x, "y": component.y}, + "data": { + "label": component.name, + "componentType": component.component_type, + "modelType": component.model_type, + "ports": [port.as_project_data() for port in component.ports], + "parameters": { + parameter.name: parameter.value + for parameter in component.parameters + }, + "rotation": component.rotation, + "mirrored": component.mirrored, + }, + } + for component in self.components + ], + "edges": edges, + "simulation": { + "t_start": self.simulation.t_start, + "t_stop": self.simulation.t_stop, + "step": self.simulation.step, + "max_step": self.simulation.max_step, + "method": self.simulation.method, + }, + } + + +@dataclass(frozen=True) +class SystemXmlValidationReport: + document: SystemXmlDocument | None + issues: tuple[ValidationIssue, ...] + + @property + def valid(self) -> bool: + return self.document is not None and not any( + issue.severity == "error" for issue in self.issues + ) + + def as_dict(self) -> dict[str, object]: + errors = sum(issue.severity == "error" for issue in self.issues) + warnings = sum(issue.severity == "warning" for issue in self.issues) + result: dict[str, object] = { + "valid": self.valid, + "errorCount": errors, + "warningCount": warnings, + "issues": [issue.as_dict() for issue in self.issues], + } + if self.document is not None: + result["system"] = self.document.summary() + return result + + +def validate_system_xml_document( + source: bytes | str, +) -> SystemXmlValidationReport: + xml_bytes = source.encode("utf-8") if isinstance(source, str) else source + if not xml_bytes.strip(): + return _failed_report("xml", "XML_EMPTY", "The XML document is empty.") + if len(xml_bytes) > SYSTEM_XML_MAX_BYTES: + return _failed_report( + "xml", + "XML_TOO_LARGE", + f"The XML document exceeds {SYSTEM_XML_MAX_BYTES} bytes.", + ) + + parser = etree.XMLParser( + resolve_entities=False, + no_network=True, + load_dtd=False, + recover=False, + huge_tree=False, + ) + try: + root = etree.fromstring(xml_bytes, parser=parser) + except etree.XMLSyntaxError as exc: + line, _ = exc.position + return _failed_report( + "xml", + "XML_SYNTAX_ERROR", + str(exc).split(", line", maxsplit=1)[0], + line=line, + ) + + if root.getroottree().docinfo.doctype: + return _failed_report( + "xml", + "XML_DTD_NOT_ALLOWED", + "DTD and entity declarations are not allowed.", + line=root.sourceline, + ) + + schema = _system_xml_v2_schema() + if not schema.validate(root): + issues = tuple( + ValidationIssue( + layer="schema", + code="XSD_VALIDATION_ERROR", + message=entry.message.strip(), + path=entry.path or None, + line=entry.line or None, + ) + for entry in schema.error_log + ) + return SystemXmlValidationReport(document=None, issues=issues) + + document = _parse_validated_root(root) + issues = tuple(_semantic_issues(document)) + return SystemXmlValidationReport(document=document, issues=issues) + + +@lru_cache(maxsize=1) +def _system_xml_v2_schema() -> etree.XMLSchema: + schema_document = etree.parse(str(SYSTEM_XML_V2_SCHEMA_PATH)) + return etree.XMLSchema(schema_document) + + +def _failed_report( + layer: ValidationLayer, + code: str, + message: str, + *, + line: int | None = None, +) -> SystemXmlValidationReport: + return SystemXmlValidationReport( + document=None, + issues=(ValidationIssue(layer=layer, code=code, message=message, line=line),), + ) + + +def _parse_validated_root(root: etree._Element) -> SystemXmlDocument: + simulation_element = root.find("Simulation") + components_element = root.find("Components") + connections_element = root.find("Connections") + assert simulation_element is not None + assert components_element is not None + assert connections_element is not None + + simulation = SystemXmlSimulation( + t_start=float(simulation_element.get("tStart")), + t_stop=float(simulation_element.get("tStop")), + step=float(simulation_element.get("step")), + max_step=float(simulation_element.get("maxStep")), + method=str(simulation_element.get("method")), + line=simulation_element.sourceline, + ) + components = tuple( + _parse_component(component) for component in components_element.findall("Component") + ) + connections = tuple( + _parse_connection(connection) + for connection in connections_element.findall("Connection") + ) + return SystemXmlDocument( + name=str(root.get("name")), + schema_version=str(root.get("schemaVersion")), + unit_system=str(root.get("unitSystem")), + simulation=simulation, + components=components, + connections=connections, + ) + + +def _parse_component(element: etree._Element) -> SystemXmlComponent: + ports = tuple( + SystemXmlPort( + name=str(port.get("name")), + kind=str(port.get("kind")), + domain=str(port.get("domain")), + nominal_role=str(port.get("nominalRole")), + positive_flow_direction=port.get("positiveFlowDirection"), + side=str(port.get("side")), + line=port.sourceline, + ) + for port in element.findall("Port") + ) + parameters = tuple( + SystemXmlParameter( + name=str(parameter.get("name")), + value=float(parameter.get("value")), + line=parameter.sourceline, + ) + for parameter in element.findall("Parameter") + ) + return SystemXmlComponent( + id=str(element.get("id")), + name=str(element.get("name")), + model_type=str(element.get("type")), + component_type=str(element.get("componentType")), + x=float(element.get("x")), + y=float(element.get("y")), + rotation=int(element.get("rotation", "0")), + mirrored=element.get("mirrored", "false") in {"true", "1"}, + ports=ports, + parameters=parameters, + line=element.sourceline, + ) + + +def _parse_connection(element: etree._Element) -> SystemXmlConnection: + endpoints = tuple( + SystemXmlEndpoint( + component=str(endpoint.get("component")), + port=str(endpoint.get("port")), + role=endpoint.get("role"), + line=endpoint.sourceline, + ) + for endpoint in element.findall("Endpoint") + ) + assert len(endpoints) == 2 + return SystemXmlConnection( + id=str(element.get("id")), + kind=str(element.get("kind")), + domain=str(element.get("domain")), + endpoints=(endpoints[0], endpoints[1]), + line=element.sourceline, + ) + + +def _semantic_issues(document: SystemXmlDocument) -> list[ValidationIssue]: + issues: list[ValidationIssue] = [] + _validate_system_and_simulation(document, issues) + component_by_id = _validate_components(document, issues) + _validate_connections(document, component_by_id, issues) + return issues + + +def _validate_system_and_simulation( + document: SystemXmlDocument, + issues: list[ValidationIssue], +) -> None: + if not document.name.strip(): + issues.append(_semantic_issue("SYSTEM_NAME_EMPTY", "System name cannot be blank.", "/System")) + if not document.components: + issues.append( + _semantic_issue( + "SYSTEM_HAS_NO_COMPONENTS", + "The system must contain at least one component.", + "/System/Components", + ) + ) + + simulation = document.simulation + values = { + "tStart": simulation.t_start, + "tStop": simulation.t_stop, + "step": simulation.step, + "maxStep": simulation.max_step, + } + for name, value in values.items(): + if not isfinite(value): + issues.append( + _semantic_issue( + "SIMULATION_VALUE_NOT_FINITE", + f"Simulation value {name} must be finite.", + f"/System/Simulation/@{name}", + simulation.line, + ) + ) + if isfinite(simulation.t_start) and isfinite(simulation.t_stop): + if simulation.t_stop <= simulation.t_start: + issues.append( + _semantic_issue( + "SIMULATION_TIME_RANGE_INVALID", + "Simulation tStop must be greater than tStart.", + "/System/Simulation", + simulation.line, + ) + ) + for name, value in { + "step": simulation.step, + "maxStep": simulation.max_step, + }.items(): + if isfinite(value) and value <= 0.0: + issues.append( + _semantic_issue( + "SIMULATION_STEP_INVALID", + f"Simulation value {name} must be greater than zero.", + f"/System/Simulation/@{name}", + simulation.line, + ) + ) + if simulation.method not in SUPPORTED_SOLVER_METHODS: + issues.append( + _semantic_issue( + "SIMULATION_METHOD_UNSUPPORTED", + f"Unsupported solver method: {simulation.method}.", + "/System/Simulation/@method", + simulation.line, + ) + ) + + +def _validate_components( + document: SystemXmlDocument, + issues: list[ValidationIssue], +) -> dict[str, SystemXmlComponent]: + component_by_id: dict[str, SystemXmlComponent] = {} + names: dict[str, str] = {} + for index, component in enumerate(document.components, start=1): + path = f"/System/Components/Component[{index}]" + if component.id in component_by_id: + issues.append( + _semantic_issue( + "COMPONENT_ID_DUPLICATE", + f"Duplicate component id: {component.id}.", + path, + component.line, + ) + ) + else: + component_by_id[component.id] = component + + if component.name in names: + issues.append( + _semantic_issue( + "COMPONENT_NAME_DUPLICATE", + f"Duplicate component name: {component.name}.", + path, + component.line, + ) + ) + else: + names[component.name] = component.id + + spec = COMPONENT_MODEL_REGISTRY.get(component.model_type) + if spec is None: + issues.append( + _semantic_issue( + "COMPONENT_TYPE_UNSUPPORTED", + f"Unsupported component model type: {component.model_type}.", + f"{path}/@type", + component.line, + ) + ) + continue + if component.component_type != component.model_type: + issues.append( + _semantic_issue( + "COMPONENT_TYPE_MISMATCH", + f"componentType '{component.component_type}' does not match model type '{component.model_type}'.", + f"{path}/@componentType", + component.line, + ) + ) + if not isfinite(component.x) or not isfinite(component.y): + issues.append( + _semantic_issue( + "COMPONENT_POSITION_NOT_FINITE", + f"Component {component.id} position must be finite.", + path, + component.line, + ) + ) + _validate_component_ports(component, spec.ports, path, issues) + _validate_component_parameters(component, spec.parameter_by_name, path, issues) + return component_by_id + + +def _validate_component_ports( + component: SystemXmlComponent, + expected_ports: tuple[PortDefinition, ...], + component_path: str, + issues: list[ValidationIssue], +) -> None: + actual_by_name: dict[str, SystemXmlPort] = {} + for port_index, port in enumerate(component.ports, start=1): + path = f"{component_path}/Port[{port_index}]" + if port.name in actual_by_name: + issues.append( + _semantic_issue( + "PORT_NAME_DUPLICATE", + f"Component {component.id} contains duplicate port {port.name}.", + path, + port.line, + ) + ) + else: + actual_by_name[port.name] = port + + expected_by_name = {port.name: port for port in expected_ports} + for name in sorted(set(expected_by_name) - set(actual_by_name)): + issues.append( + _semantic_issue( + "PORT_REQUIRED_MISSING", + f"Component {component.id} is missing registered port {name}.", + component_path, + component.line, + ) + ) + for name in sorted(set(actual_by_name) - set(expected_by_name)): + port = actual_by_name[name] + issues.append( + _semantic_issue( + "PORT_UNSUPPORTED", + f"Component {component.id} contains unsupported port {name}.", + component_path, + port.line, + ) + ) + + for name in sorted(set(actual_by_name) & set(expected_by_name)): + actual = actual_by_name[name] + expected = expected_by_name[name] + path = f"{component_path}/Port[@name='{name}']" + if actual.kind != expected.kind or actual.domain != expected.domain: + issues.append( + _semantic_issue( + "PORT_INTERFACE_MISMATCH", + f"Port {component.id}.{name} has an incompatible kind or domain.", + path, + actual.line, + ) + ) + if actual.nominal_role != expected.nominal_role: + issues.append( + _semantic_issue( + "PORT_NOMINAL_ROLE_MISMATCH", + f"Port {component.id}.{name} has nominalRole '{actual.nominal_role}', expected '{expected.nominal_role}'.", + path, + actual.line, + ) + ) + if actual.kind == "physical" and ( + actual.positive_flow_direction != expected.positive_flow_direction + ): + issues.append( + _semantic_issue( + "PORT_FLOW_SIGN_MISMATCH", + f"Port {component.id}.{name} must use positiveFlowDirection='intoComponent'.", + path, + actual.line, + ) + ) + + +def _validate_component_parameters( + component: SystemXmlComponent, + expected_parameters: Mapping[str, ParameterSpec], + component_path: str, + issues: list[ValidationIssue], +) -> None: + actual_by_name: dict[str, SystemXmlParameter] = {} + for parameter_index, parameter in enumerate(component.parameters, start=1): + path = f"{component_path}/Parameter[{parameter_index}]" + if parameter.name in actual_by_name: + issues.append( + _semantic_issue( + "PARAMETER_NAME_DUPLICATE", + f"Component {component.id} contains duplicate parameter {parameter.name}.", + path, + parameter.line, + ) + ) + else: + actual_by_name[parameter.name] = parameter + + for name in sorted(set(expected_parameters) - set(actual_by_name)): + issues.append( + _semantic_issue( + "PARAMETER_REQUIRED_MISSING", + f"Component {component.id} is missing required parameter {name}.", + component_path, + component.line, + ) + ) + for name in sorted(set(actual_by_name) - set(expected_parameters)): + parameter = actual_by_name[name] + issues.append( + _semantic_issue( + "PARAMETER_UNSUPPORTED", + f"Component {component.id} contains unsupported parameter {name}.", + component_path, + parameter.line, + ) + ) + for name in sorted(set(actual_by_name) & set(expected_parameters)): + parameter = actual_by_name[name] + message = expected_parameters[name].validation_message(parameter.value) + if message is not None: + issues.append( + _semantic_issue( + "PARAMETER_VALUE_INVALID", + f"Parameter {component.id}.{name} {message}.", + f"{component_path}/Parameter[@name='{name}']", + parameter.line, + ) + ) + + +def _validate_connections( + document: SystemXmlDocument, + component_by_id: dict[str, SystemXmlComponent], + issues: list[ValidationIssue], +) -> None: + connection_ids: set[str] = set() + connection_keys: set[tuple[tuple[str, str], tuple[str, str]]] = set() + occupied_physical_ports: dict[tuple[str, str], str] = {} + referenced_ports: set[tuple[str, str]] = set() + + for index, connection in enumerate(document.connections, start=1): + path = f"/System/Connections/Connection[{index}]" + if connection.id in connection_ids: + issues.append( + _semantic_issue( + "CONNECTION_ID_DUPLICATE", + f"Duplicate connection id: {connection.id}.", + path, + connection.line, + ) + ) + connection_ids.add(connection.id) + + if connection.undirected_key in connection_keys: + issues.append( + _semantic_issue( + "CONNECTION_DUPLICATE", + f"Connection {connection.id} duplicates an existing endpoint pair.", + path, + connection.line, + ) + ) + connection_keys.add(connection.undirected_key) + + if connection.endpoints[0].key == connection.endpoints[1].key: + issues.append( + _semantic_issue( + "CONNECTION_SELF_REFERENCE", + f"Connection {connection.id} connects an endpoint to itself.", + path, + connection.line, + ) + ) + + resolved_endpoints: list[tuple[SystemXmlEndpoint, SystemXmlPort]] = [] + for endpoint_index, endpoint in enumerate(connection.endpoints, start=1): + endpoint_path = f"{path}/Endpoint[{endpoint_index}]" + component = component_by_id.get(endpoint.component) + if component is None: + issues.append( + _semantic_issue( + "ENDPOINT_COMPONENT_UNKNOWN", + f"Connection {connection.id} references unknown component {endpoint.component}.", + endpoint_path, + endpoint.line, + ) + ) + continue + port = component.port_by_name.get(endpoint.port) + if port is None: + issues.append( + _semantic_issue( + "ENDPOINT_PORT_UNKNOWN", + f"Connection {connection.id} references unknown port {endpoint.component}.{endpoint.port}.", + endpoint_path, + endpoint.line, + ) + ) + continue + resolved_endpoints.append((endpoint, port)) + referenced_ports.add(endpoint.key) + if port.kind != connection.kind or port.domain != connection.domain: + issues.append( + _semantic_issue( + "CONNECTION_INTERFACE_MISMATCH", + f"Connection {connection.id} kind/domain does not match {endpoint.component}.{endpoint.port}.", + endpoint_path, + endpoint.line, + ) + ) + if connection.kind == "physical": + if endpoint.role is not None: + issues.append( + _semantic_issue( + "PHYSICAL_ENDPOINT_HAS_ROLE", + f"Physical endpoint {endpoint.component}.{endpoint.port} must not declare a source/target role.", + endpoint_path, + endpoint.line, + ) + ) + previous = occupied_physical_ports.get(endpoint.key) + if previous is not None: + issues.append( + _semantic_issue( + "PHYSICAL_PORT_ALREADY_CONNECTED", + f"Physical port {endpoint.component}.{endpoint.port} is already used by connection {previous}; use a Tee for branching.", + endpoint_path, + endpoint.line, + ) + ) + else: + occupied_physical_ports[endpoint.key] = connection.id + + if len(resolved_endpoints) == 2: + first_port = resolved_endpoints[0][1] + second_port = resolved_endpoints[1][1] + if first_port.kind != second_port.kind: + issues.append( + _semantic_issue( + "CONNECTION_MIXES_PORT_KINDS", + f"Connection {connection.id} mixes physical and signal ports.", + path, + connection.line, + ) + ) + if first_port.domain != second_port.domain: + issues.append( + _semantic_issue( + "CONNECTION_DOMAIN_MISMATCH", + f"Connection {connection.id} connects different physical domains.", + path, + connection.line, + ) + ) + + if connection.kind == "signal": + roles = {endpoint.role for endpoint in connection.endpoints} + if roles != {"source", "target"}: + issues.append( + _semantic_issue( + "SIGNAL_ENDPOINT_ROLES_INVALID", + f"Signal connection {connection.id} must contain source and target roles.", + path, + connection.line, + ) + ) + for endpoint, port in resolved_endpoints: + expected_role = "source" if port.nominal_role == "output" else "target" + if endpoint.role != expected_role: + issues.append( + _semantic_issue( + "SIGNAL_DIRECTION_MISMATCH", + f"Signal endpoint {endpoint.component}.{endpoint.port} has role '{endpoint.role}', expected '{expected_role}'.", + path, + endpoint.line, + ) + ) + + for component in document.components: + for port in component.ports: + if (component.id, port.name) not in referenced_ports: + issues.append( + _semantic_issue( + "PORT_UNCONNECTED", + f"Port {component.id}.{port.name} is not connected.", + f"/System/Components/Component[@id='{component.id}']/Port[@name='{port.name}']", + port.line, + severity="warning", + ) + ) + + +def _semantic_issue( + code: str, + message: str, + path: str, + line: int | None = None, + *, + severity: ValidationSeverity = "error", +) -> ValidationIssue: + return ValidationIssue( + layer="semantic", + code=code, + message=message, + severity=severity, + path=path, + line=line, + ) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e811c2d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,48 @@ +# 开发文档索引 + +本目录是 SystemSimulationApp 协议和开发规范的统一入口。 + +## 模型开发 + +1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md) + 用于创建或修改模型,包括端口、参数、结果、方程、版本、测试和 AI 修改协议。 +2. [组件库分类、发现与读取规范 v1](component-library-spec-v1.md) + 用于理解组件库清单、自动发现、启动校验、目录接口和前端读取流程。 +3. [组件目录 JSON Schema v1](../schemas/component-catalog-v1.schema.json) + `GET /api/components/catalog` 的机器可读结构。 +4. [AMESim 子模型公开组件迁移矩阵](amesim-component-migration-matrix.md) + 用于划分 `test_mql` 子模型族的公开组件、内部模型和暂不支持范围。 + +建议人工和 AI 先阅读建模规范,再阅读读取规范,然后参考目标分类中最接近的现有 +模型。不要从前端兜底数据反推后端物理契约。 + +## System XML + +- [System XML v2 协议](system-xml-v2.md) +- [System XML v2 XSD](../schemas/system-simulation-v2.xsd) +- [System XML v1 协议(旧版)](system-xml-v1.md) +- [System XML v1 XSD(旧版)](../schemas/system-simulation-v1.xsd) + +新增模型时,模型类和组件库清单是后端事实来源;System XML 保存组件实例、参数和 +连接。XML 解析器不能自行创造模型端口或参数。 + +## 当前代码入口 + +| 目的 | 文件 | +| --- | --- | +| 组件基类 | [`app/simulation/core/base.py`](../app/simulation/core/base.py) | +| 端口契约 | [`app/simulation/core/ports.py`](../app/simulation/core/ports.py) | +| 参数与结果元数据 | [`app/simulation/core/metadata.py`](../app/simulation/core/metadata.py) | +| 库和显示声明 | [`app/simulation/core/catalog.py`](../app/simulation/core/catalog.py) | +| 库发现与注册校验 | [`app/simulation/registry.py`](../app/simulation/registry.py) | +| 临时库清单 | [`app/simulation/components/experimental/library.py`](../app/simulation/components/experimental/library.py) | +| 元件完整示例 | [`app/simulation/components/example.md`](../app/simulation/components/example.md) | + +## AI 使用原则 + +- 先读规范和相邻模型,再改代码。 +- 只从 `library.py` 受控登记公开模型。 +- 不在前端复制后端端口、参数或默认值作为正式来源。 +- 不覆盖用户已有改动。 +- 不自行猜测缺失的物理方程。 +- 修改后运行针对性测试和完整回归,并报告未完成的验证。 diff --git a/docs/amesim-component-migration-matrix.md b/docs/amesim-component-migration-matrix.md new file mode 100644 index 0000000..9f14f09 --- /dev/null +++ b/docs/amesim-component-migration-matrix.md @@ -0,0 +1,89 @@ +# AMESim 子模型公开组件迁移矩阵 + +状态:规划中 +适用模型:`AmesimModels/test_mql.ame` / `PythonModels.systems.test_mql` +配套规范:[`component-model-authoring-spec-v1.md`](component-model-authoring-spec-v1.md) + +## 目标 + +本文档把 `test_mql` 中的 AMESim 子模型族拆成可执行的迁移批次。它不是组件注册清单;只有完成公开模型契约、方程实现、最小 XML 编译/仿真测试后,子模型才能加入某个 `library.py` 的 `models` 清单并出现在前端组件库中。 + +当前原则: + +- 不把现有固定拓扑 `TestMqlSystem` 整体注册为公开拖拽组件。 +- 不把机械、信号、事件元件提前伪装成可由当前气动求解器仿真的公开组件。 +- 可先公开当前 `app.simulation` 求解链路能承载的气动元件:储能、阻性、孔口、三通/四通、准稳态管段。 +- 对 AMESim 精确语义尚未复刻的元件,允许先做内部模型或文档化候选,不能在目录中标记为完成。 + +## 当前求解器能力边界 + +`app.simulation` 当前支持: + +- 物理域:`pneumatic`。 +- 端口变量:`p`、`m_flow`、`h_outflow`。 +- 流量符号:`m_flow > 0` 表示流入当前组件。 +- 网络方程:连接压力相等、连接流量和为零、组件压力-流量残差、stream 焓传播。 +- 动态:半显式 ODE,动态组件提供质量和内能状态导数。 +- 暂不支持:机械端口、信号端口仿真、事件系统、一般高指数 DAE、AMESim 气体定义组件对全局 medium 的动态切换。 + +## 子模型族矩阵 + +| AMESim 子模型 | 数量 | AMESim 角色 | 当前公开状态 | 建议目标 | 先决条件 / 限制 | +| --- | ---: | --- | --- | --- | --- | +| `PNCH023` | 4 | 固定容积气室,带换热 | 候选公开 | `amesim_pnch023`,`storage` | 可基于 `ThermodynamicVolumeComponent`;需按 AMESim `cvol/extemp/kth/sth/gi` 复核质量、能量、换热方程。 | +| `PNCH012` | 8 | 变容气室,带换热 | 暂不公开 | 内部 `test_mql` 模型;后续 `amesim_pnch012` | 体积由机械/活塞运动耦合;公开前需要机械域或明确外部体积输入协议。 | +| `PNOR001` | 8 | 常系数气动孔口 | 候选公开 | `amesim_pnor001`,`flow` | 可基于 `AlgebraicComponent`;需按 AMESim `cq/area/Cv/Kv/flowset/gi` 复核双向流、零压差正则化和单位换算。 | +| `PNVO001` | 8 | 信号调制气动孔口 | 暂不公开 | 内部固定算例;后续 `amesim_pnvo001` | 依赖信号端口和事件/阶跃控制;当前公开组件库不支持信号仿真。可先拆出无信号开度参数版本,但不得等同 AMESim `PNVO001`。 | +| `PN3NODE2` | 8 | 三端气动节点,压力温度由 port 2 固定 | 候选公开 | `amesim_pn3node2`,`junctions` | 当前 `Tee` 是通用三通近似;AMESim port 2 参考温压语义和 stream 混合需单独测试。 | +| `P4NODE2` | 8 | 四端气动节点,压力温度由 port 2 固定 | 候选公开 | `amesim_p4node2`,`junctions` | 需要新增四端 junction 基类/模型;复核 port 2 参考温压和多支路混合。 | +| `PNL00R` | 4 | 管路纯阻性摩擦段 | 候选公开 | `amesim_pnl00r`,`flow` | 可基于准稳态阻性管;需按 AMESim `PNL00R` 参数和摩擦公式复核。 | +| `PNL0001` | 20 | C-R 动态管路 | 先内部 | 后续 `amesim_pnl0001` | 含管内储气动态和摩擦耦合;当前 `pipe` 只是准稳态阻性模型,不能直接宣称等价。 | +| `PNL0002` | 8 | R-C-R 动态管路 | 先内部 | 后续 `amesim_pnl0002` | 需要多段动态状态和端口/中心阻力结构测试。 | +| `PNL0003` | 8 | C-R-C 动态管路 | 先内部 | 后续 `amesim_pnl0003` | 当前 `test_mql` 仍在诊断事件早期导数差异;公开前必须完成 AMESim baseline 对齐。 | +| `PNPL01` | 16 | 零气动流源 | 暂不公开 | 后续边界/源元件 | 当前网络要求物理端口连接并由组件/储能闭合;需设计一端口源/边界组件契约。 | +| `PNGD00` | 1 | 氦气气体定义 | 暂不公开 | medium 配置,不是画布物理组件 | 应映射为系统/介质设置;不能作为普通可连接组件注册。 | +| `PNRP17` | 8 | 气动活塞与移动体耦合 | 暂不公开 | 内部固定算例;后续跨域组件 | 依赖气动端口和机械端口耦合;需要新增机械域、跨域状态和连接规则。 | +| `MECMAS21` | 10 | 一维平动质量 | 暂不公开 | 后续机械域组件 | 当前无机械端口、力/速度/位移连接规则和机械求解器。 | +| `LMECHN1` | 2 | 动态线性机械节点 | 暂不公开 | 后续机械域组件 | 同上。 | +| `LSTP00A` | 8 | 弹性接触/端止动 | 暂不公开 | 后续机械域组件 | 依赖机械接触和事件/非光滑力模型。 | +| `F000` | 16 | 零力源 | 暂不公开 | 后续机械边界组件 | 依赖机械域。 | +| `FORC` | 2 | 信号转力 | 暂不公开 | 后续信号-机械跨域组件 | 依赖信号端口与机械端口。 | +| `STEP0` | 8 | 阶跃信号源 | 暂不公开 | 后续信号源组件 | 当前公开求解器不支持信号端口仿真和事件。 | +| `UD00` | 2 | 分段线性信号源 | 暂不公开 | 后续信号源组件 | 同上。 | +| `DIRECT` | 44 | 直接连接 | 不注册为组件 | System XML `Connection` | 连接不是组件;物理连接必须保持无方向端点语义。 | + +## 建议迁移批次 + +### 批次 1:可进入当前气动求解器的公开组件 + +优先级最高,但仍需先补 AMESim 公式与测试: + +- `amesim_pnor001` +- `amesim_pnl00r` +- `amesim_pnch023` +- `amesim_pn3node2` +- `amesim_p4node2` + +完成定义:每个模型必须有 `MODEL_TYPE/MODEL_VERSION/PORTS/PARAMETERS/RESULT_VARIABLES/DISPLAY/create()`,并补目录、参数边界、残差、零流量/反向流、最小 XML 编译和短时仿真测试。 + +### 批次 2:动态管路 + +- `amesim_pnl0001` +- `amesim_pnl0002` +- `amesim_pnl0003` + +这些模型是 `test_mql` 对齐工作的核心,公开前需要先在内部固定算例中完成 AMESim baseline 诊断闭环。不能用当前准稳态 `pipe` 替代动态管路并宣称等价。 + +### 批次 3:信号、事件、机械和跨域组件 + +- `STEP0`、`UD00`、`PNVO001` +- `MECMAS21`、`LMECHN1`、`LSTP00A`、`F000`、`FORC`、`PNRP17` + +这些需要先设计新物理域或信号求解协议,包括端口变量、连接规则、XML 协议、前端连线兼容和最小闭合系统测试。 + +## 下一步执行建议 + +1. 先为 AMESim 公开组件建立独立库,例如 `app.simulation.components.amesim`,不要混入 `experimental`。 +2. 先只登记一个完成度最高的气动代数组件,例如 `PNOR001` 或 `PNL00R`。 +3. 每登记一个模型,都同步补充测试和目录校验,确认 `/api/components/catalog`、System XML 编译和最小仿真都通过。 +4. 动态管路和机械/信号组件继续保留在内部 `test_mql` 路径,直到方程和求解能力补齐。 diff --git a/docs/component-library-spec-v1.md b/docs/component-library-spec-v1.md new file mode 100644 index 0000000..e6d0df5 --- /dev/null +++ b/docs/component-library-spec-v1.md @@ -0,0 +1,730 @@ +# 组件库分类、发现与读取规范 v1 + +状态:已在 `experimental` 临时组件库实施 +适用范围:`app/simulation/components`、组件注册中心、System XML 和 React Flow 组件库 +当前试验库:`experimental`(界面名称:临时测试组件库) + +## 0. 文档定位 + +本文档只负责“模型如何被系统发现和读取”。模型方程、状态、参数和结果应如何编写, +统一参见[组件模型建模规范 v1](component-model-authoring-spec-v1.md)。 + +人工或 AI 排查组件读取问题时,按以下顺序读取: + +1. `app/simulation/registry.py` 中的 `ENABLED_COMPONENT_LIBRARIES`。 +2. 被启用组件库的 `library.py`。 +3. `library.py/models` 明确列出的模型类。 +4. 模型类的 `DISPLAY / PORTS / PARAMETERS`。 +5. `build_component_catalog()` 的输出。 +6. 前端 `normalizeComponentCatalog()`。 + +当前事实来源优先级: + +| 信息 | 唯一事实来源 | 不能作为事实来源 | +| --- | --- | --- | +| 启用哪些库 | `ENABLED_COMPONENT_LIBRARIES` | 目录中碰巧存在的文件夹 | +| 库分类和模型清单 | 各库 `library.py` | 前端组件列表 | +| 模型类型和版本 | 模型类 | 文件名或中文名称 | +| 物理端口 | 模型类 `PORTS` | `DISPLAY.ports` 或画布方向 | +| 参数默认值和边界 | 模型类 `PARAMETERS` | 前端兜底定义 | +| 图标和端口位置 | 模型类 `DISPLAY` | 物理方程 | +| 前端运行目录 | `/api/components/catalog` | 手工扫描 Python 包 | + +如果文档、代码和目录响应不一致,应在同一次修改中修复并增加测试,不能通过复制 +另一份映射临时绕过。 + +## 1. 目标 + +本规范用于统一以下内容: + +1. 后端组件库如何声明自身信息和分类。 +2. 单个模型如何声明端口、参数、结果变量和界面显示信息。 +3. 后端如何发现、校验、注册并实例化模型。 +4. 前端如何通过统一目录接口自动生成左侧组件库和参数面板。 +5. System XML 中的模型类型如何稳定映射到 Python 实现。 + +目标工作流如下: + +```mermaid +flowchart LR + A["ENABLED_COMPONENT_LIBRARIES"] --> B["library.py"] + B --> C["受控导入模型类"] + C --> D["启动契约校验"] + D --> E["组件注册表"] + E --> F["GET /api/components/catalog"] + F --> G["React Flow 组件库和参数面板"] + E --> H["System XML 模型实例化"] +``` + +新增一个符合本规范的模型后,前端不应再修改 `App.tsx` 中的组件列表、参数列表 +或分类列表。只有新增一种前端尚不支持的图形渲染方式时,才需要补充前端图标组件。 + +## 2. 术语和层级 + +组件目录采用四个相互独立的概念: + +| 层级 | 示例 | 含义 | +| --- | --- | --- | +| 组件库 Library | `experimental` | 一组具有共同发布、维护和版本边界的模型 | +| 界面分类 Category | `storage` | 只用于组件面板分组和排序 | +| 物理域 Domain | `pneumatic` | 决定端口能否连接以及采用哪组连接方程 | +| 模型 Model | `cylinder` | 可实例化并写入 System XML 的稳定模型类型 | + +分类和物理域禁止混用。例如,`storage` 是界面分类,`pneumatic` 是物理域; +一个“储能元件”也可以属于液压域,不能根据分类推断端口连接规则。 + +标准层级为: + +```text +Library + Category + Model + Port + Port variable + Parameter + Result variable +``` + +## 3. 推荐目录结构 + +每个组件库使用独立 Python 包: + +```text +app/simulation/components/ + experimental/ + __init__.py + library.py + storage/ + __init__.py + cylinder.py + tank.py + flow/ + __init__.py + orifice.py + resistive_pipe.py + junctions/ + __init__.py + tee.py +``` + +规则如下: + +- `library.py` 是组件库唯一清单入口。 +- 分类目录名必须与分类 ID 一致。 +- 一个公开模型原则上放在一个独立 `.py` 文件中。 +- 求解器、介质、通用方程和状态对象不得放入组件库目录。 +- 未准备对外注册的实验类可以保留在库内,但不得加入库清单。 +- 禁止通过扫描任意 `.py` 文件并执行其中代码来发现模型;必须使用库清单进行受控导入。 + +## 4. 标识符和版本 + +### 4.1 标识符 + +以下字段使用稳定的英文机器标识: + +- `library.id` +- `category.id` +- `MODEL_TYPE` +- 端口名 +- 参数名 +- 结果变量名 + +标识符应使用 `snake_case`,只允许小写英文字母、数字和下划线,并以字母开头。 +已有工程约定中的 `T`、`U` 等热力学变量可以保留。 + +界面中文名称单独保存在 `label` 中。修改 `label` 不影响工程兼容性;修改机器标识 +会影响工程文件、System XML、结果文件和后端注册,因此发布后不得直接改名。 + +### 4.2 版本 + +每个组件库和模型都应具有版本: + +```python +LIBRARY_VERSION = "0.1.0" +MODEL_VERSION = "1.0.0" +``` + +版本遵循 `主版本.次版本.修订版本`: + +- 修订版本:只修复实现,不改变输入输出契约。 +- 次版本:向后兼容地新增参数、结果或能力。 +- 主版本:端口、参数语义或方程发生不兼容变化。 + +当前 System XML v2 尚未保存模型版本。正式发布组件库前,应在 XML 中增加 +`library` 和 `modelVersion`,并提供旧工程迁移规则。 + +## 5. 组件库清单 + +每个库必须提供 `library.py`,并使用有类型的不可变声明: + +```python +from app.simulation.core.catalog import ( + ComponentCategorySpec, + ComponentLibrarySpec, +) + +LIBRARY = ComponentLibrarySpec( + id="experimental", + label="临时测试组件库", + version="0.1.0", + source_package="app.simulation.components.experimental", + temporary=True, + order=100, + categories=( + ComponentCategorySpec( + id="storage", + label="储能元件", + order=10, + ), + ComponentCategorySpec( + id="flow", + label="流动元件", + order=20, + ), + ComponentCategorySpec( + id="junctions", + label="连接元件", + order=30, + ), + ), + models=( + "app.simulation.components.experimental.storage.cylinder:Cylinder", + "app.simulation.components.experimental.storage.tank:Tank", + "app.simulation.components.experimental.flow.resistive_pipe:ResistivePipe", + "app.simulation.components.experimental.flow.orifice:Orifice", + "app.simulation.components.experimental.junctions.tee:Tee", + ), +) +``` + +`models` 是唯一允许注册到系统的模型清单。它同时解决以下问题: + +- 避免导入测试脚本或内部辅助类。 +- 控制模型加载顺序。 +- 避免同一个 `MODEL_TYPE` 被多个实现重复注册。 +- 可以只加载部署环境允许使用的组件库。 +- 启动错误能明确定位到具体库和模型。 + +当前 `experimental/library.py` 已按此格式声明库、分类和五个公开模型; +`experimental/__init__.py` 只保留旧常量的兼容别名。 + +## 6. 模型类契约 + +一个可注册模型必须显式声明: + +```python +class ExampleComponent(Component): + MODEL_TYPE = "example_component" + MODEL_VERSION = "1.0.0" + PORTS = (...) + PARAMETERS = (...) + RESULT_VARIABLES = (...) + DISPLAY = ... +``` + +字段含义: + +| 字段 | 是否必须 | 用途 | +| --- | --- | --- | +| `MODEL_TYPE` | 必须 | System XML、注册表和结果元数据中的稳定类型 | +| `MODEL_VERSION` | 必须 | 模型契约和迁移版本 | +| `PORTS` | 必须 | 物理或信号连接契约 | +| `PARAMETERS` | 必须,可为空 | 用户输入参数及校验边界 | +| `RESULT_VARIABLES` | 必须,可为空 | 组件级可展示结果 | +| `DISPLAY` | 必须 | 前端名称、分类、图标、排序和端口布局 | + +模型实现还必须满足: + +1. 构造函数调用 `super().__init__(name)`。 +2. 使用 `set_parameter_values()` 保存所有规范化后的参数。 +3. 使用 `register_declared_port()` 创建 `PORTS` 中声明的端口。 +4. 组件级结果键必须与 `RESULT_VARIABLES` 完全一致。 +5. 所有内部计算均使用 SI 基准值。 +6. 模型不能直接依赖 FastAPI、React Flow 或 XML DOM。 +7. 模型的方程不能依赖图标方向、界面分类或画布位置。 + +完整方程示例参见 +[`app/simulation/components/example.md`](../app/simulation/components/example.md)。 + +## 7. 界面显示声明 + +`DISPLAY` 只描述模型在前端的呈现,不参与物理求解: + +```python +from app.simulation.core.catalog import ( + ComponentDisplaySpec, + PortDisplaySpec, +) + +DISPLAY = ComponentDisplaySpec( + label="示例容腔", + library_id="experimental", + category_id="storage", + symbol="cylinder", + order=90, + ports=( + PortDisplaySpec( + name="port_a", + side="left", + order=10, + ), + ), +) +``` + +字段规则: + +- `label`:组件库中的显示名称。 +- `library_id`:必须引用已加载的库。 +- `category_id`:必须引用该库已声明的分类。 +- `symbol`:前端图形渲染器的稳定标识。 +- `order`:同一分类中的排序值。 +- `ports`:只声明端口在图形中的位置和顺序。 + +`DISPLAY.ports` 中的端口名必须与模型的 `PORTS` 完全一致,不能缺少、增加或改名。 +旋转和镜像只改变前端计算后的视觉方位,不改变端口机器名和物理语义。 + +前端遇到未知 `symbol` 时必须显示通用占位图标,同时保留模型拖拽、参数编辑、 +连线和 XML 生成功能,不能因为缺少专用图形而丢弃整个模型。 + +## 8. 端口规范 + +端口由 `PortDefinition` 声明: + +```python +PORTS = ( + PortDefinition.pneumatic( + "port_a", + nominal_role="bidirectional", + ), +) +``` + +每个端口必须包含: + +- 稳定端口名。 +- `kind`:`physical` 或 `signal`。 +- `domain`:例如 `pneumatic`。 +- `nominal_role`:用于界面提示,不决定实际流向。 +- `positive_flow_direction`:物理流量变量的符号约定。 +- 端口变量及各自连接规则。 + +当前气动功率端口包含: + +| 变量 | 角色 | 连接规则 | 单位 | +| --- | --- | --- | --- | +| `p` | effort | `equal` | `Pa` | +| `m_flow` | flow | `sumToZero` | `kg/s` | +| `h_outflow` | stream | `streamMix` | `J/kg` | + +气动端口统一约定 `m_flow > 0` 表示质量流入当前组件。`inlet`、`outlet` 是标称角色, +不应阻止反向流动;实际方向由求解结果中的流量符号决定。 + +连接校验至少包括: + +1. 两个端口均存在。 +2. 端口不能连接自身。 +3. `kind` 相同。 +4. `domain` 相同。 +5. 端口变量集合及连接规则兼容。 +6. 同一物理端口的连接数量符合当前网络编译器能力。 + +## 9. 参数规范 + +参数使用 `ParameterDefinition` 声明: + +```python +PARAMETERS = ( + ParameterDefinition( + name="volume", + label="容积", + quantity="volume", + unit="m3", + default=0.1, + minimum=0.0, + minimum_exclusive=True, + ), +) +``` + +规则如下: + +- `name` 是模型构造、XML 和工程文件共同使用的稳定名称。 +- `label` 是界面文案。 +- `quantity` 是受控物理量标识,用于前端匹配可换算单位。 +- `unit` 是后端 SI 单位。 +- `default` 必须能够直接创建合法模型。 +- 边界必须与方程有效范围一致。 +- 无量纲量使用 `quantity="dimensionless"` 和 `unit=""`。 +- 用户输入可以使用其他公制单位,但提交后端前必须换算为 SI。 +- 文本框编辑中的临时字符串不立即判错,失焦、回车或运行仿真时再执行数值校验。 + +后端不得静默忽略未知参数。缺少参数时可使用声明的默认值;出现未知参数时必须 +返回包含组件 ID 和参数名的明确错误。 + +## 10. 结果变量规范 + +组件结果和端口结果分开管理: + +- 组件结果来自 `RESULT_VARIABLES`。 +- 端口结果根据 `PORTS` 中 `result_visible=True` 的端口变量自动生成。 +- 求解器缓存、残差和调试量默认不进入用户结果。 + +每个结果变量必须提供: + +- `name` +- `label` +- `quantity` +- `unit` +- `category` +- `order` + +仿真结果必须输出结构化元数据,前端禁止拆解结果键或按字符串关键词猜测: + +```json +{ + "key": "cylinder_1.port_b.p", + "componentId": "cylinder_1", + "componentType": "cylinder", + "scope": "port", + "portName": "port_b", + "name": "p", + "label": "压力", + "quantity": "pressure", + "unit": "Pa", + "category": "effort", + "order": 10 +} +``` + +结果页应按 `componentId`、`scope`、`portName`、`quantity` 等结构化字段筛选, +而不是从 `key` 中推断组件、端口和变量。 + +## 11. 标准模型创建入口 + +注册中心通过统一入口创建模型,避免长期维护集中式 `_cylinder_factory`、 +`_tank_factory` 等适配函数。当前接口为: + +```python +@classmethod +def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], +) -> Component: + ... +``` + +创建流程: + +1. 注册中心按 `PARAMETERS` 填充默认值。 +2. 校验数值有限性和上下限。 +3. 拒绝未知参数。 +4. 调用模型类的 `create()`。 +5. 验证实例的模型类型、端口和参数快照。 +6. 将实例交给网络编译器。 + +这种方式允许 Python 构造参数保留内部命名,同时对外始终使用规范中的参数名。 + +## 12. 自动发现与注册 + +后端启动时按以下顺序建立注册表: + +```text +读取启用的 library.py + -> 校验库 ID、版本和分类 + -> 按 models 清单导入模型类 + -> 读取模型静态契约 + -> 执行跨字段校验 + -> 建立 library registry + -> 建立 model registry + -> 构建前端 catalog +``` + +当前使用显式启用列表: + +```python +ENABLED_COMPONENT_LIBRARIES = ( + "app.simulation.components.experimental.library:LIBRARY", +) +``` + +禁止以下发现方式: + +- 在整个仓库递归导入所有 Python 文件。 +- 依赖文件名自动推断 `MODEL_TYPE`。 +- 由前端硬编码后端类路径。 +- 导入失败后悄悄跳过模型。 +- 多个实现重复注册同一个模型类型并由加载顺序决定最终结果。 + +发现或校验失败时,FastAPI 应拒绝启动并给出库 ID、模型类型、字段和原因。 + +## 13. 启动校验规则 + +注册表完成前必须执行以下校验: + +### 13.1 库与分类 + +- 库 ID 全局唯一。 +- 库版本格式有效。 +- 分类 ID 在库内唯一。 +- 所有排序值为整数。 +- 所有模型引用已存在的库和分类。 + +### 13.2 模型 + +- `MODEL_TYPE` 全局唯一,且与注册键一致。 +- 模型版本格式有效。 +- 模型继承框架要求的基类。 +- 模型提供统一创建入口。 +- 默认参数能够成功创建实例。 + +### 13.3 端口 + +- 端口名在模型内唯一。 +- 显示端口集合与物理端口集合完全一致。 +- 端口物理域、变量角色和连接规则有效。 +- 实例实际注册的端口与静态声明一致。 + +### 13.4 参数 + +- 参数名在模型内唯一。 +- 默认值有限且满足边界。 +- `minimum <= maximum`。 +- `quantity` 和 `unit` 的组合已登记。 +- 实例保留所有规范化参数,不得静默修改或丢失。 + +### 13.5 结果 + +- 结果变量名在对应作用域内唯一。 +- `quantity` 和单位有效。 +- `component_result_values()` 的键与声明一致。 +- 端口结果只来自声明为可见的端口变量。 + +## 14. 前端组件目录协议 + +前端只通过以下接口读取组件库: + +```http +GET /api/components/catalog +``` + +目录顶层必须具有版本: + +```json +{ + "schemaVersion": 1, + "libraries": [] +} +``` + +单个模型至少包含: + +```json +{ + "type": "cylinder", + "modelType": "cylinder", + "modelVersion": "1.0.0", + "label": "气瓶", + "symbol": "cylinder", + "order": 10, + "category": { + "id": "storage", + "label": "储能元件", + "order": 10 + }, + "ports": [], + "parameters": [] +} +``` + +前端读取规则: + +1. 按库 `order`、分类 `order`、模型 `order` 排序。 +2. 使用 `type` 作为拖拽数据和工程文件中的稳定类型。 +3. 使用 `label` 显示中文名称。 +4. 使用 `ports` 生成 React Flow Handle。 +5. 使用 `parameters` 生成参数输入和单位选择控件。 +6. 使用 `symbol` 选择图标渲染器。 +7. 不在前端重新定义参数默认值、边界或端口语义。 + +当前前端保留内置兜底目录,用于后端未启动时继续打开工程。兜底只是一种开发期 +容错机制,不能成为新增模型的正式注册方式;正式环境应明确提示目录加载失败。 + +### 14.1 前端实际读取步骤 + +React Flow 启动时: + +1. 使用 `no-store` 请求 `/api/components/catalog`。 +2. 检查 `schemaVersion == 1`。 +3. 检查库、模型、端口和参数结构。 +4. 检查模型 `type` 是否全局重复。 +5. 将参数数组转换为参数面板定义。 +6. 按库、分类和模型的 `order` 排序。 +7. 成功时显示“后端目录”。 +8. 请求或格式校验失败时显示“内置兜底”并使用开发期兜底目录。 + +前端兜底目录不保证包含新模型。新增公开模型后,只要 FastAPI 正常提供目录,前端 +就能读取;若要求后端离线时也显示新模型,才需要有意识地同步兜底定义。兜底定义 +仍不能成为端口、参数或默认值的权威来源。 + +### 14.2 修改后如何生效 + +修改 Python 模型、库清单或注册器后必须重启 FastAPI: + +```powershell +cd F:\Master\SystemSimulationApp +.\.venv-win\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000 +``` + +只刷新浏览器无法让已运行的 Python 进程重新导入模型。前端源代码由 Vite 开发服务 +热更新;普通目录内容变化不需要重启 Vite。 + +## 15. System XML 映射 + +System XML 中: + +```xml + +``` + +映射规则: + +- `id`:工程内唯一的组件实例 ID。 +- `name`:用户可修改的组件实例名称。 +- `type`:必须匹配唯一的 `MODEL_TYPE`。 +- `componentType`:当前为兼容字段,应与 `type` 相同。 +- ``:必须存在于模型的 `PORTS`。 +- ``:必须存在于模型的 `PARAMETERS`。 + +XML 解析器只负责结构、引用和契约校验;模型注册中心负责选择 Python 类并创建实例; +模型自身负责方程和状态。三层职责不得混合。 + +## 16. 测试要求 + +每个新模型至少需要: + +1. 元数据测试:模型类型、端口、参数和结果声明合法。 +2. 目录测试:模型出现在正确库和分类中。 +3. 默认创建测试:默认参数能构造模型。 +4. 参数边界测试:非法值被拒绝,错误信息包含组件和参数。 +5. 端口测试:实例端口与声明完全一致。 +6. XML 测试:最小系统能解析并映射到正确模型。 +7. 方程测试:至少验证一个稳态、残差或守恒关系。 +8. 最小仿真测试:一个短时算例能产生有限结果和结构化结果元数据。 + +库级测试还应检查: + +- 库清单中的所有类均可导入。 +- 所有模型类型全局唯一。 +- 无遗漏或重复分类。 +- `GET /api/components/catalog` 满足目录 schema。 + +## 17. 新增模型操作清单 + +开发者新增模型时只执行以下步骤: + +1. 在目标库的正确分类目录中新建模型文件。 +2. 实现 `MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、 + `RESULT_VARIABLES` 和 `DISPLAY`。 +3. 实现统一 `create()` 和模型方程。 +4. 将模型类路径加入该库 `library.py` 的 `models`。 +5. 添加模型单元测试和最小 XML/仿真测试。 +6. 运行注册校验和完整测试。 +7. 重启 FastAPI,刷新前端确认目录来源为“后端目录”。 + +正常情况下不需要修改: + +- React Flow 左侧组件列表。 +- 参数面板字段。 +- System XML 模型类型分派代码。 +- 结果变量关键词映射。 +- 集中式模型工厂表。 + +### 17.1 AI 修改约束 + +AI 在处理组件库读取任务时必须: + +1. 先确认目标是新增模型、修改模型还是新增库。 +2. 读取当前启用列表和目标库清单。 +3. 只把公开模型加入 `library.py/models`。 +4. 不通过递归扫描替代显式清单。 +5. 不在前端重新声明后端契约作为正式实现。 +6. 不静默跳过加载失败的模型。 +7. 保留未知 `symbol` 的通用图标回退能力。 +8. 修改后检查目录响应,并运行注册表和前端构建测试。 +9. 告知用户需要重启 FastAPI。 + +AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开模型。公开性的唯一判断 +依据是该类是否出现在已启用库的 `models` 清单中。 + +## 18. 当前实现与目标规范的差异 + +| 能力 | 当前状态 | 目标 | +| --- | --- | --- | +| 库 ID、名称、版本、分类和模型清单 | 已实现 | 由各库 `library.py` 维护 | +| 库和模型注册表 | 已实现 | 由已启用库清单自动构建 | +| 前端目录接口 | 已实现 | 已包含库版本和模型版本 | +| 前端动态分类和参数读取 | 已实现 | 新增已支持图标的模型无需改组件列表 | +| 端口与参数契约 | 已实现 | 保持为唯一事实来源 | +| 结构化结果元数据 | 已实现 | 保持为唯一事实来源 | +| `DISPLAY` | 已实现 | 由公开模型类自行声明 | +| 模型工厂 | 已实现 | 模型类统一 `create()` | +| 模型发现 | 已实现 | 按库清单受控发现 | +| 启动校验 | 已实现首版 | 覆盖版本、分类、端口、参数、单位和默认实例 | +| XML/工程中的模型版本与迁移 | 未实现 | 正式库发布前补齐 | +| 目录 JSON Schema | 已实现 | `schemas/component-catalog-v1.schema.json` | + +## 19. 推荐实施顺序 + +1. 已完成:声明类型已放入独立的 `core/catalog.py`。 +2. 已完成:`experimental/library.py` 已成为临时库唯一清单入口。 +3. 已完成:五个公开模型自行声明 `DISPLAY` 和 `MODEL_VERSION`。 +4. 已完成:公开模型统一实现 `create()`,集中式工厂函数已删除。 +5. 已完成:注册表由库清单构建,并在导入时执行契约和默认实例校验。 +6. 已完成:已增加组件目录 JSON Schema。 +7. 待完成:在 System XML 和工程文件中保存模型版本,并设计迁移机制。 +8. 待完成:规范稳定后新建正式组件库,不再向 `experimental` 增加生产模型。 + +该顺序可以保证每一步都保持现有前端和 System XML 可用,不需要一次性重写模型、 +解析器和界面。 + +## 20. 改动影响表 + +| 想做的改动 | 必须修改 | 通常不需要修改 | +| --- | --- | --- | +| 新增同库同分类模型 | 模型文件、`library.py/models`、测试 | 注册表、前端参数列表 | +| 新增分类 | 库 `categories`、模型 `DISPLAY.category_id`、测试 | 物理端口和求解器 | +| 新增组件库 | 新库包和 `library.py`、启用列表、测试 | 已有库清单 | +| 修改参数默认值或范围 | 模型 `PARAMETERS`、测试、必要的版本 | 前端参数硬编码 | +| 修改端口 | 模型 `PORTS`、`DISPLAY.ports`、XML/网络测试、版本迁移 | 库分类 | +| 新增专用图标 | 模型 `DISPLAY.symbol`、前端图标渲染器 | 参数和物理方程 | +| 新增物理域 | 端口协议、网络、求解器、XML、前端兼容规则和测试 | 仅修改分类名称 | +| 修改目录响应结构 | 后端序列化、JSON Schema、前端解析、协议版本和测试 | 单个模型方程 | + +## 21. 读取故障排查 + +| 现象 | 优先检查 | +| --- | --- | +| 模型完全没有出现在目录响应 | 模型类路径是否加入已启用库的 `models` | +| FastAPI 无法启动 | 启动错误中的库、模型和字段;通常是契约校验失败 | +| 接口有模型但前端没有 | `schemaVersion`、前端控制台、目录规范化错误 | +| 前端显示“内置兜底” | 8000 端口、`/api/components/catalog`、后端是否重启 | +| 分类错误 | `DISPLAY.category_id` 与库 `categories` | +| 端口数量或位置错误 | `PORTS` 与 `DISPLAY.ports` 是否完全一致 | +| 参数面板缺字段 | 模型 `PARAMETERS` 和目录响应,不先改前端 | +| XML 报不支持类型 | XML `type` 是否精确匹配 `MODEL_TYPE` | +| 图标是通用图形 | `symbol` 尚无专用前端渲染器,但模型仍应可用 | + +最小诊断命令: + +```powershell +.\.venv-win\Scripts\python.exe -c "from app.simulation.registry import build_component_catalog; print(build_component_catalog())" +.\.venv-win\Scripts\python.exe -m unittest tests.test_component_registry tests.test_component_catalog +``` diff --git a/docs/component-model-authoring-spec-v1.md b/docs/component-model-authoring-spec-v1.md new file mode 100644 index 0000000..5aec681 --- /dev/null +++ b/docs/component-model-authoring-spec-v1.md @@ -0,0 +1,683 @@ +# 组件模型建模规范 v1 + +状态:已在 `experimental` 临时组件库实施 +适用对象:人工开发者、代码生成工具和 AI 编程助手 +配套读取规范:[组件库分类、发现与读取规范 v1](component-library-spec-v1.md) + +## 1. 文档目标 + +本文档规定一个 Python 仿真元件应如何创建、修改、测试和注册。完成后的模型必须 +同时满足四个使用方: + +1. 求解器能够实例化模型并调用方程。 +2. System XML 能够根据稳定类型找到模型。 +3. React Flow 能够自动显示图标、端口和参数。 +4. 结果页面能够根据结构化元数据展示变量。 + +本文档是模型代码的开发合同。若本文档与当前代码行为不一致,应把它视为缺陷: +先核对实际实现,再在同一次修改中同步代码、测试和文档,禁止让两套规则长期并存。 + +## 2. 开始前先判断任务类型 + +### 2.1 新增公开模型 + +公开模型会出现在前端组件库中,也能被 System XML 创建。必须: + +- 放入某个组件库的分类目录。 +- 实现完整模型契约。 +- 加入该库 `library.py` 的 `models` 清单。 +- 添加目录、契约、方程和最小仿真测试。 + +### 2.2 修改已有公开模型 + +必须先判断改动是否破坏已有工程: + +| 改动 | 版本建议 | 兼容性要求 | +| --- | --- | --- | +| 修复数值实现但不改变契约 | 修订版本 | 旧 XML 和工程继续可用 | +| 新增有默认值的参数或结果 | 次版本 | 旧工程缺少该字段时必须有迁移或默认值 | +| 修改界面名称或图标 | 库修订版本 | 不修改机器标识 | +| 修改方程的物理语义 | 根据影响提高次版本或主版本 | 补充基准和变更说明 | +| 删除、改名端口或参数 | 主版本 | 必须设计工程和 XML 迁移 | +| 修改 `MODEL_TYPE` | 视为新模型 | 旧类型必须保留迁移映射 | + +### 2.3 新增内部模型 + +仅供固定算例或研究代码使用、不进入前端目录的模型,不加入 `library.py`。这类模型 +应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。 + +当前示例是 +[`app/simulation/examples/testmodel/dynamic_pipe.py`](../app/simulation/examples/testmodel/dynamic_pipe.py)。 + +### 2.4 新增物理域 + +仅新增模型类不足以支持新物理域。除了模型,还必须设计: + +- `PortDefinition` 和端口变量。 +- 变量角色与连接规则。 +- 网络兼容性检查。 +- 代数方程和 stream/signal 传播。 +- XML 端口协议。 +- 前端连线兼容规则。 +- 最小闭合系统与求解测试。 + +没有完成这些基础能力时,不得仅通过修改 `domain` 字符串宣称支持新物理域。 + +## 3. 开发前必须读取的文件 + +人工或 AI 在修改模型前,应按顺序读取: + +1. 本文档。 +2. 目标库的 `library.py`。 +3. 同分类中物理行为最接近的现有模型。 +4. [`core/base.py`](../app/simulation/core/base.py)。 +5. [`core/ports.py`](../app/simulation/core/ports.py)。 +6. [`core/metadata.py`](../app/simulation/core/metadata.py)。 +7. [`core/catalog.py`](../app/simulation/core/catalog.py)。 +8. [`registry.py`](../app/simulation/registry.py) 中的启动校验。 +9. 与目标模型最接近的测试。 + +不要只根据文件名、前端图标或旧 XML 猜测模型语义。 + +## 4. 文件位置和命名 + +公开模型放在: + +```text +app/simulation/components///.py +``` + +例如: + +```text +app/simulation/components/experimental/storage/cylinder.py +app/simulation/components/experimental/flow/orifice.py +app/simulation/components/experimental/junctions/tee.py +``` + +规则: + +- 一个公开模型原则上对应一个文件和一个主要模型类。 +- 模块名、`MODEL_TYPE`、端口名和参数名使用稳定机器标识。 +- `MODEL_TYPE` 使用小写 `snake_case`。 +- 参数和结果变量允许保留已有热力学惯例,如 `T0`、`T`、`U`。 +- 中文名称只写入 `label`,不能代替机器标识。 +- 求解器、介质和网络通用逻辑不得复制到模型文件。 + +## 5. 公开模型完整契约 + +每个公开模型类必须在自身类体中显式声明: + +```python +MODEL_TYPE = "example_component" +MODEL_VERSION = "1.0.0" +PORTS = (...) +PARAMETERS = (...) +RESULT_VARIABLES = (...) +DISPLAY = ... +``` + +同时必须实现: + +```python +@classmethod +def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], +) -> Component: + ... +``` + +注册器要求这些字段直接存在于公开模型类中。不要依赖父类隐式提供 +`MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、`RESULT_VARIABLES`、 +`DISPLAY` 或 `create()`。 + +## 6. 基类选择 + +### 6.1 `AlgebraicComponent` + +适用于没有积分状态、由当前端口变量和参数直接决定残差的元件,例如: + +- 孔板 +- 阀门 +- 阻性管段 +- 理想三通 + +至少实现: + +- 构造函数和端口注册。 +- `create()`。 +- `pressure_flow_equation_residuals()`。 +- 需要传递 stream 变量时实现 `update_stream_outflows()`。 + +### 6.2 `ThermodynamicVolumeComponent` + +适用于包含质量和能量状态的气体容腔,例如: + +- 气瓶 +- 贮箱 +- 有容积的管段 + +至少实现: + +- `get_state_vector()`。 +- `set_state_vector()`。 +- `refresh_thermodynamic_ports()`。 +- `state_derivative_from_ports()`。 +- `pressure_flow_equation_residuals()`。 + +该基类已经提供标准热力学组件结果: + +```text +m, U, p, T, rho, u, h +``` + +除非物理含义不同,不要重新复制这组结果声明。 + +### 6.3 其他基类 + +如果现有基类不能表达模型,应先评估是否缺少一种通用组件能力。不要为了一个模型 +直接把专用判断塞入 `SimulationNetwork` 或求解器。 + +## 7. 端口建模规范 + +当前气动模型使用: + +```python +PortDefinition.pneumatic( + "port_a", + nominal_role="bidirectional", +) +``` + +气动端口包含: + +| 变量 | 角色 | 连接规则 | SI 单位 | +| --- | --- | --- | --- | +| `p` | `effort` | `equal` | `Pa` | +| `m_flow` | `flow` | `sumToZero` | `kg/s` | +| `h_outflow` | `stream` | `streamMix` | `J/kg` | + +必须遵守: + +- `m_flow > 0` 表示质量流入当前组件。 +- `nominal_role` 只用于界面和默认布局,不限制实际流向。 +- 物理连接是非因果的,连接线端点顺序不代表流向。 +- 所有声明端口必须使用 `register_declared_port()` 创建。 +- `DISPLAY.ports` 必须与 `PORTS` 名称集合完全一致。 +- 分支连接使用三通等连接元件,不能让一个物理端口直接连接多条边。 + +禁止: + +- 在模型内部根据画布左右方向判断流向。 +- 为了前端显示另造一套端口名。 +- 把 `port_a` 固定解释为真实入口、把 `port_b` 固定解释为真实出口。 +- 直接绕过端口状态读写其他组件对象。 + +## 8. 参数建模规范 + +所有用户可配置输入必须使用 `ParameterDefinition`: + +```python +ParameterDefinition( + name="volume", + label="容积", + quantity="volume", + unit="m3", + default=0.1, + minimum=0.0, + minimum_exclusive=True, +) +``` + +字段含义: + +| 字段 | 规则 | +| --- | --- | +| `name` | 稳定机器名,同时用于 XML、工程文件和 `create()` | +| `label` | 前端显示名称,不能为空 | +| `quantity` | 受控物理量标识 | +| `unit` | 后端 SI 基准单位 | +| `default` | 必须能够创建有效模型 | +| `minimum` / `maximum` | 必须反映方程有效范围 | +| `minimum_exclusive` | 用于直径、容积等严格大于零的量 | + +当前受控单位定义在 `SI_UNIT_BY_QUANTITY`: + +| quantity | SI 单位 | +| --- | --- | +| `area` | `m2` | +| `dimensionless` | 空字符串 | +| `density` | `kg/m³` | +| `flow_coefficient` | `kg/(s*Pa^0.5)` | +| `heat_transfer_coefficient` | `W/(m2*K)` | +| `internal_energy` | `J` | +| `length` | `m` | +| `mass` | `kg` | +| `mass_flow` | `kg/s` | +| `pressure` | `Pa` | +| `specific_enthalpy` | `J/kg` | +| `specific_internal_energy` | `J/kg` | +| `temperature` | `K` | +| `velocity` | `m/s` | +| `volume` | `m3` | + +新增物理量时必须先扩展后端受控单位表,再评估前端是否需要单位换算选项。禁止在 +单个模型中私自拼写新的同义 `quantity`。 + +构造函数必须调用: + +```python +self.set_parameter_values( + { + "volume": volume, + "p0": p0, + "T0": T0, + } +) +``` + +保存值、方程计算和结果输出都使用 SI。前端显示单位变化不能改变后端参数语义。 + +## 9. 结果变量规范 + +### 9.1 组件级结果 + +组件自身状态或派生量使用 `ResultVariableDefinition`: + +```python +ResultVariableDefinition( + name="pressure_drop", + label="压降", + quantity="pressure", + unit="Pa", + category="derived", + order=10, +) +``` + +声明后必须在 `component_result_values()` 返回同名值: + +```python +def component_result_values(self) -> Mapping[str, float]: + return { + "pressure_drop": self.port_a.p - self.port_b.p, + } +``` + +声明集合和返回键必须一致。 + +### 9.2 端口结果 + +端口结果由 `PORTS` 的端口变量自动产生,不要在 `RESULT_VARIABLES` 中重复声明 +`port_a.p`、`port_a.m_flow` 等字段。 + +### 9.3 禁止暴露的内容 + +以下内容默认不能作为用户结果: + +- 非线性求解器内部未知量索引。 +- 缩放残差和迭代缓存。 +- 仅用于调试的临时中间值。 +- 可以由已有结果稳定推导、但没有明确工程用途的重复字段。 + +## 10. 显示声明规范 + +公开模型必须声明 `DISPLAY`: + +```python +DISPLAY = ComponentDisplaySpec( + label="示例阻力元件", + library_id="experimental", + category_id="flow", + symbol="generic", + ports=( + PortDisplaySpec("port_a", "left", order=10), + PortDisplaySpec("port_b", "right", order=20), + ), + order=90, +) +``` + +规则: + +- `library_id` 必须等于所属库 ID。 +- `category_id` 必须存在于所属库的 `categories`。 +- `symbol` 是前端图形键,不是模型类型。 +- 未实现专用图标时使用新的稳定键,前端会回退到通用图形。 +- 只有确实需要专用工程图标时才修改前端图标渲染器。 +- `side` 只允许 `left` 或 `right`。 +- 旋转和镜像不能改变端口名或物理语义。 + +## 11. 标准创建入口 + +`create()` 是注册器创建模型的唯一入口: + +```python +@classmethod +def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], +) -> ExampleComponent: + return cls( + name=name, + medium=medium, + coefficient=parameters["coefficient"], + ) +``` + +注册器会在调用前: + +1. 补齐默认参数。 +2. 拒绝未知参数。 +3. 检查有限值和边界。 + +调用后还会检查: + +1. 返回对象类型正确。 +2. 实例 `model_type` 与 `MODEL_TYPE` 一致。 +3. 实际端口与 `PORTS` 完全一致。 +4. 实例保存的参数与规范化参数完全一致。 + +`create()` 不应重复实现参数默认值和边界校验,也不能静默修改传入参数。 + +## 12. 方程实现要求 + +模型方程必须满足: + +- 残差形式统一为“期望等式左侧减右侧”。 +- 每条 `EquationResidual` 使用稳定、可定位的 `id`。 +- `variables` 列出该残差实际涉及的端口量或状态。 +- `role` 与方程主要约束的物理角色一致。 +- 对零压差、零流量和反向流动给出有限结果。 +- 必要正则化必须有物理解释,并通过边界测试保护。 +- 不得用画布坐标、连接线方向或组件名称决定方程。 + +动态模型还必须: + +- 状态向量长度稳定。 +- `get_state_vector()` 和 `set_state_vector()` 互为逆操作。 +- 状态导数满足质量和能量守恒约定。 +- 初始化默认值能够产生有限介质状态。 + +## 13. 可复制的代数模型模板 + +下面是一个符合当前规范的两端口代数阻力模板。复制后必须根据真实物理模型修改 +类型、参数、方程、名称和测试,不能只改类名就注册。 + +```python +from __future__ import annotations + +from collections.abc import Mapping +from math import sqrt + +from app.simulation.core.base import AlgebraicComponent +from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec +from app.simulation.core.equations import EquationResidual +from app.simulation.core.metadata import ParameterDefinition +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.ports import PortDefinition + + +class ExampleRestriction(AlgebraicComponent): + MODEL_TYPE = "example_restriction" + MODEL_VERSION = "1.0.0" + PORTS = ( + PortDefinition.pneumatic("port_a", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_b", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + name="K", + label="流量系数", + quantity="flow_coefficient", + unit="kg/(s*Pa^0.5)", + default=1e-5, + minimum=0.0, + ), + ) + RESULT_VARIABLES = () + DISPLAY = ComponentDisplaySpec( + label="示例阻力元件", + library_id="experimental", + category_id="flow", + symbol="generic", + ports=( + PortDisplaySpec("port_a", "left", order=10), + PortDisplaySpec("port_b", "right", order=20), + ), + order=90, + ) + + def __init__(self, name: str, K: float = 1e-5) -> None: + super().__init__(name) + self.set_parameter_values({"K": K}) + self.K = K + self.port_a = self.register_declared_port("port_a") + self.port_b = self.register_declared_port("port_b") + + @classmethod + def create( + cls, + *, + name: str, + medium: IdealGasMedium, + parameters: Mapping[str, float], + ) -> ExampleRestriction: + return cls(name=name, K=parameters["K"]) + + def pressure_flow_equation_residuals( + self, + ) -> tuple[EquationResidual, ...]: + pressure_difference = self.port_a.p - self.port_b.p + expected_flow = ( + self.K + * sqrt(abs(pressure_difference)) + * (1.0 if pressure_difference > 0.0 else -1.0) + if pressure_difference != 0.0 + else 0.0 + ) + return ( + EquationResidual( + id=f"{self.name}:mass_flow_balance", + owner="component", + owner_id=self.name, + relation="sumToZero", + variables=( + f"{self.name}.port_a.m_flow", + f"{self.name}.port_b.m_flow", + ), + role="flow", + value=self.port_a.m_flow + self.port_b.m_flow, + ), + EquationResidual( + id=f"{self.name}:pressure_flow_relation", + owner="component", + owner_id=self.name, + relation="constitutive", + variables=( + f"{self.name}.port_a.p", + f"{self.name}.port_b.p", + f"{self.name}.port_a.m_flow", + ), + role="flow", + value=self.port_a.m_flow - expected_flow, + ), + ) + + def update_stream_outflows( + self, + connected_h: Mapping[str, float], + ) -> None: + self.port_a.h_outflow = connected_h["port_b"] + self.port_b.h_outflow = connected_h["port_a"] +``` + +真实现有模型可参考: + +- 储能元件: + [`cylinder.py`](../app/simulation/components/experimental/storage/cylinder.py) +- 阻性元件: + [`orifice.py`](../app/simulation/components/experimental/flow/orifice.py) +- 多端口连接元件: + [`tee.py`](../app/simulation/components/experimental/junctions/tee.py) + +## 14. 注册模型 + +模型文件完成后,只修改所属库的 `library.py`: + +```python +models=( + # 已有模型 + "app.simulation.components.experimental.flow.example_restriction:ExampleRestriction", +) +``` + +禁止: + +- 直接修改 `COMPONENT_MODEL_REGISTRY`。 +- 在前端复制参数和端口定义作为正式来源。 +- 递归扫描组件目录自动导入所有 `.py`。 +- 同时注册两个相同 `MODEL_TYPE`。 +- 把测试类、抽象基类或内部算例模型加入公开清单。 + +## 15. 测试要求 + +每个公开模型至少添加: + +1. 静态契约测试。 +2. 默认参数创建测试。 +3. 参数边界测试。 +4. 端口与显示布局一致性测试。 +5. 关键方程残差测试。 +6. 零流量或反向流动测试。 +7. 目录输出测试。 +8. 最小 XML 编译测试。 +9. 能进入通用求解器的模型,再添加短时仿真测试。 + +推荐先运行: + +```powershell +.\.venv-win\Scripts\python.exe -m unittest ` + tests.test_component_registry ` + tests.test_component_catalog ` + tests.test_component_metadata +``` + +然后运行完整回归: + +```powershell +.\.venv-win\Scripts\python.exe -m unittest discover -s tests +``` + +目录契约影响前端时还要运行: + +```powershell +cd frontend +$env:Path = 'F:\Master\SystemSimulationApp\.tools\node-v24.18.0-win-x64;' + $env:Path +npm.cmd run build +``` + +## 16. 修改已有模型的安全步骤 + +1. 找到 `MODEL_TYPE` 的所有 XML、工程和测试引用。 +2. 记录修改前的端口、参数、结果和默认行为。 +3. 判断版本级别和是否需要迁移。 +4. 先增加或修改测试,明确预期物理行为。 +5. 修改模型类,不在注册器和前端复制规则。 +6. 检查默认实例和旧参数是否仍能创建。 +7. 检查最小系统是否仍然闭合。 +8. 运行针对性测试和完整回归。 +9. 同步本文档或模型专属说明中的物理假设。 + +## 17. 人工或 AI 的任务输入卡 + +为了减少猜测,新增模型前建议先填写: + +```text +模型中文名称: +MODEL_TYPE: +所属 library_id: +所属 category_id: +物理域: +模型用途和边界: +端口列表及含义: +参数列表、SI 单位、默认值和范围: +状态变量: +代数方程或微分方程: +正流量约定: +需要显示的组件结果: +已知参考模型或工程公式: +最小测试系统: +允许的近似: +明确不实现的能力: +``` + +如果关键物理信息缺失,AI 应先通过现有模型、测试或用户提供的参考补齐;不能仅凭 +组件名称自行创造方程。 + +## 18. AI 修改协议 + +AI 创建或修改模型时必须遵守: + +### 修改前 + +1. 读取第 3 节列出的文件。 +2. 检查工作区已有改动,不能覆盖无关修改。 +3. 明确模型是公开模型还是内部模型。 +4. 明确端口物理域、状态、参数、方程和结果。 +5. 找到最接近的现有模型并沿用代码风格。 + +### 修改中 + +1. 将物理契约保存在模型类中。 +2. 只在库清单中登记公开模型。 +3. 不修改集中注册表来加入单个模型。 +4. 不为了让测试通过而放宽全局校验。 +5. 不改变现有模型标识,除非任务明确要求迁移。 +6. 不把前端拖拽方向当作物理流向。 +7. 不把求解器失败简单隐藏为默认结果。 + +### 修改后 + +1. 展示涉及的模型、清单和测试文件。 +2. 报告版本变化和兼容性影响。 +3. 运行针对性测试、完整后端测试和必要的前端构建。 +4. 检查 `GET /api/components/catalog` 中的模型、分类、端口和参数。 +5. 告知用户需要重启 FastAPI 才能加载新的 Python 模块。 +6. 未执行的校验必须明确说明原因。 + +## 19. 常见失败与处理 + +| 现象 | 常见原因 | 处理 | +| --- | --- | --- | +| FastAPI 启动时报模型缺少声明 | 字段继承自父类或漏写 | 在公开模型类中显式声明 | +| 模型未出现在前端 | 未加入 `library.py` 或后端未重启 | 检查清单并重启 FastAPI | +| 前端显示“内置兜底” | `/api/components/catalog` 不可用 | 检查 8000 端口和接口响应 | +| 显示端口校验失败 | `DISPLAY.ports` 与 `PORTS` 不一致 | 使用相同端口名和完整集合 | +| 单位校验失败 | `quantity` 与 SI 单位不匹配 | 使用受控单位表或先扩展规范 | +| 默认模型无法注册 | 默认参数越界或构造函数未保存参数 | 修复默认值和 `set_parameter_values()` | +| XML 报不支持模型 | XML `type` 与 `MODEL_TYPE` 不一致 | 修正类型或提供迁移 | +| 模型可显示但无法仿真 | 只完成目录元数据,方程或物理域求解未实现 | 补齐方程、网络和求解测试 | + +## 20. 完成定义 + +一个模型只有同时满足以下条件才算完成: + +- 模型契约完整且启动校验通过。 +- 默认参数和边界有效。 +- 端口、参数和结果具有稳定物理含义。 +- 方程覆盖零流量、正常流动和必要的反向流动。 +- 模型已加入正确库清单。 +- 目录接口能自动输出模型。 +- 前端无需复制参数和端口定义即可使用。 +- XML 能映射到正确模型。 +- 最小系统能够编译;声称可仿真的模型必须产生有限结果。 +- 针对性测试、完整回归和必要的前端构建通过。 +- 文档记录了模型假设、适用范围和已知限制。 diff --git a/docs/system-xml-v1.md b/docs/system-xml-v1.md new file mode 100644 index 0000000..c0815b2 --- /dev/null +++ b/docs/system-xml-v1.md @@ -0,0 +1,72 @@ +# System XML v1 协议 + +> 此版本仅用于识别旧文件。新项目使用 [System XML v2](system-xml-v2.md),物理连接在 v2 中改为无序端点。 + +System XML 是 ReactFlow 前端与 `app.simulation` 仿真层之间的稳定交换格式。 + +## 基本约定 + +- 根元素固定为 `System`,`schemaVersion` 固定为 `1`。 +- `unitSystem` 固定为 `SI`。组件参数必须保存为 SI 基准值,界面显示单位不进入数值换算语义。 +- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。 +- `Component.id` 是稳定的仿真实例 ID;`name` 是用户可编辑的显示名称。 +- `Component.type` 是仿真后端模型类型;`componentType` 是前端组件类型。 +- `Connection.source/target` 只标识图形拓扑端点,不代表仿真中的实际流动方向。正流和倒流由求解器决定。 +- v1 文档不携带结果数据;仿真结果通过运行接口返回。 + +## 完整示例 + +```xml + + + + + + + + + + + + + + + + + + + + + +``` + +## Simulation + +| 属性 | 类型 | 含义 | 约束 | +|---|---|---|---| +| `tStart` | double | 仿真起始时间,单位 s | 有限数 | +| `tStop` | double | 仿真结束时间,单位 s | 必须大于 `tStart` | +| `step` | double | 结果采样间隔,单位 s | 必须大于 0 | +| `maxStep` | double | 求解器最大积分步长,单位 s | 必须大于 0 | +| `method` | string | 求解器名称,例如 `BDF` | 非空 | + +## Components + +每个 `Component` 必须包含唯一 `id`、显示名称、模型类型、前端类型和画布坐标。组件下可以包含多个 `Port` 和 `Parameter`。 + +参数的 `value` 必须是可转换为有限浮点数的 SI 值。具体组件支持的端口、参数和取值范围由后续组件注册表协议约束。 + +## Connections + +每个 `Connection` 必须包含唯一 `id` 和两个完整端点: + +- `source` + `sourcePort` +- `target` + `targetPort` + +端点中的组件 ID 必须存在,端口名称必须属于对应组件。v1 前端仍使用 source/target 组织连线,但仿真层必须将它们视为连接的两个端点,不得直接解释为固定物理流向。 + +## 版本兼容 + +- 解析器遇到缺少 `schemaVersion` 的旧 XML 时,应明确报告“旧协议”,不能静默当作 v1。 +- v1 新增可选字段时必须保持旧解析器可忽略;删除字段或改变字段语义时必须升级主版本。 +- XSD 只验证文档结构和基础数据类型。跨字段约束、组件端口规则和拓扑可求解性由仿真校验层负责。 diff --git a/docs/system-xml-v2.md b/docs/system-xml-v2.md new file mode 100644 index 0000000..e518453 --- /dev/null +++ b/docs/system-xml-v2.md @@ -0,0 +1,216 @@ +# System XML v2 协议 + +System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的交换格式。v2 将物理端口与信号端口分开,并移除了物理连接中的方向语义。 + +## 基本约定 + +- 根元素 `System` 的 `schemaVersion` 固定为 `2`,`unitSystem` 固定为 `SI`。 +- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。 +- `Component.id` 是稳定实例 ID,`name` 是用户可编辑名称。 +- 参数以 SI 基准值保存;显示单位不改变 XML 中的数值含义。 +- 物理端口统一规定 `m_flow > 0` 表示流入组件,`m_flow < 0` 表示流出组件。 +- `nominalRole` 只表示设计意图和展示语义,不限制实际流向。 +- v2 文档不携带仿真结果;端口实际流向、流量幅值和累计质量由运行结果接口返回。 + +## 完整示例 + +```xml + + + + + + + + + + + + + + + + + + + + + + + + +``` + +## Port + +组件的 `rotation` 只能为 `0/90/180/270`,`mirrored` 表示水平镜像。它们只用于恢复画布布局和端口显示位置,不参与物理方程或流向判断。 + +| 属性 | 含义 | +|---|---| +| `name` | 组件模型中的稳定端口名 | +| `kind` | `physical` 或 `signal` | +| `domain` | 端口物理域,当前流体组件使用 `pneumatic` | +| `nominalRole` | 物理端口使用 `inlet/outlet/bidirectional`;信号端口使用 `input/output` | +| `positiveFlowDirection` | 物理端口固定为 `intoComponent`;信号端口省略 | +| `side` | 前端图标上的 `left/right` 布局位置,不参与物理求解 | + +物理端口的流向由求解结果决定。一个名义出口的 `m_flow > 0` 表示该端口发生实际流入,可在结果层标记为倒流。 + +三通的三个端口当前保留仿真模型已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。 + +## Connection + +物理连接包含两个无序 `Endpoint`。第一个端点不代表上游,第二个端点也不代表下游;交换二者顺序不得改变仿真结果。 + +信号连接也使用两个 `Endpoint`,但必须分别携带 `role="source"` 和 `role="target"`。信号端口只允许 `output` 与 `input` 相连。 + +连接生成前必须验证: + +- 组件和端口存在。 +- 两端 `kind` 相同。 +- 两端 `domain` 相同。 +- 信号连接一端为 `output`,另一端为 `input`。 + +## v1 迁移 + +- v1 的字符串端口在加载时按组件注册表迁移成 v2 端口对象。 +- v1 的 `source/sourcePort/target/targetPort` 在导出 v2 时转换为两个 `Endpoint`。 +- 物理连接不继承 v1 的 source/target 方向。 +- v1 文件仍由原 XSD 描述;新生成文件只输出 v2。 + +XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。 + +## 仿真模型编译接口 + +`POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作: + +1. 按 `node.data.modelType` 创建 `app.simulation` 组件实例,并写入 SI 参数。 +2. 将前端端口声明与组件注册端口逐项比对。 +3. 按画布实际 `edges` 创建无方向物理连接,而不是按组件类型或拖入顺序推断拓扑。 +4. 检查端口存在性、物理域兼容性、重复连接和未连接端口。 + +成功响应中的物理连接只包含两个 `endpoints`,不包含 `source/target`: + +```json +{ + "success": true, + "name": "transfer-system", + "components": [ + { + "id": "cylinder_1", + "type": "cylinder", + "ports": [ + { + "name": "port_b", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "outlet", + "positiveFlowDirection": "intoComponent", + "variables": [ + {"name": "p", "role": "effort", "connectionRule": "equal"}, + {"name": "m_flow", "role": "flow", "connectionRule": "sumToZero"}, + {"name": "h_outflow", "role": "stream", "connectionRule": "streamMix"} + ] + } + ] + } + ], + "connections": [ + { + "id": "edge-1", + "kind": "physical", + "domain": "pneumatic", + "endpoints": [ + {"component": "cylinder_1", "port": "port_b"}, + {"component": "tank_1", "port": "port_a"} + ] + } + ], + "unconnectedPorts": [] +} +``` + +一个物理端口当前只允许一条连接;需要分支时必须显式放置 `Tee` 等结点组件。这样拓扑不会通过“一个端口连多条线”隐式产生结点方程。 + +此接口完成模型实例化、端口契约校验、拓扑编译和压力-流量方程结构组装。编译结果中的 `pressureFlowSystem` 包含未知量、方程、数量及 `isSquare` 状态;方阵只表示结构数量平衡,不代表方程一定可解。 + +当前组件已提供可执行残差:气瓶和贮箱提供状态-压力约束,孔板提供流量守恒和压差-流量本构关系,三通提供等压零结点和流量守恒。XML 注册表中的管段使用准稳态 Darcy 阻性模型,同时提供流量守恒和双向压降关系。连接层根据端口契约生成 `p` 相等及 `m_flow` 代数和为零的残差。 + +`/api/reactflow/simulate-testmodel` 继续保留固定 TestModel 和动态管段,用于已有基线对比。XML 驱动仿真使用独立的通用半显式求解链路,不调用固定 TestModel 闭合器。 + +## 第二阶段:XML 解析与校验 + +第二阶段已经实现从 System XML v2 回到仿真网络的完整入口。解析过程固定分为三层: + +| 层级 | `layer` | 负责内容 | +|---|---|---| +| XML | `xml` | 文档大小、XML 语法、禁止 DTD 和实体声明 | +| XSD | `schema` | v2 版本、元素顺序、必填属性、枚举、基础数值类型 | +| 模型语义 | `semantic` | 组件注册、端口契约、参数集合和范围、端点引用、物理域、连接占用及仿真设置 | + +校验诊断统一包含: + +```json +{ + "severity": "error", + "layer": "semantic", + "code": "ENDPOINT_PORT_UNKNOWN", + "message": "Connection edge-1 references unknown port tank_1.port_x.", + "path": "/System/Connections/Connection[1]/Endpoint[2]", + "line": 18 +} +``` + +未连接端口使用 `PORT_UNCONNECTED` 警告,不会阻止解析和网络编译;结构错误、接口不一致、参数错误和非法拓扑会使 `valid=false`。 + +### API + +四个接口均直接接收 `Content-Type: application/xml` 的原始 XML 请求体: + +- `POST /api/system-xml/validate`:无论成功与否都返回校验报告,便于编辑器实时显示问题。 +- `POST /api/system-xml/parse`:成功时返回规范化工程 JSON;失败时返回 HTTP `422` 和结构化诊断。 +- `POST /api/system-xml/compile-model`:成功时返回仿真网络、仿真设置和校验报告;失败时返回 HTTP `422`。 +- `POST /api/system-xml/simulate`:完成校验、编译、仿真准备、代数闭合、stream 传播和时间积分;成功时返回组件与端口时间序列,失败时返回 HTTP `422` 和仿真层诊断。 + +示例: + +```powershell +Invoke-RestMethod ` + -Method Post ` + -Uri http://127.0.0.1:8000/api/system-xml/validate ` + -ContentType application/xml ` + -InFile .\test\system.xml +``` + +组件参数和端口定义集中在 `app/simulation/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。 + +## 第三阶段:XML 驱动仿真 MVP + +第三阶段当前已经打通: + +1. XML 中的组件、参数和无方向物理连接编译成 `app.simulation` 网络。 +2. 仿真准备层检查未连接端口、方程数量、无储能代数孤岛和无阻力储能直连。 +3. SciPy 非线性最小二乘求解每个时刻的端口压力与质量流量。 +4. 根据求解后的实际流向迭代传播 `h_outflow`,并在三通处执行质量流量加权混合。 +5. 动态组件自动拼装质量及内能导数,使用 XML 的 `tStart/tStop/step/maxStep/method` 开展积分。 +6. 结果包含动态组件的 `m/U/p/T/rho/u/h`,以及全部物理端口的 `p/m_flow/h_outflow` 时间序列。 + +当前限制: + +- 只支持注册表中的气动物理组件,不支持信号端口仿真。 +- 所有物理端口在运行前必须完成连接;分支必须显式使用三通。 +- 每个独立物理网络必须包含至少一个气瓶或贮箱作为压力和焓的储能锚点。 +- 两个储能组件不能通过理想连接或纯三通直接耦合,必须在中间放置孔板或管段。 +- XML 管段当前是准稳态阻性元件,`p0/T0` 用于名义密度和初始代数猜测,不包含管内储气动态。 +- 当前 stream 混合是适合 MVP 的正则化近似,还不是 Modelica `inStream/actualStream` 的严格复刻。 +- 当前是半显式 ODE/代数求解链路,不支持一般高指数 DAE 和事件系统。 + +运行示例: + +```powershell +Invoke-RestMethod ` + -Method Post ` + -Uri http://127.0.0.1:8000/api/system-xml/simulate ` + -ContentType application/xml ` + -InFile .\test\system.xml +``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84c979b..f9b2ff6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,13 @@ "version": "0.1.0", "dependencies": { "@xyflow/react": "^12.11.2", + "html2canvas": "^1.4.1", + "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7" }, "devDependencies": { + "@playwright/test": "^1.62.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", @@ -83,6 +86,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -853,12 +872,30 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/classcat": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "license": "MIT" }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1014,6 +1051,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1287,6 +1337,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lucide-react": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -1326,6 +1385,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -1426,6 +1532,15 @@ "node": ">=0.10.0" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1495,6 +1610,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4593595..3d2cd40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,14 +6,19 @@ "scripts": { "dev": "vite --host 127.0.0.1", "build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build", - "preview": "vite preview --host 127.0.0.1" + "preview": "vite preview --host 127.0.0.1", + "test": "playwright test", + "test:e2e": "playwright test" }, "dependencies": { "@xyflow/react": "^12.11.2", + "html2canvas": "^1.4.1", + "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7" }, "devDependencies": { + "@playwright/test": "^1.62.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..ebfb68a --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: false, + workers: 1, + timeout: 30_000, + expect: { + timeout: 6_000, + }, + use: { + baseURL: "http://127.0.0.1:4173", + channel: "msedge", + headless: true, + viewport: { width: 1440, height: 900 }, + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + webServer: { + command: "npm run dev -- --port 4173 --strictPort", + url: "http://127.0.0.1:4173", + reuseExistingServer: false, + timeout: 60_000, + }, +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a919ef7..898127a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,54 @@ -import { DragEvent, useCallback, useMemo, useState } from "react"; +import { + ChangeEvent, + DragEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { + CSSProperties, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, +} from "react"; +import { + ChevronDown, + ChevronUp, + ClipboardPaste, + Copy, + Download, + FileCode2, + FileDown, + FileUp, + FlipHorizontal2, + FolderOpen, + Grid2X2, + Grip, + ListChecks, + Magnet, + Maximize2, + Minimize2, + Move, + Play, + Redo2, + RotateCw, + Save, + Square, + SquareTerminal, + Trash2, + Undo2, + type LucideIcon, +} from "lucide-react"; import { Background, + BackgroundVariant, + ConnectionLineType, + ConnectionMode, Controls, Handle, - MiniMap, + Panel, Position, ReactFlow, ReactFlowProvider, @@ -11,36 +56,135 @@ import { addEdge, type Connection, type Edge, + type EdgeChange, type Node, + type NodeChange, type NodeProps, type NodeTypes, useEdgesState, useNodesState, useReactFlow, + useUpdateNodeInternals, } from "@xyflow/react"; +import { + EmptySimulationResultsView, + SimulationResultsView, + type ResultVariableMetadata, + type SimulationResultsSnapshot, +} from "./SimulationResultsView"; +import { AutoFitView } from "./AutoFitView"; +import { + WorkspaceViewTabs, + type WorkspaceView, +} from "./WorkspaceViewTabs"; type ParameterValue = number | string; +type UnitQuantity = string; +type PortKind = "physical" | "signal"; +type PortNominalRole = "inlet" | "outlet" | "bidirectional" | "input" | "output"; +type PortSide = "left" | "right"; +type NodeRotation = 0 | 90 | 180 | 270; + +type PortDefinition = { + name: string; + kind: PortKind; + domain: string; + nominalRole: PortNominalRole; + positiveFlowDirection?: "intoComponent"; + side: PortSide; +}; + +type UnitOption = { + value: string; + label: string; + toBase: (value: number) => number; + fromBase: (value: number) => number; +}; type ParameterDefinition = { label: string; unit?: string; + quantity?: UnitQuantity; default: ParameterValue; + min?: number; + max?: number; + minExclusive?: boolean; }; type ComponentDefinition = { type: string; label: string; modelType: string; - ports: string[]; + modelVersion: string; + symbol: string; + order: number; + category: { + id: string; + label: string; + order: number; + }; + ports: PortDefinition[]; parameters: Record; }; +type ComponentLibraryDefinition = { + id: string; + label: string; + version: string; + sourcePackage: string; + temporary: boolean; + order: number; + components: ComponentDefinition[]; +}; + +type ComponentCatalogParameterPayload = { + name: string; + label: string; + quantity: string; + unit: string; + default: number; + minimum?: number; + maximum?: number; + minimumExclusive: boolean; +}; + +type ComponentCatalogComponentPayload = { + type: string; + label: string; + modelType: string; + modelVersion: string; + symbol: string; + order: number; + category: { + id: string; + label: string; + order: number; + }; + ports: Array; + parameters: ComponentCatalogParameterPayload[]; +}; + +type ComponentCatalogResponse = { + schemaVersion: number; + libraries: Array< + Omit & { + components: ComponentCatalogComponentPayload[]; + } + >; +}; + +type ComponentCatalogStatus = "loading" | "ready" | "fallback"; + type SimulationNodeData = { label: string; componentType: string; modelType: string; - ports: string[]; + symbol?: string; + ports: PortDefinition[]; parameters: Record; + parameterUnits: Record; + rotation: NodeRotation; + mirrored: boolean; }; type SimulationNode = Node; @@ -61,7 +205,9 @@ type ProjectNodePayload = { x: number; y: number; }; - data: SimulationNodeData; + data: Omit & { + parameterUnits?: Record; + }; }; type ProjectEdgePayload = { @@ -79,25 +225,224 @@ type ReactFlowProjectPayload = { simulation: SimulationConfig; }; -type SaveProjectResult = { - id: string; - componentCount: number; - connectionCount: number; -}; - type SimulationResult = { success: boolean; + status: "completed" | "stopped" | "stalled" | "failed"; + partial: boolean; message: string; - final: { - time: number; - tankPressure: number; - tankTemperature: number; - cylinderPressure: number; - cylinderTemperature: number; - }; + simulatedUntil: number; + requestedStopTime: number; + variables: ResultVariableMetadata[]; + final: Record; series: Record; - artifacts: Record; - networkSummary: string; + diagnostics: { + pressureFlow: { + solveCount: number; + maxScaledResidual: number; + maxEvaluationsPerSolve: number; + }; + stream: { + maxIterationsPerSolve: number; + }; + stateCount: number; + sampleCount: number; + }; +}; + +type ValidationIssue = { + id: string; + severity: "error" | "warning"; + message: string; + nodeId?: string; + edgeId?: string; +}; + +type EditorSnapshot = { + nodes: SimulationNode[]; + edges: SimulationEdge[]; + projectName: string; + simulationConfig: SimulationConfig; +}; + +type EditorClipboard = { + nodes: SimulationNode[]; + edges: SimulationEdge[]; +}; + +type ModelingPaneLayout = { + paletteWidth: number; + propertiesWidth: number; +}; + +type ModelingPaneResizeKind = "palette-width" | "properties-width"; + +type ModelingPaneResizeState = { + kind: ModelingPaneResizeKind; + pointerId: number; + startX: number; + startWidth: number; +}; + +type CanvasGridVisibility = { + lines: boolean; + dots: boolean; +}; + +const AUTOSAVE_KEY = "system-simulation-flow:autosave"; +const PROJECT_KEY_PREFIX = "system-simulation-flow:project:"; +const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result"; +const CONSOLE_PLACEMENT_KEY = "system-simulation-flow:console-positions"; +const MODELING_PANE_LAYOUT_KEY = "system-simulation-flow:modeling-pane-layout"; +const CANVAS_GRID_VISIBILITY_KEY = "system-simulation-flow:canvas-grid-visibility"; +const CONSOLE_VIEWPORT_MARGIN = 8; +const SIMULATION_STREAM_IDLE_TIMEOUT_MS = 30_000; +const SIMULATION_SOLVER_STALL_TIMEOUT_MS = 60_000; +const GRID_SIZE = 18; +const MODELING_PANE_SPLITTER_SIZE = 7; +const MIN_PALETTE_WIDTH = 180; +const MAX_PALETTE_WIDTH = 420; +const MIN_PROPERTIES_WIDTH = 280; +const MAX_PROPERTIES_WIDTH = 560; +const MIN_MODELING_CANVAS_WIDTH = 420; +const MODELING_PANE_KEYBOARD_STEP = 20; +const HISTORY_LIMIT = 50; +const NODE_FRAME_WIDTH = 132; +const NODE_FRAME_HEIGHT = 84; + +function defaultModelingPaneLayout(): ModelingPaneLayout { + return { + paletteWidth: 230, + propertiesWidth: 330, + }; +} + +function loadModelingPaneLayout(): ModelingPaneLayout { + const fallback = defaultModelingPaneLayout(); + try { + const parsed = JSON.parse( + localStorage.getItem(MODELING_PANE_LAYOUT_KEY) ?? "null", + ) as Partial | null; + if ( + !parsed || + !Number.isFinite(parsed.paletteWidth) || + !Number.isFinite(parsed.propertiesWidth) + ) { + return fallback; + } + return { + paletteWidth: Math.min( + MAX_PALETTE_WIDTH, + Math.max(MIN_PALETTE_WIDTH, Number(parsed.paletteWidth)), + ), + propertiesWidth: Math.min( + MAX_PROPERTIES_WIDTH, + Math.max(MIN_PROPERTIES_WIDTH, Number(parsed.propertiesWidth)), + ), + }; + } catch { + return fallback; + } +} + +function clampModelingPaneLayout( + layout: ModelingPaneLayout, + workbenchWidth: number, +): ModelingPaneLayout { + let paletteWidth = Math.min( + MAX_PALETTE_WIDTH, + Math.max(MIN_PALETTE_WIDTH, layout.paletteWidth), + ); + let propertiesWidth = Math.min( + MAX_PROPERTIES_WIDTH, + Math.max(MIN_PROPERTIES_WIDTH, layout.propertiesWidth), + ); + const availableSideWidth = Math.max( + MIN_PALETTE_WIDTH + MIN_PROPERTIES_WIDTH, + workbenchWidth - + MIN_MODELING_CANVAS_WIDTH - + MODELING_PANE_SPLITTER_SIZE * 2, + ); + let overflow = paletteWidth + propertiesWidth - availableSideWidth; + if (overflow > 0) { + const propertiesReduction = Math.min( + overflow, + propertiesWidth - MIN_PROPERTIES_WIDTH, + ); + propertiesWidth -= propertiesReduction; + overflow -= propertiesReduction; + } + if (overflow > 0) { + paletteWidth -= Math.min( + overflow, + paletteWidth - MIN_PALETTE_WIDTH, + ); + } + return { paletteWidth, propertiesWidth }; +} + +function loadCanvasGridVisibility(): CanvasGridVisibility { + try { + const parsed = JSON.parse( + localStorage.getItem(CANVAS_GRID_VISIBILITY_KEY) ?? "null", + ) as Partial | null; + if ( + parsed && + typeof parsed.lines === "boolean" && + typeof parsed.dots === "boolean" + ) { + return { lines: parsed.lines, dots: parsed.dots }; + } + } catch { + // Use the default view when browser storage is unavailable or invalid. + } + return { lines: false, dots: true }; +} + +const identityUnit = (value: number) => value; +const scaleUnit = (factor: number) => ({ + toBase: (value: number) => value * factor, + fromBase: (value: number) => value / factor, +}); + +const unitOptions: Record = { + area: [ + { value: "m2", label: "m²", toBase: identityUnit, fromBase: identityUnit }, + { value: "cm2", label: "cm²", ...scaleUnit(1e-4) }, + { value: "mm2", label: "mm²", ...scaleUnit(1e-6) }, + ], + heat_transfer_coefficient: [ + { + value: "W/(m2*K)", + label: "W/(m²·K)", + toBase: identityUnit, + fromBase: identityUnit, + }, + ], + pressure: [ + { value: "Pa", label: "Pa", toBase: identityUnit, fromBase: identityUnit }, + { value: "kPa", label: "kPa", ...scaleUnit(1_000) }, + { value: "MPa", label: "MPa", ...scaleUnit(1_000_000) }, + { value: "bar", label: "bar", ...scaleUnit(100_000) }, + ], + volume: [ + { value: "m3", label: "m³", toBase: identityUnit, fromBase: identityUnit }, + { value: "L", label: "L", ...scaleUnit(0.001) }, + { value: "mL", label: "mL", ...scaleUnit(0.000001) }, + ], + temperature: [ + { value: "K", label: "K", toBase: identityUnit, fromBase: identityUnit }, + { + value: "degC", + label: "°C", + toBase: (value) => value + 273.15, + fromBase: (value) => value - 273.15, + }, + ], + length: [ + { value: "m", label: "m", toBase: identityUnit, fromBase: identityUnit }, + { value: "cm", label: "cm", ...scaleUnit(0.01) }, + { value: "mm", label: "mm", ...scaleUnit(0.001) }, + ], }; const defaultSimulationConfig: SimulationConfig = { @@ -108,61 +453,424 @@ const defaultSimulationConfig: SimulationConfig = { method: "BDF", }; -const componentDefinitions: ComponentDefinition[] = [ +type SimulationConsoleLevel = "info" | "success" | "warning" | "error"; +type SimulationConsoleMode = "minimized" | "normal" | "maximized"; + +type ConsolePosition = { + x: number; + y: number; +}; + +type ConsolePlacement = ConsolePosition & { + horizontalAnchor: "left" | "right"; + verticalAnchor: "top" | "bottom"; +}; + +type ConsoleDragState = { + pointerId: number; + offsetX: number; + offsetY: number; + width: number; + height: number; +}; + +type SimulationConsoleEntry = { + id: number; + timestamp: string; + level: SimulationConsoleLevel; + message: string; + format: "message" | "xml"; +}; + +type SimulationProgressState = { + state: "idle" | "running" | "success" | "warning" | "error"; + percent: number; + message: string; + startTime: number; + simulatedTime: number; + totalTime: number; +}; + +type SimulationStreamIssue = { + code?: string; + message?: string; +}; + +type SimulationCancelReason = "user" | "stalled"; + +type SimulationTaskResponse = { + simulationId: string; + status: "queued" | "running" | "cancelling" | "completed" | "stopped" | "stalled" | "failed"; + cancelReason: SimulationCancelReason | null; + result: SimulationResult | null; + error: SimulationStreamEvent | null; +}; + +type SimulationStreamEvent = + | { + event: "progress"; + progress: number; + phase: string; + message: string; + heartbeat?: boolean; + simulatedTime?: number; + totalTime?: number; + } + | { + event: "result"; + progress: number; + phase: "complete"; + message: string; + simulatedTime?: number; + totalTime?: number; + result: SimulationResult; + } + | { + event: "error"; + progress: number; + phase: "failed"; + status: number; + message: string; + detail?: unknown; + simulatedTime?: number; + totalTime?: number; + }; +const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"]; + +function physicalPort( + name: string, + nominalRole: Extract, + side: PortSide, +): PortDefinition { + return { + name, + kind: "physical", + domain: "pneumatic", + nominalRole, + positiveFlowDirection: "intoComponent", + side, + }; +} + +const fallbackComponentDefinitions: ComponentDefinition[] = [ { type: "cylinder", label: "气瓶", modelType: "cylinder", - ports: ["port_b"], + modelVersion: "1.0.0", + symbol: "cylinder", + order: 10, + category: { id: "storage", label: "储能元件", order: 10 }, + ports: [physicalPort("port_b", "outlet", "right")], parameters: { - volume: { label: "容积", unit: "m3", default: 0.01 }, - p0: { label: "初始压力", unit: "Pa", default: 35000000 }, - T0: { label: "初始温度", unit: "K", default: 300 }, + volume: { + label: "容积", + unit: "m3", + quantity: "volume", + default: 0.01, + min: 0, + minExclusive: true, + }, + p0: { + label: "初始压力", + unit: "Pa", + quantity: "pressure", + default: 35000000, + min: 0, + minExclusive: true, + }, + T0: { + label: "初始温度", + unit: "K", + quantity: "temperature", + default: 300, + min: 0, + minExclusive: true, + }, }, }, { type: "tank", label: "贮箱", modelType: "tank", - ports: ["port_a"], + modelVersion: "1.0.0", + symbol: "tank", + order: 20, + category: { id: "storage", label: "储能元件", order: 10 }, + ports: [physicalPort("port_a", "inlet", "left")], parameters: { - volume: { label: "容积", unit: "m3", default: 0.1 }, - p0: { label: "初始压力", unit: "Pa", default: 100000 }, - T0: { label: "初始温度", unit: "K", default: 300 }, + volume: { + label: "容积", + unit: "m3", + quantity: "volume", + default: 0.1, + min: 0, + minExclusive: true, + }, + p0: { + label: "初始压力", + unit: "Pa", + quantity: "pressure", + default: 100000, + min: 0, + minExclusive: true, + }, + T0: { + label: "初始温度", + unit: "K", + quantity: "temperature", + default: 300, + min: 0, + minExclusive: true, + }, }, }, { type: "pipe", label: "管段", modelType: "pipe", - ports: ["port_a", "port_b"], + modelVersion: "1.0.0", + symbol: "pipe", + order: 30, + category: { id: "flow", label: "流动元件", order: 20 }, + ports: [ + physicalPort("port_a", "inlet", "left"), + physicalPort("port_b", "outlet", "right"), + ], parameters: { - length: { label: "长度", unit: "m", default: 5 }, - diameter: { label: "直径", unit: "m", default: 0.02 }, - lambda_darcy: { label: "摩阻系数", default: 0.02 }, - p0: { label: "初始压力", unit: "Pa", default: 100000 }, - T0: { label: "初始温度", unit: "K", default: 300 }, + length: { + label: "长度", + unit: "m", + quantity: "length", + default: 5, + min: 0, + minExclusive: true, + }, + diameter: { + label: "直径", + unit: "m", + quantity: "length", + default: 0.02, + min: 0, + minExclusive: true, + }, + lambda_darcy: { label: "摩阻系数", default: 0.02, min: 0 }, + p0: { + label: "初始压力", + unit: "Pa", + quantity: "pressure", + default: 100000, + min: 0, + minExclusive: true, + }, + T0: { + label: "初始温度", + unit: "K", + quantity: "temperature", + default: 300, + min: 0, + minExclusive: true, + }, }, }, { type: "orifice", label: "孔板/阀门", modelType: "orifice", - ports: ["port_a", "port_b"], + modelVersion: "1.0.0", + symbol: "orifice", + order: 40, + category: { id: "flow", label: "流动元件", order: 20 }, + ports: [ + physicalPort("port_a", "inlet", "left"), + physicalPort("port_b", "outlet", "right"), + ], parameters: { - K: { label: "流量系数", default: 0.00001 }, - opening: { label: "开度", default: 1 }, + K: { + label: "流量系数", + unit: "kg/(s*Pa^0.5)", + quantity: "flow_coefficient", + default: 0.00001, + min: 0, + }, + opening: { label: "开度", default: 1, min: 0, max: 1 }, }, }, { type: "tee", label: "三通", modelType: "tee", - ports: ["port_in", "port_out1", "port_out2"], + modelVersion: "1.0.0", + symbol: "tee", + order: 50, + category: { id: "junctions", label: "连接元件", order: 30 }, + ports: [ + physicalPort("port_in", "bidirectional", "left"), + physicalPort("port_out1", "bidirectional", "right"), + physicalPort("port_out2", "bidirectional", "right"), + ], parameters: {}, }, ]; +const fallbackComponentLibraries: ComponentLibraryDefinition[] = [ + { + id: "experimental", + label: "临时测试组件库", + version: "0.1.0", + sourcePackage: "frontend-fallback", + temporary: true, + order: 100, + components: fallbackComponentDefinitions, + }, +]; + +const supportedComponentSymbols = new Set([ + "cylinder", + "tank", + "pipe", + "orifice", + "tee", +]); + +function normalizeComponentCatalog(payload: unknown): ComponentLibraryDefinition[] { + const catalog = payload as ComponentCatalogResponse | null; + if ( + !catalog || + catalog.schemaVersion !== 1 || + !Array.isArray(catalog.libraries) + ) { + throw new Error("组件目录格式不受支持"); + } + + const componentTypes = new Set(); + const libraries = catalog.libraries.map((library) => { + if ( + typeof library.id !== "string" || + typeof library.label !== "string" || + typeof library.version !== "string" || + !Array.isArray(library.components) + ) { + throw new Error("组件库声明不完整"); + } + const components = library.components.map((component) => { + if ( + typeof component.type !== "string" || + typeof component.modelType !== "string" || + typeof component.modelVersion !== "string" || + typeof component.label !== "string" || + !Array.isArray(component.ports) || + !Array.isArray(component.parameters) + ) { + throw new Error(`组件库 ${library.id} 包含无效元件`); + } + if (componentTypes.has(component.type)) { + throw new Error(`组件类型重复:${component.type}`); + } + componentTypes.add(component.type); + + return { + type: component.type, + label: component.label, + modelType: component.modelType, + modelVersion: component.modelVersion, + symbol: component.symbol || component.type, + order: Number(component.order) || 0, + category: { + id: component.category?.id || "uncategorized", + label: component.category?.label || "其他元件", + order: Number(component.category?.order) || 0, + }, + ports: component.ports.map((port) => ({ + name: port.name, + kind: port.kind, + domain: port.domain, + nominalRole: port.nominalRole, + ...(port.positiveFlowDirection === "intoComponent" + ? { positiveFlowDirection: "intoComponent" as const } + : {}), + side: port.side, + })), + parameters: Object.fromEntries( + component.parameters.map((parameter) => [ + parameter.name, + { + label: parameter.label || parameter.name, + ...(parameter.unit ? { unit: parameter.unit } : {}), + ...(parameter.quantity && parameter.quantity !== "dimensionless" + ? { quantity: parameter.quantity } + : {}), + default: parameter.default, + ...(parameter.minimum !== undefined + ? { min: parameter.minimum } + : {}), + ...(parameter.maximum !== undefined + ? { max: parameter.maximum } + : {}), + ...(parameter.minimumExclusive + ? { minExclusive: true } + : {}), + }, + ]), + ), + } satisfies ComponentDefinition; + }); + + return { + id: library.id, + label: library.label, + version: library.version, + sourcePackage: library.sourcePackage, + temporary: Boolean(library.temporary), + order: Number(library.order) || 0, + components: components.sort( + (first, second) => first.order - second.order || first.type.localeCompare(second.type), + ), + }; + }); + + if (componentTypes.size === 0) { + throw new Error("组件目录为空"); + } + return libraries.sort( + (first, second) => first.order - second.order || first.id.localeCompare(second.id), + ); +} + +function componentSymbolClass(symbol: string) { + return supportedComponentSymbols.has(symbol) ? symbol : "generic"; +} + +function groupLibraryComponents(library: ComponentLibraryDefinition) { + const categories = new Map< + string, + { + id: string; + label: string; + order: number; + components: ComponentDefinition[]; + } + >(); + library.components.forEach((component) => { + const category = categories.get(component.category.id) ?? { + ...component.category, + components: [], + }; + category.components.push(component); + categories.set(component.category.id, category); + }); + return [...categories.values()] + .map((category) => ({ + ...category, + components: category.components.sort( + (first, second) => first.order - second.order || first.type.localeCompare(second.type), + ), + })) + .sort( + (first, second) => first.order - second.order || first.id.localeCompare(second.id), + ); +} + const nextNodeNumberByType: Record = {}; function defaultParameters(definition: ComponentDefinition) { @@ -174,60 +882,184 @@ function defaultParameters(definition: ComponentDefinition) { ); } +function defaultParameterUnits(definition: ComponentDefinition) { + return Object.fromEntries( + Object.entries(definition.parameters) + .filter(([, parameter]) => parameter.unit) + .map(([key, parameter]) => [key, parameter.unit as string]), + ); +} + function buildNodeData(definition: ComponentDefinition): SimulationNodeData { return { label: definition.label, componentType: definition.type, modelType: definition.modelType, - ports: definition.ports, + symbol: definition.symbol, + ports: definition.ports.map((port) => ({ ...port })), parameters: defaultParameters(definition), + parameterUnits: defaultParameterUnits(definition), + rotation: 0, + mirrored: false, }; } -function SimulationComponentNode({ data, selected }: NodeProps) { - const inputPorts = data.ports.filter((port) => port.includes("in") || port === "port_a"); - const outputPorts = data.ports.filter((port) => !inputPorts.includes(port)); - const singlePorts = data.ports.length === 1 ? data.ports : []; +function SimulationComponentNode({ id, data, selected }: NodeProps) { + const updateNodeInternals = useUpdateNodeInternals(); + const rotation = normalizeNodeRotation(data.rotation); + const mirrored = Boolean(data.mirrored); + const vertical = rotation === 90 || rotation === 270; + const hasDedicatedSymbol = supportedComponentSymbols.has( + data.symbol ?? data.componentType, + ); + const portPlacements = transformedPortPlacements(data.ports, rotation, mirrored); + + useEffect(() => { + updateNodeInternals(id); + }, [mirrored, rotation, id, updateNodeInternals]); return ( -
-