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/.gitignore b/PythonModels/.gitignore deleted file mode 100644 index 7a60b85..0000000 --- a/PythonModels/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -__pycache__/ -*.pyc diff --git a/PythonModels/__init__.py b/PythonModels/__init__.py deleted file mode 100644 index a392a55..0000000 --- a/PythonModels/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Python port scaffold for the Modelica-based pressurization system.""" - diff --git a/PythonModels/registry.py b/PythonModels/registry.py deleted file mode 100644 index b43ef06..0000000 --- a/PythonModels/registry.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Mapping -from dataclasses import dataclass - -from PythonModels.components.cylinder import Cylinder -from PythonModels.components.orifice import Orifice -from PythonModels.components.resistive_pipe import ResistivePipe -from PythonModels.components.tank import Tank -from PythonModels.components.tee import Tee -from PythonModels.core.base import Component -from PythonModels.core.metadata import ParameterDefinition -from PythonModels.core.medium import IdealGasMedium -from PythonModels.core.ports import PortDefinition - - -ParameterSpec = ParameterDefinition - - -ComponentFactory = Callable[ - [str, IdealGasMedium, Mapping[str, float]], - Component, -] - - -@dataclass(frozen=True) -class ComponentModelSpec: - model_type: str - ports: tuple[PortDefinition, ...] - parameters: tuple[ParameterDefinition, ...] - factory: ComponentFactory - - @property - def parameter_by_name(self) -> dict[str, ParameterDefinition]: - return {parameter.name: parameter for parameter in self.parameters} - - def create( - self, - name: str, - medium: IdealGasMedium, - values: Mapping[str, float], - ) -> Component: - 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}." - ) - unknown = sorted(set(values) - set(self.parameter_by_name)) - if unknown: - raise ValueError( - f"Component '{name}' contains unsupported parameters: " - + ", ".join(unknown) - + "." - ) - component = self.factory(name, medium, resolved) - component.model_type = self.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 != resolved: - raise ValueError( - f"Component implementation {self.model_type} did not preserve its parameters." - ) - return component - - -def _cylinder_factory( - name: str, - medium: IdealGasMedium, - values: Mapping[str, float], -) -> Component: - return Cylinder( - name=name, - medium=medium, - V=values["volume"], - p0=values["p0"], - T0=values["T0"], - ) - - -def _tank_factory( - name: str, - medium: IdealGasMedium, - values: Mapping[str, float], -) -> Component: - return Tank( - name=name, - medium=medium, - V=values["volume"], - p0=values["p0"], - T0=values["T0"], - ) - - -def _pipe_factory( - name: str, - medium: IdealGasMedium, - values: Mapping[str, float], -) -> Component: - return ResistivePipe( - name=name, - medium=medium, - L=values["length"], - D=values["diameter"], - lambda_darcy=values["lambda_darcy"], - p0=values["p0"], - T0=values["T0"], - ) - - -def _orifice_factory( - name: str, - medium: IdealGasMedium, - values: Mapping[str, float], -) -> Component: - return Orifice(name=name, opening=values["opening"], K=values["K"]) - - -def _tee_factory( - name: str, - medium: IdealGasMedium, - values: Mapping[str, float], -) -> Component: - return Tee(name=name) - - -COMPONENT_MODEL_REGISTRY: dict[str, ComponentModelSpec] = { - "cylinder": ComponentModelSpec( - model_type=Cylinder.MODEL_TYPE, - ports=Cylinder.PORTS, - parameters=Cylinder.PARAMETERS, - factory=_cylinder_factory, - ), - "tank": ComponentModelSpec( - model_type=Tank.MODEL_TYPE, - ports=Tank.PORTS, - parameters=Tank.PARAMETERS, - factory=_tank_factory, - ), - "pipe": ComponentModelSpec( - model_type=ResistivePipe.MODEL_TYPE, - ports=ResistivePipe.PORTS, - parameters=ResistivePipe.PARAMETERS, - factory=_pipe_factory, - ), - "orifice": ComponentModelSpec( - model_type=Orifice.MODEL_TYPE, - ports=Orifice.PORTS, - parameters=Orifice.PARAMETERS, - factory=_orifice_factory, - ), - "tee": ComponentModelSpec( - model_type=Tee.MODEL_TYPE, - ports=Tee.PORTS, - parameters=Tee.PARAMETERS, - factory=_tee_factory, - ), -} - - -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/README.md b/README.md index 20a476f..6b4cc30 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # SystemSimulationApp -ReactFlow 系统建模与 PythonModels 仿真应用。 +ReactFlow 系统建模与 `app.simulation` 仿真后端。 ## 后端接口 +- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。 - `POST /api/reactflow/system-xml`:导出 System XML v2。 -- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为 PythonModels 网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。 +- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。 - `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。 - `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。 - `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。 -- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 PythonModels 组件网络。 +- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。 - `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。 - `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。 @@ -25,6 +26,10 @@ XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境 ## 文档 +- [开发文档索引](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) diff --git a/app/main.py b/app/main.py index a63cc55..89de93f 100644 --- a/app/main.py +++ b/app/main.py @@ -28,8 +28,8 @@ from app.system_xml import ( ) if TYPE_CHECKING: - from PythonModels.core.network import SimulationNetwork - from PythonModels.core.ports import PortDefinition + from app.simulation.core.ports import PortDefinition + from app.simulation.systems.network import SimulationNetwork app = FastAPI(title="System Simulation ReactFlow App") @@ -231,6 +231,13 @@ 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: try: @@ -572,10 +579,10 @@ def run_system_xml_simulation( progress_callback: SimulationProgressEmitter | None = None, cancel_check: Callable[[], bool] | None = None, ) -> dict[str, object]: - from PythonModels.core.algebraic import AlgebraicSolveError - from PythonModels.core.solver import SolveIVPConfig - from PythonModels.core.stream import StreamSolveError - from PythonModels.systems.generic import ( + 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, ) @@ -1071,9 +1078,9 @@ def validate_compatible_ports( def compile_reactflow_network(project: ReactFlowProjectPayload) -> "SimulationNetwork": - from PythonModels.core.medium import IdealGasMedium - from PythonModels.core.network import SimulationNetwork - from PythonModels.registry import get_component_model_spec + 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) @@ -1156,14 +1163,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, @@ -1171,6 +1177,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: diff --git a/PythonModels/README.md b/app/simulation/README.md similarity index 77% rename from PythonModels/README.md rename to app/simulation/README.md index 328256e..193a99f 100644 --- a/PythonModels/README.md +++ b/app/simulation/README.md @@ -1,6 +1,6 @@ -# PythonModels +# 仿真后端 -`PythonModels` 用于承接 `ModelicaModels` 的 Python 平台移植。 +`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。 目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。 @@ -8,35 +8,44 @@ ## 当前目录 -- `core/`: 通用基础设施 - 包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。 -- `components/`: 元件级 Python 实现 - 目前有 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee` 五类元件。 - 新增或修改元件时先阅读 `components/example.md` 中的建模规范与完整示例。 -- `systems/`: 系统级装配与闭合 - 当前只有 `TestModelSystem`,对应 `ModelicaModels/Testmodel.mo`。 -- `reporting/`: 结果导出与对比 - 当前承接主变量 CSV、温度 CSV/SVG、Python 对 OpenModelica 的对比表与误差摘要导出。 -- `scripts/`: 运行脚本 - 当前入口是 `run_testmodel.py`。 -- `baselines/`: 提交进仓库的稳定基线 - 当前承接 Python 主变量基线和 Python 对 Modelica 的误差摘要基线。 -- `runs/`: 每次实际运行的默认输出目录 - 当前脚本默认会在这里创建带时间戳的子目录,用来放这次运行生成的产物。 +- `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` -- `core/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装 -- `core/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回 -- `components/pipe.py`: 单阻容管道近似,入口压降 + 出口直连内容腔 -- `components/tee.py`: 三通的最小 stream 混合 helper -- `systems/testmodel.py`: `Testmodel` 的系统装配壳与外部运行入口 -- `systems/testmodel_closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写 +- `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/对比摘要导出 -- `scripts/run_testmodel.py`: 基线运行与程序化执行入口 -- `tests/test_pythonmodels_regression.py`: 当前 Python 基线回归测试 +- `examples/testmodel/run.py`: 基线运行与程序化执行入口 +- `tests/`: 当前组件契约、XML、通用系统和结果导出测试 ## 当前阶段进度 @@ -74,7 +83,7 @@ 本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态: 1. `M2`:已完成当前阶段首版 - - 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `systems/testmodel.py` 拆到新的 `systems/testmodel_closure.py` + - 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `examples/testmodel/system.py` 拆到 `examples/testmodel/closure.py` - `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节 2. `M3`:已完成当前阶段首版 @@ -179,19 +188,18 @@ 最小运行方式: ```bash -python3 -m PythonModels.scripts.run_testmodel +python -m app.simulation.examples.testmodel.run ``` 如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如: ```python -from PythonModels.core.solver import SolveIVPConfig -from PythonModels.scripts.run_testmodel import ( +from app.simulation.examples.testmodel.run import ( TestModelRunConfig, TestModelSamplingConfig, run_testmodel, ) -from PythonModels.systems.testmodel import ( +from app.simulation.examples.testmodel.system import ( BranchConfig, CylinderConfig, OrificeConfig, @@ -199,6 +207,7 @@ from PythonModels.systems.testmodel import ( TankConfig, TestModelConfig, ) +from app.simulation.solvers.solver import SolveIVPConfig run_config = TestModelRunConfig( model=TestModelConfig( @@ -219,7 +228,7 @@ result = run_testmodel(run_config=run_config) 如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行: ```python -from PythonModels.scripts.run_testmodel import ( +from app.simulation.examples.testmodel.run import ( prepare_testmodel_run, run_prepared_testmodel, TestModelRunConfig, @@ -239,12 +248,13 @@ print(result.used_modelica_reference) 1. 构建 `TestModelSystem` 2. 打印原始初值向量与约束一致后的初值向量 3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s` -4. 将结果写入 `PythonModels/runs/` 下本次运行专属的时间戳目录 +4. 将结果写入 `app/data/simulation-runs/` 下本次运行专属的时间戳目录 5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果 -当前脚本默认不会再把运行结果直接写到提交基线目录,而是会在 `PythonModels/runs/` 下创建一个带时间戳的子目录,例如: +当前脚本默认不会把运行结果直接写到提交基线目录,而是会在 +`app/data/simulation-runs/` 下创建一个带时间戳的子目录,例如: -- `PythonModels/runs/testmodel_20260512_103000_123456/` +- `app/data/simulation-runs/testmodel_20260512_103000_123456/` 该目录里通常会包含: @@ -258,7 +268,7 @@ print(result.used_modelica_reference) ## 基线结果 当前基线对比摘要来自: -[testmodel_modelica_comparison_summary.txt](/home/lujz/projects/pressurization-transfer-system/PythonModels/baselines/testmodel/testmodel_modelica_comparison_summary.txt) +[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt) 当前四个主变量的最大误差为: @@ -319,24 +329,24 @@ print(result.used_modelica_reference) `PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。 - `core/state.py`: 正常 `VolumeState` 只负责 `[m, U]` 状态打包。 -- `core/network.py`: 正常 +- `systems/network.py`: 正常 负责状态向量拼装和连接摘要,不参与物理求解。 -- `core/solver.py`: 正常 +- `solvers/solver.py`: 正常 已支持 SciPy、RK4 回退和零时长仿真。 -- `components/*.py`: 正常 +- `components/experimental/**/*.py`: 正常 都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。 -- `systems/testmodel.py`: 是当前最重要的技术债集中区 +- `examples/testmodel/system.py`: 是当前最重要的技术债集中区 这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。 -- `scripts/run_testmodel.py`: 正常 +- `examples/testmodel/run.py`: 正常 已不是“最小打印脚本”,而是当前结果导出和对比入口。 -- `baselines/`: 是当前稳定基线,不应该随着日常运行频繁改动。 -- `runs/`: 是当前默认运行产物目录,不是手写源代码,也不应该当作提交基线使用。 +- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。 +- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。 ## 当前主技术债 目前最主要的技术债,可以直接理解成下面 4 件事: 1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。 -2. `systems/testmodel.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。 +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/PythonModels/components/__init__.py b/app/simulation/components/__init__.py similarity index 100% rename from PythonModels/components/__init__.py rename to app/simulation/components/__init__.py diff --git a/PythonModels/components/example.md b/app/simulation/components/example.md similarity index 67% rename from PythonModels/components/example.md rename to app/simulation/components/example.md index 4ca00d5..a20f8f6 100644 --- a/PythonModels/components/example.md +++ b/app/simulation/components/example.md @@ -1,22 +1,36 @@ # 元件建模规范与示例 -本文档是 `PythonModels/components` 下新增元件的最小开发规范。目标是让元件的端口、输入参数和可展示结果都由元件类显式声明,避免 XML 校验、求解器和前端分别维护同一份含义。 +规范的权威版本位于 +[`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`:组件库名称、分类、图标、排序和端口画布位置,不参与物理求解。 元件构造函数还必须: @@ -24,6 +38,7 @@ RESULT_VARIABLES = (...) 2. 使用 `set_parameter_values()` 保存规范化后的输入参数。 3. 使用 `register_declared_port()` 创建已声明端口。 4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。 +5. 实现统一的类方法 `create()`,接收规范化后的 SI 参数。 ## 二、输入参数与结果变量 @@ -79,19 +94,21 @@ from __future__ import annotations from collections.abc import Mapping -from PythonModels.core.base import ThermodynamicVolumeComponent -from PythonModels.core.equations import EquationResidual -from PythonModels.core.metadata import ( +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 PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties -from PythonModels.core.ports import PortDefinition -from PythonModels.core.state import VolumeState +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"), ) @@ -125,6 +142,14 @@ class ExampleVolume(ThermodynamicVolumeComponent): ), ) 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, @@ -145,6 +170,22 @@ class ExampleVolume(ThermodynamicVolumeComponent): 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() @@ -190,27 +231,22 @@ class ExampleVolume(ThermodynamicVolumeComponent): ) ``` -注册时只引用元件类已经声明的契约,不要再复制参数和端口定义: +模型文件不再直接修改全局注册表。完成模型类后,只把类路径加入所属库 +`library.py` 的 `models` 清单: ```python -def _example_volume_factory(name, medium, values): - return ExampleVolume( - name=name, - medium=medium, - volume=values["volume"], - p0=values["p0"], - T0=values["T0"], - ) - - -COMPONENT_MODEL_REGISTRY[ExampleVolume.MODEL_TYPE] = ComponentModelSpec( - model_type=ExampleVolume.MODEL_TYPE, - ports=ExampleVolume.PORTS, - parameters=ExampleVolume.PARAMETERS, - factory=_example_volume_factory, +models=( + # ...已有模型 + "app.simulation.components.experimental.storage.example_volume:ExampleVolume", ) ``` +后端会受控导入清单中的类,校验版本、分类、端口、参数、单位、显示信息和默认实例, +再自动建立注册表。校验通过后,`GET /api/components/catalog` 会输出该元件, +前端刷新时即可加载。 +当前 `experimental` 仅用于规范验证;正式模型应先建立新的库声明,再把 +`library_id` 指向正式库。 + 完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义: ```json @@ -231,10 +267,16 @@ COMPONENT_MODEL_REGISTRY[ExampleVolume.MODEL_TYPE] = ComponentModelSpec( ## 五、新增元件检查清单 -1. `MODEL_TYPE` 是否唯一,并与 XML、前端组件类型一致。 +1. `MODEL_TYPE` 是否唯一,并与 XML 的模型类型一致。 2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。 3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。 4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。 5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。 6. 是否只暴露有工程意义的结果,而非内部计算变量。 -7. 是否补充参数边界、端口契约、结果元数据和最小仿真的自动测试。 +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/PythonModels/components/orifice.py b/app/simulation/components/experimental/flow/orifice.py similarity index 72% rename from PythonModels/components/orifice.py rename to app/simulation/components/experimental/flow/orifice.py index 28238a5..9984ba5 100644 --- a/PythonModels/components/orifice.py +++ b/app/simulation/components/experimental/flow/orifice.py @@ -3,16 +3,19 @@ from __future__ import annotations from collections.abc import Mapping from math import sqrt -from PythonModels.core.base import AlgebraicComponent -from PythonModels.core.equations import EquationResidual -from PythonModels.core.metadata import ParameterDefinition -from PythonModels.core.ports import PortDefinition +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"), @@ -35,6 +38,17 @@ class Orifice(AlgebraicComponent): ), ) 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) @@ -44,6 +58,20 @@ class Orifice(AlgebraicComponent): 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) 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/PythonModels/components/resistive_pipe.py b/app/simulation/components/experimental/flow/resistive_pipe.py similarity index 79% rename from PythonModels/components/resistive_pipe.py rename to app/simulation/components/experimental/flow/resistive_pipe.py index 2a5723d..9d62d0d 100644 --- a/PythonModels/components/resistive_pipe.py +++ b/app/simulation/components/experimental/flow/resistive_pipe.py @@ -3,17 +3,19 @@ from __future__ import annotations from collections.abc import Mapping from math import pi -from PythonModels.core.base import AlgebraicComponent -from PythonModels.core.equations import EquationResidual -from PythonModels.core.metadata import ParameterDefinition -from PythonModels.core.medium import IdealGasMedium -from PythonModels.core.ports import PortDefinition +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"), @@ -63,6 +65,17 @@ class ResistivePipe(AlgebraicComponent): ), ) 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, @@ -101,6 +114,24 @@ class ResistivePipe(AlgebraicComponent): 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) 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/PythonModels/components/tee.py b/app/simulation/components/experimental/junctions/tee.py similarity index 89% rename from PythonModels/components/tee.py rename to app/simulation/components/experimental/junctions/tee.py index 0c8c9a9..6912530 100644 --- a/PythonModels/components/tee.py +++ b/app/simulation/components/experimental/junctions/tee.py @@ -2,15 +2,18 @@ from __future__ import annotations from collections.abc import Mapping -from PythonModels.core.base import AlgebraicComponent -from PythonModels.core.equations import EquationResidual -from PythonModels.core.ports import PortDefinition +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"), @@ -18,6 +21,18 @@ class Tee(AlgebraicComponent): ) 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) @@ -26,6 +41,16 @@ class Tee(AlgebraicComponent): 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( 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/PythonModels/components/cylinder.py b/app/simulation/components/experimental/storage/cylinder.py similarity index 78% rename from PythonModels/components/cylinder.py rename to app/simulation/components/experimental/storage/cylinder.py index 215c06f..ce72f85 100644 --- a/PythonModels/components/cylinder.py +++ b/app/simulation/components/experimental/storage/cylinder.py @@ -2,21 +2,23 @@ from __future__ import annotations from collections.abc import Mapping -from PythonModels.core.base import ThermodynamicVolumeComponent -from PythonModels.core.equations import EquationResidual -from PythonModels.core.metadata import ( +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 PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties -from PythonModels.core.ports import PortDefinition -from PythonModels.core.state import VolumeState +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( @@ -48,6 +50,14 @@ class Cylinder(ThermodynamicVolumeComponent): ), ) 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, @@ -66,6 +76,22 @@ class Cylinder(ThermodynamicVolumeComponent): 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() diff --git a/PythonModels/components/tank.py b/app/simulation/components/experimental/storage/tank.py similarity index 78% rename from PythonModels/components/tank.py rename to app/simulation/components/experimental/storage/tank.py index 02b3a9e..6f3f336 100644 --- a/PythonModels/components/tank.py +++ b/app/simulation/components/experimental/storage/tank.py @@ -2,21 +2,23 @@ from __future__ import annotations from collections.abc import Mapping -from PythonModels.core.base import ThermodynamicVolumeComponent -from PythonModels.core.equations import EquationResidual -from PythonModels.core.metadata import ( +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 PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties -from PythonModels.core.ports import PortDefinition -from PythonModels.core.state import VolumeState +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( @@ -48,6 +50,14 @@ class Tank(ThermodynamicVolumeComponent): ), ) 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, @@ -66,6 +76,22 @@ class Tank(ThermodynamicVolumeComponent): 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() diff --git a/PythonModels/core/__init__.py b/app/simulation/core/__init__.py similarity index 100% rename from PythonModels/core/__init__.py rename to app/simulation/core/__init__.py diff --git a/PythonModels/core/base.py b/app/simulation/core/base.py similarity index 91% rename from PythonModels/core/base.py rename to app/simulation/core/base.py index 4209ed2..93347aa 100644 --- a/PythonModels/core/base.py +++ b/app/simulation/core/base.py @@ -2,23 +2,29 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Mapping -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar -from PythonModels.core.equations import EquationResidual -from PythonModels.core.metadata import ( +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 PythonModels.core.ports import PortDefinition, PortState +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 @@ -174,6 +180,20 @@ class Component(ABC): 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.""" 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/PythonModels/core/equations.py b/app/simulation/core/equations.py similarity index 94% rename from PythonModels/core/equations.py rename to app/simulation/core/equations.py index 930132e..7c28b48 100644 --- a/PythonModels/core/equations.py +++ b/app/simulation/core/equations.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal -from PythonModels.core.ports import VariableRole +from app.simulation.core.ports import VariableRole EquationOwner = Literal["connection", "component"] diff --git a/PythonModels/core/medium.py b/app/simulation/core/medium.py similarity index 100% rename from PythonModels/core/medium.py rename to app/simulation/core/medium.py diff --git a/PythonModels/core/metadata.py b/app/simulation/core/metadata.py similarity index 91% rename from PythonModels/core/metadata.py rename to app/simulation/core/metadata.py index 7c5add2..b0c597d 100644 --- a/PythonModels/core/metadata.py +++ b/app/simulation/core/metadata.py @@ -8,6 +8,22 @@ from typing import Literal ResultVariableScope = Literal["component", "port"] +SI_UNIT_BY_QUANTITY: dict[str, str] = { + "dimensionless": "", + "density": "kg/m³", + "flow_coefficient": "kg/(s*Pa^0.5)", + "internal_energy": "J", + "length": "m", + "mass": "kg", + "mass_flow": "kg/s", + "pressure": "Pa", + "specific_enthalpy": "J/kg", + "specific_internal_energy": "J/kg", + "temperature": "K", + "volume": "m3", +} + + @dataclass(frozen=True) class ParameterDefinition: """User-configurable model input expressed in the backend SI contract.""" diff --git a/PythonModels/core/ports.py b/app/simulation/core/ports.py similarity index 100% rename from PythonModels/core/ports.py rename to app/simulation/core/ports.py diff --git a/PythonModels/core/state.py b/app/simulation/core/state.py similarity index 100% rename from PythonModels/core/state.py rename to app/simulation/core/state.py 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/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/PythonModels/systems/testmodel_closure.py b/app/simulation/examples/testmodel/closure.py similarity index 98% rename from PythonModels/systems/testmodel_closure.py rename to app/simulation/examples/testmodel/closure.py index e0846e4..142f40e 100644 --- a/PythonModels/systems/testmodel_closure.py +++ b/app/simulation/examples/testmodel/closure.py @@ -3,13 +3,13 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Callable -from PythonModels.components.cylinder import Cylinder -from PythonModels.components.orifice import Orifice -from PythonModels.components.pipe import Pipe -from PythonModels.components.tank import Tank -from PythonModels.components.tee import Tee -from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties -from PythonModels.core.state import VolumeState +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) diff --git a/PythonModels/components/pipe.py b/app/simulation/examples/testmodel/dynamic_pipe.py similarity index 89% rename from PythonModels/components/pipe.py rename to app/simulation/examples/testmodel/dynamic_pipe.py index 43d0173..7f388e6 100644 --- a/PythonModels/components/pipe.py +++ b/app/simulation/examples/testmodel/dynamic_pipe.py @@ -2,21 +2,22 @@ from __future__ import annotations from collections.abc import Mapping -from PythonModels.core.base import ThermodynamicVolumeComponent -from PythonModels.core.equations import EquationResidual -from PythonModels.core.metadata import ( +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 PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties -from PythonModels.core.ports import PortDefinition -from PythonModels.core.state import VolumeState +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): - """Python port of ModelicaModels.Mypipe.""" + """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"), @@ -99,6 +100,24 @@ class Pipe(ThermodynamicVolumeComponent): 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() diff --git a/PythonModels/scripts/run_testmodel.py b/app/simulation/examples/testmodel/run.py similarity index 91% rename from PythonModels/scripts/run_testmodel.py rename to app/simulation/examples/testmodel/run.py index 4834677..7a7a0f0 100644 --- a/PythonModels/scripts/run_testmodel.py +++ b/app/simulation/examples/testmodel/run.py @@ -4,7 +4,18 @@ from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path -from PythonModels.reporting import ( +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, @@ -13,13 +24,7 @@ from PythonModels.reporting import ( load_modelica_series, write_testmodel_run_report, ) -from PythonModels.core.solver import SolveIVPConfig -from PythonModels.systems.testmodel import ( - InitializationDiagnostics, - TestModelConfig, - TestModelSystem, -) -from PythonModels.systems.testmodel_closure import TestModelSolveDiagnostics +from app.simulation.solvers.solver import SolveIVPConfig @dataclass(frozen=True) @@ -90,9 +95,9 @@ def _sample_times(t_start: float, t_stop: float, step: float) -> list[float]: return [t_start + index * step for index in range(point_count + 1)] -def _default_run_output_dir(pythonmodels_root: Path) -> Path: +def _default_run_output_dir() -> Path: timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f") - return pythonmodels_root / "runs" / timestamp + return SIMULATION_RUNS_DIR / timestamp def prepare_testmodel_run( @@ -102,22 +107,20 @@ def prepare_testmodel_run( modelica_result_path: Path | None = None, ) -> PreparedTestModelRun: run_config = run_config or TestModelRunConfig() - repo_root = Path(__file__).resolve().parents[2] - pythonmodels_root = Path(__file__).resolve().parents[1] resolved_output_dir = ( output_dir or run_config.paths.output_dir - or _default_run_output_dir(pythonmodels_root) + or _default_run_output_dir() ) resolved_modelica_result_path = ( modelica_result_path or run_config.paths.modelica_result_path - or repo_root / "ModelicaModels" / "Simulation" / "Testmodel_res.csv" + or MODELICA_TESTMODEL_RESULT_PATH ) t_eval = tuple(run_config.sample_times()) return PreparedTestModelRun( run_config=run_config, - repo_root=repo_root, + repo_root=PROJECT_ROOT, output_dir=resolved_output_dir, modelica_result_path=resolved_modelica_result_path, t_eval=t_eval, diff --git a/PythonModels/systems/testmodel.py b/app/simulation/examples/testmodel/system.py similarity index 94% rename from PythonModels/systems/testmodel.py rename to app/simulation/examples/testmodel/system.py index 2652292..8553450 100644 --- a/PythonModels/systems/testmodel.py +++ b/app/simulation/examples/testmodel/system.py @@ -3,21 +3,21 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any -from PythonModels.components.cylinder import Cylinder -from PythonModels.components.orifice import Orifice -from PythonModels.components.pipe import Pipe -from PythonModels.components.tank import Tank -from PythonModels.components.tee import Tee -from PythonModels.core.medium import IdealGasMedium -from PythonModels.core.network import SimulationNetwork -from PythonModels.core.solver import SolveIVPConfig, integrate_ode -from PythonModels.systems.testmodel_closure import ( +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) 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..2740c3c --- /dev/null +++ b/app/simulation/registry.py @@ -0,0 +1,717 @@ +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", +) + +_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/PythonModels/reporting/__init__.py b/app/simulation/reporting/__init__.py similarity index 90% rename from PythonModels/reporting/__init__.py rename to app/simulation/reporting/__init__.py index 9ca6880..3196560 100644 --- a/PythonModels/reporting/__init__.py +++ b/app/simulation/reporting/__init__.py @@ -1,4 +1,4 @@ -from PythonModels.reporting.testmodel_outputs import ( +from app.simulation.reporting.testmodel_outputs import ( COMPARISON_KEYS, MODELICA_COMPARISON_COLUMNS, PRIMARY_KEYS, diff --git a/PythonModels/reporting/testmodel_outputs.py b/app/simulation/reporting/testmodel_outputs.py similarity index 100% rename from PythonModels/reporting/testmodel_outputs.py rename to app/simulation/reporting/testmodel_outputs.py 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/PythonModels/core/algebraic.py b/app/simulation/solvers/algebraic.py similarity index 98% rename from PythonModels/core/algebraic.py rename to app/simulation/solvers/algebraic.py index 02e1656..bd3216d 100644 --- a/PythonModels/core/algebraic.py +++ b/app/simulation/solvers/algebraic.py @@ -3,8 +3,8 @@ from __future__ import annotations from dataclasses import dataclass from math import sqrt -from PythonModels.core.network import SimulationNetwork -from PythonModels.core.ports import PortState, VariableRole +from app.simulation.core.ports import PortState, VariableRole +from app.simulation.systems.network import SimulationNetwork class AlgebraicSolveError(RuntimeError): diff --git a/PythonModels/core/solver.py b/app/simulation/solvers/solver.py similarity index 100% rename from PythonModels/core/solver.py rename to app/simulation/solvers/solver.py diff --git a/PythonModels/core/stream.py b/app/simulation/solvers/stream.py similarity index 97% rename from PythonModels/core/stream.py rename to app/simulation/solvers/stream.py index a0eca86..573d4d1 100644 --- a/PythonModels/core/stream.py +++ b/app/simulation/solvers/stream.py @@ -2,8 +2,8 @@ from __future__ import annotations from dataclasses import dataclass -from PythonModels.core.base import DynamicComponent -from PythonModels.core.network import Endpoint, SimulationNetwork +from app.simulation.core.base import DynamicComponent +from app.simulation.systems.network import Endpoint, SimulationNetwork class StreamSolveError(RuntimeError): diff --git a/PythonModels/systems/__init__.py b/app/simulation/systems/__init__.py similarity index 100% rename from PythonModels/systems/__init__.py rename to app/simulation/systems/__init__.py diff --git a/PythonModels/systems/generic.py b/app/simulation/systems/generic.py similarity index 97% rename from PythonModels/systems/generic.py rename to app/simulation/systems/generic.py index 160baf4..d3ae1ea 100644 --- a/PythonModels/systems/generic.py +++ b/app/simulation/systems/generic.py @@ -5,12 +5,12 @@ from dataclasses import dataclass from math import floor, isfinite from typing import Literal -from PythonModels.core.algebraic import PressureFlowSolver -from PythonModels.core.base import DynamicComponent -from PythonModels.core.metadata import ResultVariableMetadata -from PythonModels.core.network import Endpoint, SimulationNetwork -from PythonModels.core.solver import ODESolution, SolveIVPConfig, integrate_ode -from PythonModels.core.stream import StreamResolver +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] diff --git a/PythonModels/core/network.py b/app/simulation/systems/network.py similarity index 98% rename from PythonModels/core/network.py rename to app/simulation/systems/network.py index 8e66525..e0eb908 100644 --- a/PythonModels/core/network.py +++ b/app/simulation/systems/network.py @@ -2,10 +2,10 @@ from __future__ import annotations from dataclasses import dataclass -from PythonModels.core.base import Component, DynamicComponent -from PythonModels.core.equations import EquationResidual -from PythonModels.core.metadata import ResultVariableMetadata -from PythonModels.core.ports import PortState +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) diff --git a/app/system_xml.py b/app/system_xml.py index 7262deb..7c94e83 100644 --- a/app/system_xml.py +++ b/app/system_xml.py @@ -9,8 +9,8 @@ from typing import Literal from lxml import etree -from PythonModels.core.ports import PortDefinition -from PythonModels.registry import ( +from app.simulation.core.ports import PortDefinition +from app.simulation.registry import ( COMPONENT_MODEL_REGISTRY, ParameterSpec, ) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0fe72b1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,46 @@ +# 开发文档索引 + +本目录是 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` 的机器可读结构。 + +建议人工和 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/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..d81dd72 --- /dev/null +++ b/docs/component-model-authoring-spec-v1.md @@ -0,0 +1,680 @@ +# 组件模型建模规范 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 单位 | +| --- | --- | +| `dimensionless` | 空字符串 | +| `density` | `kg/m³` | +| `flow_coefficient` | `kg/(s*Pa^0.5)` | +| `internal_energy` | `J` | +| `length` | `m` | +| `mass` | `kg` | +| `mass_flow` | `kg/s` | +| `pressure` | `Pa` | +| `specific_enthalpy` | `J/kg` | +| `specific_internal_energy` | `J/kg` | +| `temperature` | `K` | +| `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 index d3b5f85..c0815b2 100644 --- a/docs/system-xml-v1.md +++ b/docs/system-xml-v1.md @@ -2,7 +2,7 @@ > 此版本仅用于识别旧文件。新项目使用 [System XML v2](system-xml-v2.md),物理连接在 v2 中改为无序端点。 -System XML 是 ReactFlow 前端与 PythonModels 仿真层之间的稳定交换格式。 +System XML 是 ReactFlow 前端与 `app.simulation` 仿真层之间的稳定交换格式。 ## 基本约定 @@ -10,7 +10,7 @@ System XML 是 ReactFlow 前端与 PythonModels 仿真层之间的稳定交换 - `unitSystem` 固定为 `SI`。组件参数必须保存为 SI 基准值,界面显示单位不进入数值换算语义。 - 子元素顺序固定为 `Simulation`、`Components`、`Connections`。 - `Component.id` 是稳定的仿真实例 ID;`name` 是用户可编辑的显示名称。 -- `Component.type` 是 PythonModels 模型类型;`componentType` 是前端组件类型。 +- `Component.type` 是仿真后端模型类型;`componentType` 是前端组件类型。 - `Connection.source/target` 只标识图形拓扑端点,不代表仿真中的实际流动方向。正流和倒流由求解器决定。 - v1 文档不携带结果数据;仿真结果通过运行接口返回。 diff --git a/docs/system-xml-v2.md b/docs/system-xml-v2.md index fab5bbb..e518453 100644 --- a/docs/system-xml-v2.md +++ b/docs/system-xml-v2.md @@ -1,6 +1,6 @@ # System XML v2 协议 -System XML v2 是 ReactFlow 建模前端与 PythonModels 仿真层之间的交换格式。v2 将物理端口与信号端口分开,并移除了物理连接中的方向语义。 +System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的交换格式。v2 将物理端口与信号端口分开,并移除了物理连接中的方向语义。 ## 基本约定 @@ -56,7 +56,7 @@ System XML v2 是 ReactFlow 建模前端与 PythonModels 仿真层之间的交 物理端口的流向由求解结果决定。一个名义出口的 `m_flow > 0` 表示该端口发生实际流入,可在结果层标记为倒流。 -三通的三个端口当前保留 PythonModels 已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。 +三通的三个端口当前保留仿真模型已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。 ## Connection @@ -80,11 +80,11 @@ System XML v2 是 ReactFlow 建模前端与 PythonModels 仿真层之间的交 XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。 -## PythonModels 编译接口 +## 仿真模型编译接口 `POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作: -1. 按 `node.data.modelType` 创建 PythonModels 组件实例,并写入 SI 参数。 +1. 按 `node.data.modelType` 创建 `app.simulation` 组件实例,并写入 SI 参数。 2. 将前端端口声明与组件注册端口逐项比对。 3. 按画布实际 `edges` 创建无方向物理连接,而不是按组件类型或拖入顺序推断拓扑。 4. 检查端口存在性、物理域兼容性、重复连接和未连接端口。 @@ -140,7 +140,7 @@ XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求 ## 第二阶段:XML 解析与校验 -第二阶段已经实现从 System XML v2 回到 PythonModels 网络的完整入口。解析过程固定分为三层: +第二阶段已经实现从 System XML v2 回到仿真网络的完整入口。解析过程固定分为三层: | 层级 | `layer` | 负责内容 | |---|---|---| @@ -169,7 +169,7 @@ XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求 - `POST /api/system-xml/validate`:无论成功与否都返回校验报告,便于编辑器实时显示问题。 - `POST /api/system-xml/parse`:成功时返回规范化工程 JSON;失败时返回 HTTP `422` 和结构化诊断。 -- `POST /api/system-xml/compile-model`:成功时返回 PythonModels 网络、仿真设置和校验报告;失败时返回 HTTP `422`。 +- `POST /api/system-xml/compile-model`:成功时返回仿真网络、仿真设置和校验报告;失败时返回 HTTP `422`。 - `POST /api/system-xml/simulate`:完成校验、编译、仿真准备、代数闭合、stream 传播和时间积分;成功时返回组件与端口时间序列,失败时返回 HTTP `422` 和仿真层诊断。 示例: @@ -182,13 +182,13 @@ Invoke-RestMethod ` -InFile .\test\system.xml ``` -组件参数和端口定义集中在 `PythonModels/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。 +组件参数和端口定义集中在 `app/simulation/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。 ## 第三阶段:XML 驱动仿真 MVP 第三阶段当前已经打通: -1. XML 中的组件、参数和无方向物理连接编译成 PythonModels 网络。 +1. XML 中的组件、参数和无方向物理连接编译成 `app.simulation` 网络。 2. 仿真准备层检查未连接端口、方程数量、无储能代数孤岛和无阻力储能直连。 3. SciPy 非线性最小二乘求解每个时刻的端口压力与质量流量。 4. 根据求解后的实际流向迭代传播 `h_outflow`,并在三通处执行质量流量加权混合。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5328c80..f9b2ff6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,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", @@ -85,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", @@ -1368,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", diff --git a/frontend/package.json b/frontend/package.json index 08bb269..3d2cd40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "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", @@ -16,6 +18,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 a1496af..25a3e07 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,6 @@ import type { PointerEvent as ReactPointerEvent, } from "react"; import { - BellRing, ChevronDown, ChevronUp, ClipboardPaste, @@ -73,13 +72,14 @@ import { type ResultVariableMetadata, type SimulationResultsSnapshot, } from "./SimulationResultsView"; +import { AutoFitView } from "./AutoFitView"; import { WorkspaceViewTabs, type WorkspaceView, } from "./WorkspaceViewTabs"; type ParameterValue = number | string; -type UnitQuantity = "pressure" | "volume" | "temperature" | "length"; +type UnitQuantity = string; type PortKind = "physical" | "signal"; type PortNominalRole = "inlet" | "outlet" | "bidirectional" | "input" | "output"; type PortSide = "left" | "right"; @@ -115,14 +115,71 @@ type ComponentDefinition = { type: string; label: string; modelType: 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; + symbol?: string; ports: PortDefinition[]; parameters: Record; parameterUnits: Record; @@ -409,6 +466,7 @@ type SimulationConsoleEntry = { timestamp: string; level: SimulationConsoleLevel; message: string; + format: "message" | "xml"; }; type SimulationProgressState = { @@ -481,11 +539,15 @@ function physicalPort( }; } -const componentDefinitions: ComponentDefinition[] = [ +const fallbackComponentDefinitions: ComponentDefinition[] = [ { type: "cylinder", label: "气瓶", modelType: "cylinder", + modelVersion: "1.0.0", + symbol: "cylinder", + order: 10, + category: { id: "storage", label: "储能元件", order: 10 }, ports: [physicalPort("port_b", "outlet", "right")], parameters: { volume: { @@ -496,7 +558,14 @@ const componentDefinitions: ComponentDefinition[] = [ min: 0, minExclusive: true, }, - p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 35000000, min: 0 }, + p0: { + label: "初始压力", + unit: "Pa", + quantity: "pressure", + default: 35000000, + min: 0, + minExclusive: true, + }, T0: { label: "初始温度", unit: "K", @@ -511,6 +580,10 @@ const componentDefinitions: ComponentDefinition[] = [ type: "tank", label: "贮箱", modelType: "tank", + modelVersion: "1.0.0", + symbol: "tank", + order: 20, + category: { id: "storage", label: "储能元件", order: 10 }, ports: [physicalPort("port_a", "inlet", "left")], parameters: { volume: { @@ -521,7 +594,14 @@ const componentDefinitions: ComponentDefinition[] = [ min: 0, minExclusive: true, }, - p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 100000, min: 0 }, + p0: { + label: "初始压力", + unit: "Pa", + quantity: "pressure", + default: 100000, + min: 0, + minExclusive: true, + }, T0: { label: "初始温度", unit: "K", @@ -536,6 +616,10 @@ const componentDefinitions: ComponentDefinition[] = [ type: "pipe", label: "管段", modelType: "pipe", + 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"), @@ -558,7 +642,14 @@ const componentDefinitions: ComponentDefinition[] = [ minExclusive: true, }, lambda_darcy: { label: "摩阻系数", default: 0.02, min: 0 }, - p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 100000, min: 0 }, + p0: { + label: "初始压力", + unit: "Pa", + quantity: "pressure", + default: 100000, + min: 0, + minExclusive: true, + }, T0: { label: "初始温度", unit: "K", @@ -573,12 +664,22 @@ const componentDefinitions: ComponentDefinition[] = [ type: "orifice", label: "孔板/阀门", modelType: "orifice", + 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, min: 0 }, + K: { + label: "流量系数", + unit: "kg/(s*Pa^0.5)", + quantity: "flow_coefficient", + default: 0.00001, + min: 0, + }, opening: { label: "开度", default: 1, min: 0, max: 1 }, }, }, @@ -586,6 +687,10 @@ const componentDefinitions: ComponentDefinition[] = [ type: "tee", label: "三通", modelType: "tee", + 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"), @@ -595,6 +700,164 @@ const componentDefinitions: ComponentDefinition[] = [ }, ]; +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) { @@ -619,6 +882,7 @@ function buildNodeData(definition: ComponentDefinition): SimulationNodeData { label: definition.label, componentType: definition.type, modelType: definition.modelType, + symbol: definition.symbol, ports: definition.ports.map((port) => ({ ...port })), parameters: defaultParameters(definition), parameterUnits: defaultParameterUnits(definition), @@ -632,6 +896,9 @@ function SimulationComponentNode({ id, data, selected }: NodeProps { @@ -640,7 +907,7 @@ function SimulationComponentNode({ id, data, selected }: NodeProps
))} - ); @@ -791,7 +1060,10 @@ function symbolForComponent(type: string) { if (type === "orifice") { return
; } - return
; + if (type === "tee") { + return
; + } + return null; } type ToolbarButtonProps = { @@ -833,7 +1105,6 @@ type SimulationConsoleProps = { entries: SimulationConsoleEntry[]; mode: SimulationConsoleMode; progress: SimulationProgressState; - unreadCount: number; onClear: () => void; onModeChange: (mode: SimulationConsoleMode) => void; onStop: () => void; @@ -1099,7 +1370,6 @@ function SimulationConsole({ entries, mode, progress, - unreadCount, onClear, onModeChange, onStop, @@ -1311,11 +1581,10 @@ function SimulationConsole({ : undefined; if (mode === "minimized") { - const latestMessage = entries.at(-1)?.message ?? "等待仿真任务"; return (
@@ -1473,21 +1736,34 @@ function SimulationConsole({
{entries.length === 0 ? (
暂无仿真消息
) : ( entries.map((entry) => ( -
+
{consoleLevelLabels[entry.level]} - {entry.message} + {entry.format === "xml" ? ( +
+                  {entry.message}
+                
+ ) : ( + {entry.message} + )}
)) )} @@ -1571,6 +1847,16 @@ function DeferredNumberInput({ } function FlowWorkbench() { + const [componentLibraries, setComponentLibraries] = + useState(fallbackComponentLibraries); + const [componentCatalogStatus, setComponentCatalogStatus] = + useState("loading"); + const [componentCatalogMessage, setComponentCatalogMessage] = + useState("正在加载后端组件目录"); + const componentDefinitions = useMemo( + () => componentLibraries.flatMap((library) => library.components), + [componentLibraries], + ); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); @@ -1579,20 +1865,16 @@ function FlowWorkbench() { const [simulationConfig, setSimulationConfig] = useState( defaultSimulationConfig, ); - const [xmlText, setXmlText] = useState(""); - const [statusText, setStatusText] = useState("后端待调用"); - const [simulationResult, setSimulationResult] = useState(null); - const [simulationResultSignature, setSimulationResultSignature] = useState(""); const [resultSnapshot, setResultSnapshot] = useState(loadStoredResultSnapshot); const [hasUnreadResults, setHasUnreadResults] = useState(false); const [activeView, setActiveView] = useState( workspaceViewFromLocation, ); + const [modelingFitRequest, setModelingFitRequest] = useState(1); const [busy, setBusy] = useState(false); const [consoleEntries, setConsoleEntries] = useState([]); const [consoleMode, setConsoleMode] = useState("minimized"); - const [consoleUnreadCount, setConsoleUnreadCount] = useState(0); const [simulationStopPending, setSimulationStopPending] = useState(false); const [simulationProgress, setSimulationProgress] = useState({ state: "idle", @@ -1631,15 +1913,52 @@ function FlowWorkbench() { const futureRef = useRef([]); const clipboardRef = useRef(null); const pasteCountRef = useRef(0); - const consoleModeRef = useRef("minimized"); const consoleEntryIdRef = useRef(0); const activeSimulationIdRef = useRef(null); + const previousActiveViewRef = useRef(activeView); nodesRef.current = nodes; edgesRef.current = edges; projectNameRef.current = projectName; simulationConfigRef.current = simulationConfig; - consoleModeRef.current = consoleMode; + + useEffect(() => { + const controller = new AbortController(); + + const loadComponentCatalog = async () => { + try { + const response = await fetch("/api/components/catalog", { + cache: "no-store", + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`.trim()); + } + const libraries = normalizeComponentCatalog(await response.json()); + if (controller.signal.aborted) { + return; + } + setComponentLibraries(libraries); + setComponentCatalogStatus("ready"); + setComponentCatalogMessage( + `已从后端加载 ${libraries.reduce( + (count, library) => count + library.components.length, + 0, + )} 个组件`, + ); + } catch (error) { + if (controller.signal.aborted) { + return; + } + setComponentLibraries(fallbackComponentLibraries); + setComponentCatalogStatus("fallback"); + setComponentCatalogMessage(`后端组件目录不可用:${formatError(error)}`); + } + }; + + void loadComponentCatalog(); + return () => controller.abort(); + }, []); useEffect(() => { try { @@ -1817,32 +2136,34 @@ function FlowWorkbench() { }; const changeConsoleMode = useCallback((mode: SimulationConsoleMode) => { - consoleModeRef.current = mode; setConsoleMode(mode); - if (mode !== "minimized") { - setConsoleUnreadCount(0); - } }, []); const appendConsoleEntry = useCallback( - (level: SimulationConsoleLevel, message: string) => { + ( + level: SimulationConsoleLevel, + message: string, + format: SimulationConsoleEntry["format"] = "message", + ) => { const entry: SimulationConsoleEntry = { id: ++consoleEntryIdRef.current, timestamp: new Date().toLocaleTimeString("zh-CN", { hour12: false }), level, message, + format, }; setConsoleEntries((currentEntries) => [ ...currentEntries.slice(-399), entry, ]); - if (consoleModeRef.current === "minimized") { - setConsoleUnreadCount((count) => count + 1); - } }, [], ); + const requestModelingFitView = useCallback(() => { + setModelingFitRequest((request) => request + 1); + }, []); + const changeWorkspaceView = useCallback((view: WorkspaceView) => { setActiveView(view); if (view === "results") { @@ -1858,12 +2179,10 @@ function FlowWorkbench() { (snapshot: SimulationResultsSnapshot) => { setResultSnapshot(snapshot); storeResultSnapshot(snapshot); - setSimulationResult(null); - setSimulationResultSignature(""); - setStatusText(`已加载结果文件:${snapshot.project.name}`); + appendConsoleEntry("success", `已加载结果文件:${snapshot.project.name}`); changeWorkspaceView("results"); }, - [changeWorkspaceView], + [appendConsoleEntry, changeWorkspaceView], ); const nodeTypes = useMemo( @@ -1892,8 +2211,6 @@ function FlowWorkbench() { [buildCurrentProject], ); const validationCurrent = validatedSignature === validationSignature; - const simulationResultCurrent = - Boolean(simulationResult) && simulationResultSignature === validationSignature; const duplicateNames = useMemo(() => findDuplicateNames(nodes), [nodes]); const validationSeverityByNode = useMemo( () => { @@ -1989,9 +2306,6 @@ function FlowWorkbench() { setSimulationConfig({ ...snapshot.simulationConfig }); setSelectedNodeId(null); setSelectedEdgeId(null); - setSimulationResult(null); - setSimulationResultSignature(""); - setXmlText(""); syncNextNodeNumbers(restoredNodes); }, [setEdges, setNodes], @@ -2005,8 +2319,8 @@ function FlowWorkbench() { futureRef.current.push(captureSnapshot()); restoreSnapshot(previous); updateHistoryAvailability(); - setStatusText("已撤销上一步操作"); - }, [captureSnapshot, restoreSnapshot, updateHistoryAvailability]); + appendConsoleEntry("info", "已撤销上一步操作"); + }, [appendConsoleEntry, captureSnapshot, restoreSnapshot, updateHistoryAvailability]); const redo = useCallback(() => { const next = futureRef.current.pop(); @@ -2016,8 +2330,8 @@ function FlowWorkbench() { historyRef.current.push(captureSnapshot()); restoreSnapshot(next); updateHistoryAvailability(); - setStatusText("已重做上一步操作"); - }, [captureSnapshot, restoreSnapshot, updateHistoryAvailability]); + appendConsoleEntry("info", "已重做上一步操作"); + }, [appendConsoleEntry, captureSnapshot, restoreSnapshot, updateHistoryAvailability]); const rotateSelection = useCallback(() => { const selectedIds = new Set( @@ -2026,7 +2340,7 @@ function FlowWorkbench() { .map((node) => node.id), ); if (selectedIds.size === 0) { - setStatusText("请先选择需要旋转的组件"); + appendConsoleEntry("warning", "请先选择需要旋转的组件"); return; } recordHistory(); @@ -2035,15 +2349,15 @@ function FlowWorkbench() { selectedIds.has(node.id) ? rotateNodeClockwise(node) : node, ), ); - setStatusText(`已顺时针旋转 ${selectedIds.size} 个组件`); - }, [recordHistory, selectedNodeId, setNodes]); + appendConsoleEntry("info", `已顺时针旋转 ${selectedIds.size} 个组件`); + }, [appendConsoleEntry, recordHistory, selectedNodeId, setNodes]); const mirrorSelection = useCallback(() => { const selectedNodes = nodesRef.current.filter( (node) => node.selected || node.id === selectedNodeId, ); if (selectedNodes.length === 0) { - setStatusText("请先选择需要镜像的组件"); + appendConsoleEntry("warning", "请先选择需要镜像的组件"); return; } const selectedIds = new Set(selectedNodes.map((node) => node.id)); @@ -2062,8 +2376,11 @@ function FlowWorkbench() { : node, ), ); - setStatusText(`${nextMirrored ? "已镜像" : "已取消镜像"} ${selectedIds.size} 个组件`); - }, [recordHistory, selectedNodeId, setNodes]); + appendConsoleEntry( + "info", + `${nextMirrored ? "已镜像" : "已取消镜像"} ${selectedIds.size} 个组件`, + ); + }, [appendConsoleEntry, recordHistory, selectedNodeId, setNodes]); const handleNodesChange = useCallback( (changes: NodeChange[]) => { @@ -2098,8 +2415,12 @@ function FlowWorkbench() { currentEdges, ), ); + appendConsoleEntry( + "info", + `已连接 ${connection.source}.${connection.sourceHandle ?? "port"} 与 ${connection.target}.${connection.targetHandle ?? "port"}`, + ); }, - [recordHistory, setEdges], + [appendConsoleEntry, recordHistory, setEdges], ); const isValidConnection = useCallback( @@ -2153,8 +2474,9 @@ function FlowWorkbench() { recordHistory(); setNodes((currentNodes) => [...currentNodes, node]); setSelectedNodeId(id); + appendConsoleEntry("info", `已添加组件:${id}`); }, - [reactFlow, recordHistory, setNodes], + [appendConsoleEntry, componentDefinitions, reactFlow, recordHistory, setNodes], ); const updateSelectedNode = (updater: (node: SimulationNode) => SimulationNode) => { @@ -2210,7 +2532,7 @@ function FlowWorkbench() { (node) => node.selected || node.id === selectedNodeId, ); if (selectedNodes.length === 0) { - setStatusText("请先选择需要复制的组件"); + appendConsoleEntry("warning", "请先选择需要复制的组件"); return; } const selectedIds = new Set(selectedNodes.map((node) => node.id)); @@ -2223,13 +2545,13 @@ function FlowWorkbench() { ), }; pasteCountRef.current = 0; - setStatusText(`已复制 ${selectedNodes.length} 个组件`); - }, [selectedNodeId]); + appendConsoleEntry("info", `已复制 ${selectedNodes.length} 个组件`); + }, [appendConsoleEntry, selectedNodeId]); const pasteSelection = useCallback(() => { const clipboard = clipboardRef.current; if (!clipboard || clipboard.nodes.length === 0) { - setStatusText("剪贴板中没有可粘贴的组件"); + appendConsoleEntry("warning", "剪贴板中没有可粘贴的组件"); return; } @@ -2275,8 +2597,8 @@ function FlowWorkbench() { ]); setSelectedNodeId(pastedNodes.length === 1 ? pastedNodes[0].id : null); setSelectedEdgeId(null); - setStatusText(`已粘贴 ${pastedNodes.length} 个组件`); - }, [recordHistory, setEdges, setNodes]); + appendConsoleEntry("info", `已粘贴 ${pastedNodes.length} 个组件`); + }, [appendConsoleEntry, recordHistory, setEdges, setNodes]); const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => { setSimulationConfig((current) => ({ @@ -2288,21 +2610,25 @@ function FlowWorkbench() { const generateXml = () => { try { const xml = buildSystemXml(buildCurrentProject()); - setXmlText(xml); - setStatusText(`XML 已生成:${nodes.length} 个组件,${edges.length} 条连接`); + changeConsoleMode("normal"); + appendConsoleEntry( + "success", + `XML 已生成:${nodes.length} 个组件,${edges.length} 条连接`, + ); + appendConsoleEntry("info", xml, "xml"); } catch (error) { - setStatusText(`XML 生成失败:${formatError(error)}`); + changeConsoleMode("normal"); + appendConsoleEntry("error", `XML 生成失败:${formatError(error)}`); } }; const downloadXml = () => { try { const xml = buildSystemXml(buildCurrentProject()); - setXmlText(xml); downloadText(`${safeFilename(projectName)}.xml`, xml); - setStatusText("XML 已下载"); + appendConsoleEntry("success", `XML 已下载:${safeFilename(projectName)}.xml`); } catch (error) { - setStatusText(`XML 下载失败:${formatError(error)}`); + appendConsoleEntry("error", `XML 下载失败:${formatError(error)}`); } }; @@ -2311,23 +2637,29 @@ function FlowWorkbench() { if (addToHistory) { recordHistory(); } - const loadedNodes = project.nodes.map(normalizeLoadedNode); + const loadedNodes = project.nodes.map((node) => + normalizeLoadedNode(node, componentDefinitions), + ); setNodes(loadedNodes); setEdges(project.edges.map(normalizeLoadedEdge)); setProjectName(project.name || "untitled"); setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation }); setSelectedNodeId(null); setSelectedEdgeId(null); - setSimulationResult(null); - setSimulationResultSignature(""); setValidationIssues([]); setValidatedSignature(""); - setXmlText(""); syncNextNodeNumbers(loadedNodes); - setStatusText(message); - window.requestAnimationFrame(() => reactFlow.fitView({ padding: 0.18 })); + appendConsoleEntry("success", message); + requestModelingFitView(); }, - [reactFlow, recordHistory, setEdges, setNodes], + [ + appendConsoleEntry, + componentDefinitions, + recordHistory, + requestModelingFitView, + setEdges, + setNodes, + ], ); const saveProject = () => { @@ -2336,11 +2668,12 @@ function FlowWorkbench() { localStorage.setItem(localProjectKey(project.name), JSON.stringify(project)); localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(project)); setSaveState(`已保存 ${formatTime(new Date())}`); - setStatusText( + appendConsoleEntry( + "success", `工程已保存到浏览器:${project.nodes.length} 个组件,${project.edges.length} 条连接`, ); } catch (error) { - setStatusText(`保存失败:${formatError(error)}`); + appendConsoleEntry("error", `保存失败:${formatError(error)}`); } }; @@ -2349,7 +2682,7 @@ function FlowWorkbench() { try { const project = parseProjectPayload(localStorage.getItem(localProjectKey(name))); if (!project) { - setStatusText(`没有找到本地工程:${name}`); + appendConsoleEntry("warning", `没有找到本地工程:${name}`); return; } applyProject( @@ -2357,7 +2690,7 @@ function FlowWorkbench() { `工程已加载:${project.nodes.length} 个组件,${project.edges.length} 条连接`, ); } catch (error) { - setStatusText(`加载失败:${formatError(error)}`); + appendConsoleEntry("error", `加载失败:${formatError(error)}`); } }; @@ -2368,7 +2701,7 @@ function FlowWorkbench() { JSON.stringify(project, null, 2), "application/json;charset=utf-8", ); - setStatusText("工程 JSON 已导出"); + appendConsoleEntry("success", `工程 JSON 已导出:${safeFilename(project.name)}.json`); }; const importProject = async (event: ChangeEvent) => { @@ -2384,15 +2717,17 @@ function FlowWorkbench() { } applyProject(project, `已导入工程:${file.name}`); } catch (error) { - setStatusText(`导入失败:${formatError(error)}`); + appendConsoleEntry("error", `导入失败:${formatError(error)}`); } }; const checkModel = useCallback(() => { + changeConsoleMode("normal"); const issues = validateModel( nodesRef.current, edgesRef.current, simulationConfigRef.current, + componentDefinitions, ); const signature = JSON.stringify( buildProjectPayload( @@ -2406,34 +2741,21 @@ function FlowWorkbench() { setValidatedSignature(signature); const errorCount = issues.filter((issue) => issue.severity === "error").length; const warningCount = issues.length - errorCount; - setStatusText( + const summary = errorCount === 0 ? warningCount === 0 ? "模型检查通过" : `模型检查通过,存在 ${warningCount} 条警告` - : `模型检查发现 ${errorCount} 个错误、${warningCount} 条警告`, + : `模型检查发现 ${errorCount} 个错误、${warningCount} 条警告`; + appendConsoleEntry( + errorCount > 0 ? "error" : warningCount > 0 ? "warning" : "success", + summary, + ); + issues.forEach((issue) => + appendConsoleEntry(issue.severity === "error" ? "error" : "warning", issue.message), ); return issues; - }, []); - - const focusValidationIssue = (issue: ValidationIssue) => { - if (!issue.nodeId) { - return; - } - const node = nodesRef.current.find((item) => item.id === issue.nodeId); - if (!node) { - return; - } - setNodes((currentNodes) => - currentNodes.map((item) => ({ ...item, selected: item.id === issue.nodeId })), - ); - setSelectedNodeId(issue.nodeId); - setSelectedEdgeId(null); - reactFlow.setCenter(node.position.x, node.position.y, { - duration: 250, - zoom: Math.max(reactFlow.getZoom(), 1), - }); - }; + }, [appendConsoleEntry, changeConsoleMode, componentDefinitions]); const publishSimulationResult = ( result: SimulationResult, @@ -2471,7 +2793,6 @@ function FlowWorkbench() { } as const; const statusMessage = statusMessages[resultStatus]; - setStatusText(statusMessage); if (resultStatus === "completed") { setSimulationProgress({ state: "success", @@ -2508,8 +2829,6 @@ function FlowWorkbench() { return; } - setSimulationResult(result); - setSimulationResultSignature(JSON.stringify(project)); const nextSnapshot: SimulationResultsSnapshot = { id: `simulation-${Date.now()}`, createdAt: new Date().toISOString(), @@ -2558,8 +2877,6 @@ function FlowWorkbench() { simulatedTime: simulationConfigRef.current.t_start, totalTime: simulationConfigRef.current.t_stop, }); - appendConsoleEntry("error", `模型检查未通过,共 ${modelErrors.length} 个错误`); - modelErrors.forEach((issue) => appendConsoleEntry("error", issue.message)); return; } changeConsoleMode("normal"); @@ -2575,17 +2892,12 @@ function FlowWorkbench() { "info", `开始仿真:${projectNameRef.current || "untitled"},时间范围 ${simulationConfigRef.current.t_start} - ${simulationConfigRef.current.t_stop} s`, ); - issues - .filter((issue) => issue.severity === "warning") - .forEach((issue) => appendConsoleEntry("warning", issue.message)); - await runBusyAction("正在编译模型并运行仿真...", async () => { const project = buildCurrentProject(); const xml = buildSystemXml(project); const simulationId = createSimulationId(); activeSimulationIdRef.current = simulationId; setSimulationStopPending(false); - setXmlText(xml); let lastLoggedPhase = ""; let lastLoggedBucket = -1; try { @@ -2674,7 +2986,7 @@ function FlowWorkbench() { .filter((issue) => issue !== message) .forEach((issue) => appendConsoleEntry("error", issue)); } - throw failure; + return; } finally { activeSimulationIdRef.current = null; setSimulationStopPending(false); @@ -2684,11 +2996,11 @@ function FlowWorkbench() { async function runBusyAction(message: string, action: () => Promise) { setBusy(true); - setStatusText(message); + appendConsoleEntry("info", message); try { await action(); } catch (error) { - setStatusText(`操作失败:${formatError(error)}`); + appendConsoleEntry("error", `操作失败:${formatError(error)}`); } finally { setBusy(false); } @@ -2708,7 +3020,7 @@ function FlowWorkbench() { setRecoveryProject(null); setAutosaveReady(true); setSaveState("尚未保存"); - setStatusText("已忽略上次的自动保存草稿"); + appendConsoleEntry("info", "已忽略上次的自动保存草稿"); }; const selectAllElements = useCallback(() => { @@ -2716,7 +3028,11 @@ function FlowWorkbench() { setEdges((currentEdges) => currentEdges.map((edge) => ({ ...edge, selected: true }))); setSelectedNodeId(null); setSelectedEdgeId(null); - }, [setEdges, setNodes]); + appendConsoleEntry( + "info", + `已选择 ${nodesRef.current.length} 个组件和 ${edgesRef.current.length} 条连接`, + ); + }, [appendConsoleEntry, setEdges, setNodes]); const deleteSelection = useCallback(() => { const nodeIds = new Set( @@ -2730,7 +3046,7 @@ function FlowWorkbench() { .map((edge) => edge.id), ); if (nodeIds.size === 0 && edgeIds.size === 0) { - setStatusText("请先选择需要删除的组件或连接"); + appendConsoleEntry("warning", "请先选择需要删除的组件或连接"); return; } recordHistory(); @@ -2745,8 +3061,54 @@ function FlowWorkbench() { ); setSelectedNodeId(null); setSelectedEdgeId(null); - setStatusText(`已删除 ${nodeIds.size} 个组件和 ${edgeIds.size} 条连接`); - }, [recordHistory, selectedEdgeId, selectedNodeId, setEdges, setNodes]); + appendConsoleEntry( + "info", + `已删除 ${nodeIds.size} 个组件和 ${edgeIds.size} 条连接`, + ); + }, [ + appendConsoleEntry, + recordHistory, + selectedEdgeId, + selectedNodeId, + setEdges, + setNodes, + ]); + + const toggleSnap = () => { + const nextEnabled = !snapEnabled; + setSnapEnabled(nextEnabled); + appendConsoleEntry( + "info", + `网格吸附已${nextEnabled ? "开启" : "关闭"}`, + ); + }; + + const toggleGridLines = () => { + const nextVisible = !gridVisibility.lines; + setGridVisibility((current) => ({ ...current, lines: nextVisible })); + appendConsoleEntry( + "info", + `主网格线已${nextVisible ? "显示" : "隐藏"}`, + ); + }; + + const toggleGridDots = () => { + const nextVisible = !gridVisibility.dots; + setGridVisibility((current) => ({ ...current, dots: nextVisible })); + appendConsoleEntry( + "info", + `网格点已${nextVisible ? "显示" : "隐藏"}`, + ); + }; + + const fitModelingView = () => { + if (nodesRef.current.length === 0) { + appendConsoleEntry("warning", "画布中没有可适配的组件"); + return; + } + void reactFlow.fitView({ duration: 180, minZoom: 0.1, padding: 0.18 }); + appendConsoleEntry("info", "已适配建模画布"); + }; useEffect(() => { try { @@ -2793,6 +3155,14 @@ function FlowWorkbench() { }; }, []); + useEffect(() => { + const previousView = previousActiveViewRef.current; + previousActiveViewRef.current = activeView; + if (activeView === "modeling" && previousView !== "modeling") { + requestModelingFitView(); + } + }, [activeView, requestModelingFitView]); + useEffect(() => { if (activeView === "results" && hasUnreadResults) { setHasUnreadResults(false); @@ -2856,7 +3226,6 @@ function FlowWorkbench() { onStop={() => void stopSimulation()} progress={simulationProgress} stopPending={simulationStopPending} - unreadCount={consoleUnreadCount} /> ); @@ -2877,7 +3246,6 @@ function FlowWorkbench() { onViewChange={changeWorkspaceView} /> )} - {simulationConsole} ); } @@ -2999,35 +3367,25 @@ function FlowWorkbench() { setSnapEnabled((enabled) => !enabled)} + onClick={toggleSnap} pressed={snapEnabled} /> - setGridVisibility((current) => ({ - ...current, - lines: !current.lines, - })) - } + onClick={toggleGridLines} pressed={gridVisibility.lines} /> - setGridVisibility((current) => ({ - ...current, - dots: !current.dots, - })) - } + onClick={toggleGridDots} pressed={gridVisibility.dots} /> reactFlow.fitView({ padding: 0.18 })} + onClick={fitModelingView} />