规范仿真模型库并完善前端交互

归档仿真模型并补充组件目录、建模规范与校验。

完善控制台、默认节点、视图适配及前端自动化测试。
This commit is contained in:
ljz committed 2026-07-29 15:44:16 +08:00
1 parent f7f1078911
commit bbc88a6bbb
71 files changed
+4555 -787

No files matched your search

+3 -1
View File
@@ -12,11 +12,13 @@ htmlcov/
# Local virtual environments # Local virtual environments
.venv/ .venv/
.venv-win/ .venv-win/
PythonModels/runs/
app/data/ app/data/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
frontend/test-results/
frontend/playwright-report/
frontend/blob-report/
venv/ venv/
env/ env/
-2
View File
@@ -1,2 +0,0 @@
__pycache__/
*.pyc
-2
View File
@@ -1,2 +0,0 @@
"""Python port scaffold for the Modelica-based pressurization system."""
-172
View File
@@ -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
+8 -3
View File
@@ -1,15 +1,16 @@
# SystemSimulationApp # SystemSimulationApp
ReactFlow 系统建模与 PythonModels 仿真应用。 ReactFlow 系统建模与 `app.simulation` 仿真后端。
## 后端接口 ## 后端接口
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
- `POST /api/reactflow/system-xml`:导出 System XML v2。 - `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/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
- `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。 - `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。
- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。 - `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/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。 - `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 协议](docs/system-xml-v2.md)
- [System XML v2 XSD](schemas/system-simulation-v2.xsd) - [System XML v2 XSD](schemas/system-simulation-v2.xsd)
- [System XML v1 协议(旧版)](docs/system-xml-v1.md) - [System XML v1 协议(旧版)](docs/system-xml-v1.md)
+19 -12
View File
@@ -28,8 +28,8 @@ from app.system_xml import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from PythonModels.core.network import SimulationNetwork from app.simulation.core.ports import PortDefinition
from PythonModels.core.ports import PortDefinition from app.simulation.systems.network import SimulationNetwork
app = FastAPI(title="System Simulation ReactFlow App") app = FastAPI(title="System Simulation ReactFlow App")
@@ -231,6 +231,13 @@ def frontend_asset(path: str) -> FileResponse:
return FileResponse(asset_path) 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") @app.post("/api/reactflow/system-xml")
def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response: def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
try: try:
@@ -572,10 +579,10 @@ def run_system_xml_simulation(
progress_callback: SimulationProgressEmitter | None = None, progress_callback: SimulationProgressEmitter | None = None,
cancel_check: Callable[[], bool] | None = None, cancel_check: Callable[[], bool] | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
from PythonModels.core.algebraic import AlgebraicSolveError from app.simulation.solvers.algebraic import AlgebraicSolveError
from PythonModels.core.solver import SolveIVPConfig from app.simulation.solvers.solver import SolveIVPConfig
from PythonModels.core.stream import StreamSolveError from app.simulation.solvers.stream import StreamSolveError
from PythonModels.systems.generic import ( from app.simulation.systems.generic import (
GenericFluidSystem, GenericFluidSystem,
SimulationPreparationError, SimulationPreparationError,
) )
@@ -1071,9 +1078,9 @@ def validate_compatible_ports(
def compile_reactflow_network(project: ReactFlowProjectPayload) -> "SimulationNetwork": def compile_reactflow_network(project: ReactFlowProjectPayload) -> "SimulationNetwork":
from PythonModels.core.medium import IdealGasMedium from app.simulation.core.medium import IdealGasMedium
from PythonModels.core.network import SimulationNetwork from app.simulation.registry import get_component_model_spec
from PythonModels.registry import get_component_model_spec from app.simulation.systems.network import SimulationNetwork
medium = IdealGasMedium() medium = IdealGasMedium()
network = SimulationNetwork(name=project.name) 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]: def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, object]:
from PythonModels.core.solver import SolveIVPConfig from app.simulation.examples.testmodel.run import (
from PythonModels.scripts.run_testmodel import (
TestModelExecutionConfig, TestModelExecutionConfig,
TestModelRunConfig, TestModelRunConfig,
TestModelSamplingConfig, TestModelSamplingConfig,
run_testmodel, run_testmodel,
) )
from PythonModels.systems.testmodel import ( from app.simulation.examples.testmodel.system import (
BranchConfig, BranchConfig,
CylinderConfig, CylinderConfig,
OrificeConfig, OrificeConfig,
@@ -1171,6 +1177,7 @@ def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, objec
TankConfig, TankConfig,
TestModelConfig, TestModelConfig,
) )
from app.simulation.solvers.solver import SolveIVPConfig
nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {} nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {}
for node in project.nodes: for node in project.nodes:
@@ -1,6 +1,6 @@
# PythonModels # 仿真后端
`PythonModels` 用于承接 `ModelicaModels` 的 Python 平台移植。 `app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。 目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。
@@ -8,35 +8,44 @@
## 当前目录 ## 当前目录
- `core/`: 通用基础设施 - `core/`: 元件基类、端口、状态、介质、方程和元数据协议。
包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。 - `solvers/`: ODE、压力流量代数方程和 stream 求解。
- `components/`: 元件级 Python 实现 - `components/experimental/`: 用于验证元件开发规范的临时组件库。
目前有 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee` 五类元件。 - `components/experimental/storage/`: 气瓶和贮箱等储能元件。
新增或修改元件时先阅读 `components/example.md` 中的建模规范与完整示例。 - `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。
- `systems/`: 系统级装配与闭合 - `components/experimental/junctions/`: 三通等连接节点。
当前只有 `TestModelSystem`,对应 `ModelicaModels/Testmodel.mo`。 - `systems/`: 通用仿真网络与 XML 驱动系统装配。
- `reporting/`: 结果导出与对比 - `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和运行入口。
当前承接主变量 CSV、温度 CSV/SVG、Python 对 OpenModelica 的对比表与误差摘要导出。 - `reporting/`: CSV、SVG、运行报告和 Modelica 对比结果导出。
- `scripts/`: 运行脚本 - `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
当前入口是 `run_testmodel.py`。 - `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
- `baselines/`: 提交进仓库的稳定基线
当前承接 Python 主变量基线和 Python 对 Modelica 的误差摘要基线。 稳定基准存放在 `tests/baselines/simulation/`,实际运行产物默认写入被 Git 忽略的
- `runs/`: 每次实际运行的默认输出目录 `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/medium.py`: 温度相关的空气近似介质 `IdealGasMedium` - `core/medium.py`: 温度相关的空气近似介质 `IdealGasMedium`
- `core/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装 - `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
- `core/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回 - `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
- `components/pipe.py`: 单阻容管道近似,入口压降 + 出口直连内容腔 - `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
- `components/tee.py`: 三通的最小 stream 混合 helper - `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
- `systems/testmodel.py`: `Testmodel` 的系统装配壳与外部运行入口 - `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
- `systems/testmodel_closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写 - `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出 - `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
- `scripts/run_testmodel.py`: 基线运行与程序化执行入口 - `examples/testmodel/run.py`: 基线运行与程序化执行入口
- `tests/test_pythonmodels_regression.py`: 当前 Python 基线回归测试 - `tests/`: 当前组件契约、XML、通用系统和结果导出测试
## 当前阶段进度 ## 当前阶段进度
@@ -74,7 +83,7 @@
本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态: 本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态:
1. `M2`:已完成当前阶段首版 1. `M2`:已完成当前阶段首版
- 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `systems/testmodel.py` 拆到新的 `systems/testmodel_closure.py` - 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `examples/testmodel/system.py` 拆到 `examples/testmodel/closure.py`
- `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节 - `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节
2. `M3`:已完成当前阶段首版 2. `M3`:已完成当前阶段首版
@@ -179,19 +188,18 @@
最小运行方式: 最小运行方式:
```bash ```bash
python3 -m PythonModels.scripts.run_testmodel python -m app.simulation.examples.testmodel.run
``` ```
如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如: 如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如:
```python ```python
from PythonModels.core.solver import SolveIVPConfig from app.simulation.examples.testmodel.run import (
from PythonModels.scripts.run_testmodel import (
TestModelRunConfig, TestModelRunConfig,
TestModelSamplingConfig, TestModelSamplingConfig,
run_testmodel, run_testmodel,
) )
from PythonModels.systems.testmodel import ( from app.simulation.examples.testmodel.system import (
BranchConfig, BranchConfig,
CylinderConfig, CylinderConfig,
OrificeConfig, OrificeConfig,
@@ -199,6 +207,7 @@ from PythonModels.systems.testmodel import (
TankConfig, TankConfig,
TestModelConfig, TestModelConfig,
) )
from app.simulation.solvers.solver import SolveIVPConfig
run_config = TestModelRunConfig( run_config = TestModelRunConfig(
model=TestModelConfig( model=TestModelConfig(
@@ -219,7 +228,7 @@ result = run_testmodel(run_config=run_config)
如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行: 如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行:
```python ```python
from PythonModels.scripts.run_testmodel import ( from app.simulation.examples.testmodel.run import (
prepare_testmodel_run, prepare_testmodel_run,
run_prepared_testmodel, run_prepared_testmodel,
TestModelRunConfig, TestModelRunConfig,
@@ -239,12 +248,13 @@ print(result.used_modelica_reference)
1. 构建 `TestModelSystem` 1. 构建 `TestModelSystem`
2. 打印原始初值向量与约束一致后的初值向量 2. 打印原始初值向量与约束一致后的初值向量
3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s` 3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s`
4. 将结果写入 `PythonModels/runs/` 下本次运行专属的时间戳目录 4. 将结果写入 `app/data/simulation-runs/` 下本次运行专属的时间戳目录
5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果 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` 三个必要字段。 `PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。
- `core/state.py`: 正常 - `core/state.py`: 正常
`VolumeState` 只负责 `[m, U]` 状态打包。 `VolumeState` 只负责 `[m, U]` 状态打包。
- `core/network.py`: 正常 - `systems/network.py`: 正常
负责状态向量拼装和连接摘要,不参与物理求解。 负责状态向量拼装和连接摘要,不参与物理求解。
- `core/solver.py`: 正常 - `solvers/solver.py`: 正常
已支持 SciPy、RK4 回退和零时长仿真。 已支持 SciPy、RK4 回退和零时长仿真。
- `components/*.py`: 正常 - `components/experimental/**/*.py`: 正常
都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。 都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。
- `systems/testmodel.py`: 是当前最重要的技术债集中区 - `examples/testmodel/system.py`: 是当前最重要的技术债集中区
这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。 这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。
- `scripts/run_testmodel.py`: 正常 - `examples/testmodel/run.py`: 正常
已不是“最小打印脚本”,而是当前结果导出和对比入口。 已不是“最小打印脚本”,而是当前结果导出和对比入口。
- `baselines/`: 是当前稳定基线,不应该随着日常运行频繁改动。 - `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
- `runs/`: 是当前默认运行产物目录,不是手写源代码,也不应该当作提交基线使用。 - `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
## 当前主技术债 ## 当前主技术债
目前最主要的技术债,可以直接理解成下面 4 件事: 目前最主要的技术债,可以直接理解成下面 4 件事:
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。 1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
2. `systems/testmodel.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。 2. `examples/testmodel/system.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。 3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。 4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
+2
View File
@@ -0,0 +1,2 @@
"""Simulation domain models, solvers, system assembly, and result tools."""
File renamed without changes.
@@ -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 ```python
MODEL_TYPE = "example_component" MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PORTS = (...) PORTS = (...)
PARAMETERS = (...) PARAMETERS = (...)
RESULT_VARIABLES = (...) RESULT_VARIABLES = (...)
DISPLAY = ...
``` ```
- `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。 - `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。
- `MODEL_VERSION`:模型契约版本,采用 `主版本.次版本.修订版本`。
- `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。 - `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。
- `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。 - `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。
- `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。 - `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。
- `DISPLAY`:组件库名称、分类、图标、排序和端口画布位置,不参与物理求解。
元件构造函数还必须: 元件构造函数还必须:
@@ -24,6 +38,7 @@ RESULT_VARIABLES = (...)
2. 使用 `set_parameter_values()` 保存规范化后的输入参数。 2. 使用 `set_parameter_values()` 保存规范化后的输入参数。
3. 使用 `register_declared_port()` 创建已声明端口。 3. 使用 `register_declared_port()` 创建已声明端口。
4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。 4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。
5. 实现统一的类方法 `create()`,接收规范化后的 SI 参数。
## 二、输入参数与结果变量 ## 二、输入参数与结果变量
@@ -79,19 +94,21 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from PythonModels.core.base import ThermodynamicVolumeComponent from app.simulation.core.base import ThermodynamicVolumeComponent
from PythonModels.core.equations import EquationResidual from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from PythonModels.core.metadata import ( from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition, ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES, THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
) )
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from PythonModels.core.state import VolumeState from app.simulation.core.state import VolumeState
class ExampleVolume(ThermodynamicVolumeComponent): class ExampleVolume(ThermodynamicVolumeComponent):
MODEL_TYPE = "example_volume" MODEL_TYPE = "example_volume"
MODEL_VERSION = "1.0.0"
PORTS = ( PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"), PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
) )
@@ -125,6 +142,14 @@ class ExampleVolume(ThermodynamicVolumeComponent):
), ),
) )
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES 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__( def __init__(
self, self,
@@ -145,6 +170,22 @@ class ExampleVolume(ThermodynamicVolumeComponent):
self.state = VolumeState(m=initial_mass, U=initial_energy) self.state = VolumeState(m=initial_mass, U=initial_energy)
self.port_a = self.register_declared_port("port_a") 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]: def get_state_vector(self) -> list[float]:
return self.state.as_vector() return self.state.as_vector()
@@ -190,27 +231,22 @@ class ExampleVolume(ThermodynamicVolumeComponent):
) )
``` ```
注册时只引用元件类已经声明的契约,不要再复制参数和端口定义: 模型文件不再直接修改全局注册表。完成模型类后,只把类路径加入所属库
`library.py` 的 `models` 清单:
```python ```python
def _example_volume_factory(name, medium, values): models=(
return ExampleVolume( # ...已有模型
name=name, "app.simulation.components.experimental.storage.example_volume:ExampleVolume",
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,
) )
``` ```
后端会受控导入清单中的类,校验版本、分类、端口、参数、单位、显示信息和默认实例,
再自动建立注册表。校验通过后,`GET /api/components/catalog` 会输出该元件,
前端刷新时即可加载。
当前 `experimental` 仅用于规范验证;正式模型应先建立新的库声明,再把
`library_id` 指向正式库。
完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义: 完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义:
```json ```json
@@ -231,10 +267,16 @@ COMPONENT_MODEL_REGISTRY[ExampleVolume.MODEL_TYPE] = ComponentModelSpec(
## 五、新增元件检查清单 ## 五、新增元件检查清单
1. `MODEL_TYPE` 是否唯一,并与 XML、前端组件类型一致。 1. `MODEL_TYPE` 是否唯一,并与 XML 的模型类型一致。
2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。 2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。
3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。 3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。
4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。 4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。
5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。 5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。
6. 是否只暴露有工程意义的结果,而非内部计算变量。 6. 是否只暴露有工程意义的结果,而非内部计算变量。
7. 是否补充参数边界、端口契约、结果元数据和最小仿真的自动测试。 7. `MODEL_VERSION` 和 `DISPLAY` 是否完整,显示端口是否与物理端口完全一致。
8. 是否实现统一的 `create()`,并能用默认参数创建模型。
9. 模型类路径是否只加入所属库的 `library.py` 清单。
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
组件库、分类和自动发现的完整规则参见
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
@@ -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
@@ -0,0 +1 @@
"""Flow-path and resistance components."""
@@ -3,16 +3,19 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from math import sqrt from math import sqrt
from PythonModels.core.base import AlgebraicComponent from app.simulation.core.base import AlgebraicComponent
from PythonModels.core.equations import EquationResidual from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from PythonModels.core.metadata import ParameterDefinition from app.simulation.core.equations import EquationResidual
from PythonModels.core.ports import PortDefinition from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class Orifice(AlgebraicComponent): class Orifice(AlgebraicComponent):
"""Python port of ModelicaModels.Myorifice.""" """Python port of ModelicaModels.Myorifice."""
MODEL_TYPE = "orifice" MODEL_TYPE = "orifice"
MODEL_VERSION = "1.0.0"
PORTS = ( PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"), PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"), PortDefinition.pneumatic("port_b", nominal_role="outlet"),
@@ -35,6 +38,17 @@ class Orifice(AlgebraicComponent):
), ),
) )
RESULT_VARIABLES = () 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: def __init__(self, name: str, opening: float = 1.0, K: float = 1e-5) -> None:
super().__init__(name=name) super().__init__(name=name)
@@ -44,6 +58,20 @@ class Orifice(AlgebraicComponent):
self.port_a = self.register_declared_port("port_a") self.port_a = self.register_declared_port("port_a")
self.port_b = self.register_declared_port("port_b") 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 @property
def K_eff(self) -> float: def K_eff(self) -> float:
return self.K * max(self.opening, 0.001) return self.K * max(self.opening, 0.001)
@@ -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",)
@@ -3,17 +3,19 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from math import pi from math import pi
from PythonModels.core.base import AlgebraicComponent from app.simulation.core.base import AlgebraicComponent
from PythonModels.core.equations import EquationResidual from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from PythonModels.core.metadata import ParameterDefinition from app.simulation.core.equations import EquationResidual
from PythonModels.core.medium import IdealGasMedium from app.simulation.core.metadata import ParameterDefinition
from PythonModels.core.ports import PortDefinition from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class ResistivePipe(AlgebraicComponent): class ResistivePipe(AlgebraicComponent):
"""Quasi-steady Darcy resistance used by topology-driven simulation.""" """Quasi-steady Darcy resistance used by topology-driven simulation."""
MODEL_TYPE = "pipe" MODEL_TYPE = "pipe"
MODEL_VERSION = "1.0.0"
PORTS = ( PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"), PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"), PortDefinition.pneumatic("port_b", nominal_role="outlet"),
@@ -63,6 +65,17 @@ class ResistivePipe(AlgebraicComponent):
), ),
) )
RESULT_VARIABLES = () 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__( def __init__(
self, self,
@@ -101,6 +114,24 @@ class ResistivePipe(AlgebraicComponent):
self.port_b.p = p0 self.port_b.p = p0
self.port_b.h_outflow = initial_h 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: 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) average_pressure = max(0.5 * (p_a + p_b), 1.0)
density = max(self.medium.density(average_pressure, self.T0), 1e-12) density = max(self.medium.density(average_pressure, self.T0), 1e-12)
@@ -0,0 +1 @@
"""Flow junction components."""
@@ -2,15 +2,18 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from PythonModels.core.base import AlgebraicComponent from app.simulation.core.base import AlgebraicComponent
from PythonModels.core.equations import EquationResidual from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from PythonModels.core.ports import PortDefinition from app.simulation.core.equations import EquationResidual
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class Tee(AlgebraicComponent): class Tee(AlgebraicComponent):
"""Python port of ModelicaModels.Mytee.""" """Python port of ModelicaModels.Mytee."""
MODEL_TYPE = "tee" MODEL_TYPE = "tee"
MODEL_VERSION = "1.0.0"
PORTS = ( PORTS = (
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"), PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"), PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
@@ -18,6 +21,18 @@ class Tee(AlgebraicComponent):
) )
PARAMETERS = () PARAMETERS = ()
RESULT_VARIABLES = () 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: def __init__(self, name: str) -> None:
super().__init__(name=name) super().__init__(name=name)
@@ -26,6 +41,16 @@ class Tee(AlgebraicComponent):
self.port_out1 = self.register_declared_port("port_out1") self.port_out1 = self.register_declared_port("port_out1")
self.port_out2 = self.register_declared_port("port_out2") 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, ...]: def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return ( return (
EquationResidual( EquationResidual(
@@ -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",
),
)
@@ -0,0 +1 @@
"""Storage and thermodynamic volume components."""
@@ -2,21 +2,23 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from PythonModels.core.base import ThermodynamicVolumeComponent from app.simulation.core.base import ThermodynamicVolumeComponent
from PythonModels.core.equations import EquationResidual from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from PythonModels.core.metadata import ( from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition, ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES, THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
) )
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from PythonModels.core.state import VolumeState from app.simulation.core.state import VolumeState
class Cylinder(ThermodynamicVolumeComponent): class Cylinder(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mycylinder.""" """Python port of ModelicaModels.Mycylinder."""
MODEL_TYPE = "cylinder" MODEL_TYPE = "cylinder"
MODEL_VERSION = "1.0.0"
PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),) PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),)
PARAMETERS = ( PARAMETERS = (
ParameterDefinition( ParameterDefinition(
@@ -48,6 +50,14 @@ class Cylinder(ThermodynamicVolumeComponent):
), ),
) )
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES 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__( def __init__(
self, self,
@@ -66,6 +76,22 @@ class Cylinder(ThermodynamicVolumeComponent):
self.state = VolumeState(m=m0, U=U0) self.state = VolumeState(m=m0, U=U0)
self.port_b = self.register_declared_port("port_b") 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]: def get_state_vector(self) -> list[float]:
return self.state.as_vector() return self.state.as_vector()
@@ -2,21 +2,23 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from PythonModels.core.base import ThermodynamicVolumeComponent from app.simulation.core.base import ThermodynamicVolumeComponent
from PythonModels.core.equations import EquationResidual from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from PythonModels.core.metadata import ( from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition, ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES, THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
) )
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from PythonModels.core.state import VolumeState from app.simulation.core.state import VolumeState
class Tank(ThermodynamicVolumeComponent): class Tank(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mytank.""" """Python port of ModelicaModels.Mytank."""
MODEL_TYPE = "tank" MODEL_TYPE = "tank"
MODEL_VERSION = "1.0.0"
PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),) PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),)
PARAMETERS = ( PARAMETERS = (
ParameterDefinition( ParameterDefinition(
@@ -48,6 +50,14 @@ class Tank(ThermodynamicVolumeComponent):
), ),
) )
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES 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__( def __init__(
self, self,
@@ -66,6 +76,22 @@ class Tank(ThermodynamicVolumeComponent):
self.state = VolumeState(m=m0, U=U0) self.state = VolumeState(m=m0, U=U0)
self.port_a = self.register_declared_port("port_a") 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]: def get_state_vector(self) -> list[float]:
return self.state.as_vector() return self.state.as_vector()
File renamed without changes.
@@ -2,23 +2,29 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any, ClassVar from typing import TYPE_CHECKING, Any, ClassVar
from PythonModels.core.equations import EquationResidual from app.simulation.core.catalog import ComponentDisplaySpec
from PythonModels.core.metadata import ( from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition, ParameterDefinition,
ResultVariableDefinition, ResultVariableDefinition,
ResultVariableMetadata, ResultVariableMetadata,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES, 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): class Component(ABC):
MODEL_TYPE: ClassVar[str | None] = None MODEL_TYPE: ClassVar[str | None] = None
MODEL_VERSION: ClassVar[str | None] = None
PORTS: ClassVar[tuple[PortDefinition, ...]] = () PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = () PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = () RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
DISPLAY: ClassVar[ComponentDisplaySpec | None] = None
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
self.name = name self.name = name
@@ -174,6 +180,20 @@ class Component(ABC):
for definition in self.PARAMETERS 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, ...]: def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
"""Return algebraic residuals after the network assigns port states.""" """Return algebraic residuals after the network assigns port states."""
+83
View File
@@ -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),
)
],
}
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
from PythonModels.core.ports import VariableRole from app.simulation.core.ports import VariableRole
EquationOwner = Literal["connection", "component"] EquationOwner = Literal["connection", "component"]
File renamed without changes.
@@ -8,6 +8,22 @@ from typing import Literal
ResultVariableScope = Literal["component", "port"] 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) @dataclass(frozen=True)
class ParameterDefinition: class ParameterDefinition:
"""User-configurable model input expressed in the backend SI contract.""" """User-configurable model input expressed in the backend SI contract."""
File renamed without changes.
File renamed without changes.
+1
View File
@@ -0,0 +1 @@
"""Reference systems and regression examples."""
@@ -0,0 +1 @@
"""Legacy TestModel reference system."""
@@ -3,13 +3,13 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Callable from typing import Callable
from PythonModels.components.cylinder import Cylinder from app.simulation.components.experimental.flow.orifice import Orifice
from PythonModels.components.orifice import Orifice from app.simulation.examples.testmodel.dynamic_pipe import Pipe
from PythonModels.components.pipe import Pipe from app.simulation.components.experimental.junctions.tee import Tee
from PythonModels.components.tank import Tank from app.simulation.components.experimental.storage.cylinder import Cylinder
from PythonModels.components.tee import Tee from app.simulation.components.experimental.storage.tank import Tank
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.state import VolumeState from app.simulation.core.state import VolumeState
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -2,21 +2,22 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from PythonModels.core.base import ThermodynamicVolumeComponent from app.simulation.core.base import ThermodynamicVolumeComponent
from PythonModels.core.equations import EquationResidual from app.simulation.core.equations import EquationResidual
from PythonModels.core.metadata import ( from app.simulation.core.metadata import (
ParameterDefinition, ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES, THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
) )
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from PythonModels.core.state import VolumeState from app.simulation.core.state import VolumeState
class Pipe(ThermodynamicVolumeComponent): class Pipe(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mypipe.""" """Dynamic pipe retained for the fixed TestModel compatibility example."""
MODEL_TYPE = "pipe" MODEL_TYPE = "pipe"
MODEL_VERSION = "0.1.0"
PORTS = ( PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"), PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"), 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_a = self.register_declared_port("port_a")
self.port_b = self.register_declared_port("port_b") 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]: def get_state_vector(self) -> list[float]:
return self.state.as_vector() return self.state.as_vector()
@@ -4,7 +4,18 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path 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, COMPARISON_KEYS,
PRIMARY_KEYS, PRIMARY_KEYS,
TestModelArtifacts, TestModelArtifacts,
@@ -13,13 +24,7 @@ from PythonModels.reporting import (
load_modelica_series, load_modelica_series,
write_testmodel_run_report, write_testmodel_run_report,
) )
from PythonModels.core.solver import SolveIVPConfig from app.simulation.solvers.solver import SolveIVPConfig
from PythonModels.systems.testmodel import (
InitializationDiagnostics,
TestModelConfig,
TestModelSystem,
)
from PythonModels.systems.testmodel_closure import TestModelSolveDiagnostics
@dataclass(frozen=True) @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)] 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") 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( def prepare_testmodel_run(
@@ -102,22 +107,20 @@ def prepare_testmodel_run(
modelica_result_path: Path | None = None, modelica_result_path: Path | None = None,
) -> PreparedTestModelRun: ) -> PreparedTestModelRun:
run_config = run_config or TestModelRunConfig() run_config = run_config or TestModelRunConfig()
repo_root = Path(__file__).resolve().parents[2]
pythonmodels_root = Path(__file__).resolve().parents[1]
resolved_output_dir = ( resolved_output_dir = (
output_dir output_dir
or run_config.paths.output_dir or run_config.paths.output_dir
or _default_run_output_dir(pythonmodels_root) or _default_run_output_dir()
) )
resolved_modelica_result_path = ( resolved_modelica_result_path = (
modelica_result_path modelica_result_path
or run_config.paths.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()) t_eval = tuple(run_config.sample_times())
return PreparedTestModelRun( return PreparedTestModelRun(
run_config=run_config, run_config=run_config,
repo_root=repo_root, repo_root=PROJECT_ROOT,
output_dir=resolved_output_dir, output_dir=resolved_output_dir,
modelica_result_path=resolved_modelica_result_path, modelica_result_path=resolved_modelica_result_path,
t_eval=t_eval, t_eval=t_eval,
@@ -3,21 +3,21 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from PythonModels.components.cylinder import Cylinder from app.simulation.components.experimental.flow.orifice import Orifice
from PythonModels.components.orifice import Orifice from app.simulation.components.experimental.junctions.tee import Tee
from PythonModels.components.pipe import Pipe from app.simulation.components.experimental.storage.cylinder import Cylinder
from PythonModels.components.tank import Tank from app.simulation.components.experimental.storage.tank import Tank
from PythonModels.components.tee import Tee from app.simulation.core.medium import IdealGasMedium
from PythonModels.core.medium import IdealGasMedium from app.simulation.examples.testmodel.closure import (
from PythonModels.core.network import SimulationNetwork
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
from PythonModels.systems.testmodel_closure import (
BranchClosureComponents, BranchClosureComponents,
InitializationDiagnostics, InitializationDiagnostics,
TestModelClosure, TestModelClosure,
TestModelClosureComponents, TestModelClosureComponents,
TestModelSnapshot, 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) @dataclass(frozen=True)
+21
View File
@@ -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"
)
+717
View File
@@ -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
@@ -1,4 +1,4 @@
from PythonModels.reporting.testmodel_outputs import ( from app.simulation.reporting.testmodel_outputs import (
COMPARISON_KEYS, COMPARISON_KEYS,
MODELICA_COMPARISON_COLUMNS, MODELICA_COMPARISON_COLUMNS,
PRIMARY_KEYS, PRIMARY_KEYS,
+1
View File
@@ -0,0 +1 @@
"""Numerical solvers used by simulation systems."""
@@ -3,8 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from math import sqrt from math import sqrt
from PythonModels.core.network import SimulationNetwork from app.simulation.core.ports import PortState, VariableRole
from PythonModels.core.ports import PortState, VariableRole from app.simulation.systems.network import SimulationNetwork
class AlgebraicSolveError(RuntimeError): class AlgebraicSolveError(RuntimeError):
File renamed without changes.
@@ -2,8 +2,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from PythonModels.core.base import DynamicComponent from app.simulation.core.base import DynamicComponent
from PythonModels.core.network import Endpoint, SimulationNetwork from app.simulation.systems.network import Endpoint, SimulationNetwork
class StreamSolveError(RuntimeError): class StreamSolveError(RuntimeError):
File renamed without changes.
@@ -5,12 +5,12 @@ from dataclasses import dataclass
from math import floor, isfinite from math import floor, isfinite
from typing import Literal from typing import Literal
from PythonModels.core.algebraic import PressureFlowSolver from app.simulation.core.base import DynamicComponent
from PythonModels.core.base import DynamicComponent from app.simulation.core.metadata import ResultVariableMetadata
from PythonModels.core.metadata import ResultVariableMetadata from app.simulation.solvers.algebraic import PressureFlowSolver
from PythonModels.core.network import Endpoint, SimulationNetwork from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
from PythonModels.core.solver import ODESolution, SolveIVPConfig, integrate_ode from app.simulation.solvers.stream import StreamResolver
from PythonModels.core.stream import StreamResolver from app.simulation.systems.network import Endpoint, SimulationNetwork
SimulationProgressCallback = Callable[[float, str], None] SimulationProgressCallback = Callable[[float, str], None]
@@ -2,10 +2,10 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from PythonModels.core.base import Component, DynamicComponent from app.simulation.core.base import Component, DynamicComponent
from PythonModels.core.equations import EquationResidual from app.simulation.core.equations import EquationResidual
from PythonModels.core.metadata import ResultVariableMetadata from app.simulation.core.metadata import ResultVariableMetadata
from PythonModels.core.ports import PortState from app.simulation.core.ports import PortState
@dataclass(frozen=True) @dataclass(frozen=True)
+2 -2
View File
@@ -9,8 +9,8 @@ from typing import Literal
from lxml import etree from lxml import etree
from PythonModels.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from PythonModels.registry import ( from app.simulation.registry import (
COMPONENT_MODEL_REGISTRY, COMPONENT_MODEL_REGISTRY,
ParameterSpec, ParameterSpec,
) )
+46
View File
@@ -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` 受控登记公开模型。
- 不在前端复制后端端口、参数或默认值作为正式来源。
- 不覆盖用户已有改动。
- 不自行猜测缺失的物理方程。
- 修改后运行针对性测试和完整回归,并报告未完成的验证。
+730
View File
@@ -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
<Component
id="cylinder_1"
name="cylinder_1"
type="cylinder"
componentType="cylinder">
```
映射规则:
- `id`:工程内唯一的组件实例 ID。
- `name`:用户可修改的组件实例名称。
- `type`:必须匹配唯一的 `MODEL_TYPE`。
- `componentType`:当前为兼容字段,应与 `type` 相同。
- `<Port name>`:必须存在于模型的 `PORTS`。
- `<Parameter name>`:必须存在于模型的 `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
```
+680
View File
@@ -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/<library_id>/<category_id>/<model_module>.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 能映射到正确模型。
- 最小系统能够编译;声称可仿真的模型必须产生有限结果。
- 针对性测试、完整回归和必要的前端构建通过。
- 文档记录了模型假设、适用范围和已知限制。
+2 -2
View File
@@ -2,7 +2,7 @@
> 此版本仅用于识别旧文件。新项目使用 [System XML v2](system-xml-v2.md),物理连接在 v2 中改为无序端点。 > 此版本仅用于识别旧文件。新项目使用 [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 基准值,界面显示单位不进入数值换算语义。 - `unitSystem` 固定为 `SI`。组件参数必须保存为 SI 基准值,界面显示单位不进入数值换算语义。
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。 - 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
- `Component.id` 是稳定的仿真实例 ID;`name` 是用户可编辑的显示名称。 - `Component.id` 是稳定的仿真实例 ID;`name` 是用户可编辑的显示名称。
- `Component.type` 是 PythonModels 模型类型;`componentType` 是前端组件类型。 - `Component.type` 是仿真后端模型类型;`componentType` 是前端组件类型。
- `Connection.source/target` 只标识图形拓扑端点,不代表仿真中的实际流动方向。正流和倒流由求解器决定。 - `Connection.source/target` 只标识图形拓扑端点,不代表仿真中的实际流动方向。正流和倒流由求解器决定。
- v1 文档不携带结果数据;仿真结果通过运行接口返回。 - v1 文档不携带结果数据;仿真结果通过运行接口返回。
+8 -8
View File
@@ -1,6 +1,6 @@
# System XML v2 协议 # 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` 表示该端口发生实际流入,可在结果层标记为倒流。 物理端口的流向由求解结果决定。一个名义出口的 `m_flow > 0` 表示该端口发生实际流入,可在结果层标记为倒流。
三通的三个端口当前保留 PythonModels 已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。 三通的三个端口当前保留仿真模型已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。
## Connection ## Connection
@@ -80,11 +80,11 @@ System XML v2 是 ReactFlow 建模前端与 PythonModels 仿真层之间的交
XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。 XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。
## PythonModels 编译接口 ## 仿真模型编译接口
`POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作: `POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作:
1. 按 `node.data.modelType` 创建 PythonModels 组件实例,并写入 SI 参数。 1. 按 `node.data.modelType` 创建 `app.simulation` 组件实例,并写入 SI 参数。
2. 将前端端口声明与组件注册端口逐项比对。 2. 将前端端口声明与组件注册端口逐项比对。
3. 按画布实际 `edges` 创建无方向物理连接,而不是按组件类型或拖入顺序推断拓扑。 3. 按画布实际 `edges` 创建无方向物理连接,而不是按组件类型或拖入顺序推断拓扑。
4. 检查端口存在性、物理域兼容性、重复连接和未连接端口。 4. 检查端口存在性、物理域兼容性、重复连接和未连接端口。
@@ -140,7 +140,7 @@ XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求
## 第二阶段:XML 解析与校验 ## 第二阶段:XML 解析与校验
第二阶段已经实现从 System XML v2 回到 PythonModels 网络的完整入口。解析过程固定分为三层: 第二阶段已经实现从 System XML v2 回到仿真网络的完整入口。解析过程固定分为三层:
| 层级 | `layer` | 负责内容 | | 层级 | `layer` | 负责内容 |
|---|---|---| |---|---|---|
@@ -169,7 +169,7 @@ XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求
- `POST /api/system-xml/validate`:无论成功与否都返回校验报告,便于编辑器实时显示问题。 - `POST /api/system-xml/validate`:无论成功与否都返回校验报告,便于编辑器实时显示问题。
- `POST /api/system-xml/parse`:成功时返回规范化工程 JSON;失败时返回 HTTP `422` 和结构化诊断。 - `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` 和仿真层诊断。 - `POST /api/system-xml/simulate`:完成校验、编译、仿真准备、代数闭合、stream 传播和时间积分;成功时返回组件与端口时间序列,失败时返回 HTTP `422` 和仿真层诊断。
示例: 示例:
@@ -182,13 +182,13 @@ Invoke-RestMethod `
-InFile .\test\system.xml -InFile .\test\system.xml
``` ```
组件参数和端口定义集中在 `PythonModels/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。 组件参数和端口定义集中在 `app/simulation/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。
## 第三阶段:XML 驱动仿真 MVP ## 第三阶段:XML 驱动仿真 MVP
第三阶段当前已经打通: 第三阶段当前已经打通:
1. XML 中的组件、参数和无方向物理连接编译成 PythonModels 网络。 1. XML 中的组件、参数和无方向物理连接编译成 `app.simulation` 网络。
2. 仿真准备层检查未连接端口、方程数量、无储能代数孤岛和无阻力储能直连。 2. 仿真准备层检查未连接端口、方程数量、无储能代数孤岛和无阻力储能直连。
3. SciPy 非线性最小二乘求解每个时刻的端口压力与质量流量。 3. SciPy 非线性最小二乘求解每个时刻的端口压力与质量流量。
4. 根据求解后的实际流向迭代传播 `h_outflow`,并在三通处执行质量流量加权混合。 4. 根据求解后的实际流向迭代传播 `h_outflow`,并在三通处执行质量流量加权混合。
+64
View File
@@ -15,6 +15,7 @@
"react-dom": "^19.2.7" "react-dom": "^19.2.7"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
@@ -85,6 +86,22 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", "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" "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": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+4 -1
View File
@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1", "dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build", "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": { "dependencies": {
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
@@ -16,6 +18,7 @@
"react-dom": "^19.2.7" "react-dom": "^19.2.7"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
+25
View File
@@ -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,
},
});
+597 -233
View File
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
import { useLayoutEffect } from "react";
import { useReactFlow, useStore } from "@xyflow/react";
type AutoFitViewProps = {
enabled?: boolean;
expectedNodeCount: number;
minZoom?: number;
padding?: number;
requestKey: number | string;
};
export function AutoFitView({
enabled = true,
expectedNodeCount,
minZoom = 0.1,
padding = 0.18,
requestKey,
}: AutoFitViewProps) {
const expectedNodesAreMeasured = useStore((state) => {
if (expectedNodeCount <= 0 || state.nodeLookup.size !== expectedNodeCount) {
return false;
}
for (const node of state.nodeLookup.values()) {
if (
!node.measured.width ||
!node.measured.height ||
node.measured.width <= 0 ||
node.measured.height <= 0
) {
return false;
}
}
return true;
});
const { fitView } = useReactFlow();
useLayoutEffect(() => {
if (!enabled || !expectedNodesAreMeasured) {
return;
}
void fitView({ duration: 0, minZoom, padding });
}, [
enabled,
expectedNodesAreMeasured,
fitView,
minZoom,
padding,
requestKey,
]);
return null;
}
+10 -2
View File
@@ -33,6 +33,7 @@ import {
WorkspaceViewTabs, WorkspaceViewTabs,
type WorkspaceView, type WorkspaceView,
} from "./WorkspaceViewTabs"; } from "./WorkspaceViewTabs";
import { AutoFitView } from "./AutoFitView";
type ResultPortDefinition = { type ResultPortDefinition = {
name: string; name: string;
@@ -1134,7 +1135,7 @@ export function SimulationResultsView({
edgesFocusable={false} edgesFocusable={false}
edgesReconnectable={false} edgesReconnectable={false}
elementsSelectable elementsSelectable
fitView minZoom={0.1}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
nodes={readOnlyNodes} nodes={readOnlyNodes}
nodesConnectable={false} nodesConnectable={false}
@@ -1146,8 +1147,15 @@ export function SimulationResultsView({
selectionOnDrag={false} selectionOnDrag={false}
zoomOnDoubleClick={false} zoomOnDoubleClick={false}
> >
<AutoFitView
enabled={readOnlyNodes.length > 0}
expectedNodeCount={readOnlyNodes.length}
requestKey={snapshot.id}
/>
<Background gap={18} /> <Background gap={18} />
<Controls /> <Controls
fitViewOptions={{ duration: 180, minZoom: 0.1, padding: 0.18 }}
/>
</ReactFlow> </ReactFlow>
</div> </div>
</section> </section>
+150 -176
View File
@@ -369,15 +369,6 @@ textarea {
border-left: 0; border-left: 0;
} }
.status-strip {
padding: 10px 14px;
border-bottom: 1px solid #e3e8ef;
color: #155e75;
background: #ecfeff;
font-size: 12px;
font-weight: 650;
}
.panel-title { .panel-title {
padding: 14px; padding: 14px;
border-bottom: 1px solid #e3e8ef; border-bottom: 1px solid #e3e8ef;
@@ -385,13 +376,90 @@ textarea {
font-weight: 700; font-weight: 700;
} }
.palette-list, .palette-list {
display: block;
padding: 12px;
}
.parameter-form { .parameter-form {
display: grid; display: grid;
gap: 10px; gap: 10px;
padding: 12px; padding: 12px;
} }
.palette-panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.catalog-source {
display: inline-flex;
align-items: center;
gap: 5px;
color: #64748b;
font-size: 10px;
font-weight: 500;
white-space: nowrap;
}
.catalog-source > span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #d49b16;
}
.catalog-source.ready > span {
background: #198754;
}
.catalog-source.fallback > span {
background: #b45309;
}
.palette-library + .palette-library {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid #e3e8ef;
}
.palette-library-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.palette-library-heading strong {
color: #17212b;
font-size: 12px;
}
.palette-library-heading small {
color: #64748b;
font-size: 10px;
white-space: nowrap;
}
.palette-category + .palette-category {
margin-top: 14px;
}
.palette-category-title {
margin-bottom: 6px;
color: #526172;
font-size: 10px;
font-weight: 700;
}
.palette-category-items {
display: grid;
gap: 8px;
}
.compact-form { .compact-form {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -523,6 +591,34 @@ textarea {
border: 0; border: 0;
} }
.icon-generic {
position: relative;
border-radius: 4px;
background: #ffffff;
}
.icon-generic::before,
.icon-generic::after {
position: absolute;
top: 50%;
width: 6px;
height: 6px;
box-sizing: border-box;
border: 1.5px solid #4b5563;
border-radius: 50%;
background: #ffffff;
content: "";
transform: translateY(-50%);
}
.icon-generic::before {
left: -4px;
}
.icon-generic::after {
right: -4px;
}
.icon-tee::before, .icon-tee::before,
.icon-tee::after { .icon-tee::after {
position: absolute; position: absolute;
@@ -678,6 +774,22 @@ textarea {
background: rgba(255, 255, 255, 0.88); background: rgba(255, 255, 255, 0.88);
} }
.sim-node.generic-component .node-label {
top: 50%;
bottom: auto;
display: -webkit-box;
max-width: calc(100% - 28px);
overflow: hidden;
line-height: 16px;
text-align: center;
text-overflow: ellipsis;
transform: translate(-50%, -50%);
white-space: normal;
background: transparent;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.symbol-cylinder, .symbol-cylinder,
.symbol-tank, .symbol-tank,
.symbol-pipe, .symbol-pipe,
@@ -842,138 +954,6 @@ textarea {
font-size: 13px; font-size: 13px;
} }
.validation-section {
padding: 10px 12px;
border-bottom: 1px solid #e3e8ef;
background: #fffdf9;
}
.validation-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
}
.validation-heading span {
color: #9a6700;
font-size: 11px;
font-weight: 500;
}
.validation-list {
display: grid;
gap: 6px;
}
.validation-item {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 7px;
align-items: start;
width: 100%;
padding: 7px;
border: 1px solid #e5c0bd;
border-radius: 5px;
color: #7a271a;
text-align: left;
background: #fff7f6;
cursor: pointer;
}
.validation-item.warning {
border-color: #ead59a;
color: #713f12;
background: #fffbeb;
}
.validation-item:disabled {
cursor: default;
}
.validation-item span {
font-size: 10px;
font-weight: 700;
}
.validation-item strong {
overflow-wrap: anywhere;
font-size: 11px;
line-height: 1.45;
}
.xml-output {
width: calc(100% - 24px);
min-height: 220px;
margin: 12px;
padding: 10px;
border: 1px solid #c2ccda;
border-radius: 6px;
color: #1f2933;
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
resize: vertical;
}
.result-section {
display: grid;
gap: 12px;
padding: 12px;
}
.result-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.result-title small {
color: #b45309;
font-size: 11px;
font-weight: 600;
}
.result-grid {
display: grid;
grid-template-columns: minmax(80px, 1fr) minmax(0, 1.4fr);
gap: 8px 10px;
align-items: baseline;
font-size: 12px;
}
.result-grid span {
color: #64748b;
}
.result-grid strong {
overflow-wrap: anywhere;
color: #1f2933;
font-size: 12px;
}
.run-message {
padding: 8px;
border: 1px solid #d6dee9;
border-radius: 6px;
color: #334155;
background: #f8fafc;
font-size: 12px;
}
.component-result {
display: grid;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e3e8ef;
}
.component-result > strong {
color: #1f2933;
font-size: 12px;
}
.results-shell { .results-shell {
display: grid; display: grid;
grid-template-rows: 62px minmax(0, 1fr); grid-template-rows: 62px minmax(0, 1fr);
@@ -2032,11 +2012,6 @@ textarea {
height: 40px; height: 40px;
} }
.simulation-console.minimized.has-unread {
border-color: #1d6fb8;
animation: simulation-console-attention 1.2s ease-out 2;
}
.simulation-console-collapsed { .simulation-console-collapsed {
display: grid; display: grid;
grid-template-columns: 34px 34px minmax(0, 1fr); grid-template-columns: 34px 34px minmax(0, 1fr);
@@ -2082,7 +2057,7 @@ textarea {
.simulation-console-collapsed-open { .simulation-console-collapsed-open {
display: grid; display: grid;
grid-template-columns: 18px auto minmax(0, 1fr) auto 18px; grid-template-columns: 18px auto minmax(0, 1fr) 18px;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
@@ -2109,7 +2084,6 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.simulation-console-collapsed-message,
.simulation-console-collapsed-status { .simulation-console-collapsed-status {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -2126,21 +2100,6 @@ textarea {
font-weight: 700; font-weight: 700;
} }
.simulation-console-unread {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 24px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
color: #ffffff;
background: #c13d35;
font-size: 10px;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.simulation-console-header { .simulation-console-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2361,21 +2320,36 @@ textarea {
color: #a9322d; color: #a9322d;
} }
@keyframes simulation-console-attention { .simulation-console-entry-message {
0%, min-width: 0;
100% { overflow-wrap: anywhere;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22); }
}
50% { .simulation-console-entry.xml {
box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.2), 0 10px 28px rgba(15, 23, 42, 0.22); align-items: start;
} }
.simulation-console-entry-xml {
min-width: 0;
max-height: 145px;
margin: 0;
padding: 8px 9px;
overflow: auto;
border: 1px solid #cad5e1;
border-radius: 4px;
color: #172231;
background: #f8fafc;
font-family: Consolas, "Courier New", monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre;
}
.simulation-console.maximized .simulation-console-entry-xml {
max-height: calc(100vh - 170px);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.simulation-console.minimized.has-unread {
animation: none;
}
.simulation-console-progress-track span { .simulation-console-progress-track span {
transition: none; transition: none;
} }
+81
View File
@@ -0,0 +1,81 @@
import { expect, test } from "@playwright/test";
import {
expectAllNodesInsideCanvas,
prepareApp,
resultSnapshot,
wideProject,
} from "./fixtures";
test("加载本地工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(project),
);
}, wideProject);
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toContainText("工程已加载");
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("工程已加载");
});
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify(project),
);
}, wideProject);
await page.goto("/");
await page.getByRole("button", { name: "恢复" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toContainText("已恢复上次自动保存的工程");
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
});
test("切换到结果页时自动适配只读系统图", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((snapshot) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, resultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0);
await page.getByRole("tab", { name: "建模" }).click();
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toBeVisible();
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0);
await expect(page.locator(".results-system-canvas .react-flow__edge")).toHaveCount(
wideProject.edges.length,
);
});
+213
View File
@@ -0,0 +1,213 @@
import { expect, type Page } from "@playwright/test";
const physicalPort = (
name: string,
side: "left" | "right",
nominalRole: "inlet" | "outlet" | "bidirectional",
) => ({
name,
kind: "physical",
domain: "pneumatic",
nominalRole,
positiveFlowDirection: "intoComponent",
side,
order: side === "left" ? 0 : 1,
});
export const componentCatalog = {
schemaVersion: 1,
libraries: [
{
id: "e2e-library",
label: "自动化测试组件库",
version: "1.0.0",
sourcePackage: "tests.e2e",
temporary: true,
order: 1,
components: [
{
type: "generic_sensor",
label: "通用测试元件",
modelType: "generic_sensor",
modelVersion: "1.0.0",
symbol: "symbol-not-registered",
order: 1,
category: {
id: "generic",
label: "通用组件",
order: 1,
},
ports: [
physicalPort("port_a", "left", "bidirectional"),
physicalPort("port_b", "right", "bidirectional"),
],
parameters: [
{
name: "gain",
label: "增益",
quantity: "dimensionless",
unit: "",
default: 1,
minimumExclusive: false,
},
],
},
],
},
],
};
const projectPort = (
name: string,
side: "left" | "right",
nominalRole: "inlet" | "outlet" | "bidirectional",
) => ({
name,
kind: "physical",
domain: "pneumatic",
nominalRole,
positiveFlowDirection: "intoComponent",
side,
});
export const wideProject = {
name: "demo-system",
nodes: Array.from({ length: 4 }, (_, index) => ({
id: `generic_sensor_${index + 1}`,
type: "simulationComponent",
position: {
x: index * 620,
y: index % 2 === 0 ? 0 : 260,
},
data: {
label: `generic_sensor_${index + 1}`,
componentType: "generic_sensor",
modelType: "generic_sensor",
symbol: "symbol-not-registered",
ports: [
projectPort("port_a", "left", "bidirectional"),
projectPort("port_b", "right", "bidirectional"),
],
parameters: { gain: 1 },
parameterUnits: { gain: "" },
rotation: 0,
mirrored: false,
},
})),
edges: Array.from({ length: 3 }, (_, index) => ({
id: `edge-${index + 1}`,
source: `generic_sensor_${index + 1}`,
target: `generic_sensor_${index + 2}`,
sourceHandle: "port_b",
targetHandle: "port_a",
})),
simulation: {
t_start: 0,
t_stop: 10,
step: 0.1,
max_step: 0.05,
method: "RK45",
},
};
export const resultSnapshot = {
id: "e2e-result",
createdAt: "2026-07-28T10:00:00.000Z",
project: wideProject,
result: {
success: true,
status: "completed",
partial: false,
message: "Simulation completed",
simulatedUntil: 10,
requestedStopTime: 10,
variables: [
{
key: "generic_sensor_1.value",
componentId: "generic_sensor_1",
componentType: "generic_sensor",
scope: "component",
portName: null,
name: "value",
label: "数值",
quantity: "dimensionless",
unit: "",
category: "state",
order: 1,
},
],
final: { "generic_sensor_1.value": 1 },
series: {
time: [0, 5, 10],
"generic_sensor_1.value": [0, 0.5, 1],
},
diagnostics: {
pressureFlow: {
solveCount: 3,
maxScaledResidual: 0,
maxEvaluationsPerSolve: 1,
},
stream: {
maxIterationsPerSolve: 1,
},
stateCount: 1,
sampleCount: 3,
},
},
};
export async function prepareApp(page: Page) {
await page.addInitScript(() => {
window.localStorage.clear();
window.sessionStorage.clear();
window.location.hash = "#/modeling";
});
await page.route("**/api/components/catalog", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(componentCatalog),
});
});
}
export async function expectAllNodesInsideCanvas(
page: Page,
canvasSelector: string,
expectedCount = wideProject.nodes.length,
) {
const canvas = page.locator(canvasSelector);
await expect(canvas).toBeVisible();
const nodes = canvas.locator(".react-flow__node");
await expect(nodes).toHaveCount(expectedCount);
await expect
.poll(
async () =>
page.evaluate(
({ selector, count }) => {
const canvasElement = document.querySelector<HTMLElement>(selector);
const nodeElements = Array.from(
canvasElement?.querySelectorAll<HTMLElement>(".react-flow__node") ?? [],
);
if (!canvasElement || nodeElements.length !== count) {
return false;
}
const canvasBounds = canvasElement.getBoundingClientRect();
const tolerance = 3;
return nodeElements.every((node) => {
const bounds = node.getBoundingClientRect();
return (
bounds.left >= canvasBounds.left - tolerance &&
bounds.top >= canvasBounds.top - tolerance &&
bounds.right <= canvasBounds.right + tolerance &&
bounds.bottom <= canvasBounds.bottom + tolerance
);
});
},
{ selector: canvasSelector, count: expectedCount },
),
{ message: `所有节点应完整显示在 ${canvasSelector} 内` },
)
.toBe(true);
}
@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test";
import { prepareApp } from "./fixtures";
test.beforeEach(async ({ page }) => {
await prepareApp(page);
});
test("未知图形元件使用带端口和名称的默认节点,并将动作结果写入控制台", async ({
page,
}) => {
await page.goto("/");
const paletteItem = page.getByRole("button", { name: /通用测试元件/ });
await expect(paletteItem).toBeVisible();
await paletteItem.dragTo(page.locator(".flow-canvas .react-flow__pane"));
const node = page.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]');
await expect(node).toBeVisible();
await expect(node.locator(".sim-node")).toHaveClass(/generic-component/);
await expect(node.locator(".node-symbol-anchor")).toHaveCount(0);
await expect(node.locator(".node-label")).toHaveText("generic_sensor_1");
await expect(node.locator(".port-handle")).toHaveCount(2);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toHaveClass(/has-unread/);
await expect(consolePanel.locator(".simulation-console-unread")).toHaveCount(0);
await expect(consolePanel).not.toContainText("已添加组件");
await page.getByRole("button", { name: "生成系统 XML" }).click();
await expect(consolePanel).toContainText("XML 已生成");
const xmlBlock = consolePanel.getByLabel("生成的系统 XML");
await expect(xmlBlock).toBeVisible();
await expect(xmlBlock).toContainText('<?xml version="1.0" encoding="UTF-8"?>');
await expect(xmlBlock).toContainText('<Component id="generic_sensor_1"');
await expect(page.locator(".properties .xml-output")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("系统 XML");
await page.getByRole("button", { name: "检查模型" }).click();
await expect(consolePanel).toContainText(/模型检查/);
await expect(page.locator(".status-strip")).toHaveCount(0);
await expect(page.locator(".validation-section")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("仿真结果");
});
+304
View File
@@ -0,0 +1,304 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://systemsimulation.local/schemas/component-catalog-v1.schema.json",
"title": "SystemSimulation Component Catalog v1",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"libraries"
],
"properties": {
"schemaVersion": {
"const": 1
},
"libraries": {
"type": "array",
"items": {
"$ref": "#/$defs/library"
}
}
},
"$defs": {
"machineId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
},
"category": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"label",
"order"
],
"properties": {
"id": {
"$ref": "#/$defs/machineId"
},
"label": {
"type": "string",
"minLength": 1
},
"order": {
"type": "integer"
}
}
},
"portVariable": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"role",
"connectionRule",
"label",
"quantity",
"unit",
"resultVisible",
"order"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
},
"role": {
"enum": [
"effort",
"flow",
"stream",
"signal"
]
},
"connectionRule": {
"enum": [
"equal",
"sumToZero",
"streamMix",
"directed"
]
},
"label": {
"type": "string",
"minLength": 1
},
"quantity": {
"type": "string",
"minLength": 1
},
"unit": {
"type": "string"
},
"resultVisible": {
"type": "boolean"
},
"order": {
"type": "integer"
}
}
},
"port": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"kind",
"domain",
"nominalRole",
"positiveFlowDirection",
"variables",
"side",
"order"
],
"properties": {
"name": {
"$ref": "#/$defs/machineId"
},
"kind": {
"enum": [
"physical",
"signal"
]
},
"domain": {
"$ref": "#/$defs/machineId"
},
"nominalRole": {
"enum": [
"inlet",
"outlet",
"bidirectional",
"input",
"output"
]
},
"positiveFlowDirection": {
"enum": [
"intoComponent",
null
]
},
"variables": {
"type": "array",
"items": {
"$ref": "#/$defs/portVariable"
}
},
"side": {
"enum": [
"left",
"right"
]
},
"order": {
"type": "integer"
}
}
},
"parameter": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"label",
"quantity",
"unit",
"default",
"minimumExclusive"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
},
"label": {
"type": "string",
"minLength": 1
},
"quantity": {
"type": "string",
"minLength": 1
},
"unit": {
"type": "string"
},
"default": {
"type": "number"
},
"minimum": {
"type": "number"
},
"maximum": {
"type": "number"
},
"minimumExclusive": {
"type": "boolean"
}
}
},
"component": {
"type": "object",
"additionalProperties": false,
"required": [
"type",
"modelType",
"modelVersion",
"label",
"symbol",
"order",
"category",
"ports",
"parameters"
],
"properties": {
"type": {
"$ref": "#/$defs/machineId"
},
"modelType": {
"$ref": "#/$defs/machineId"
},
"modelVersion": {
"$ref": "#/$defs/version"
},
"label": {
"type": "string",
"minLength": 1
},
"symbol": {
"$ref": "#/$defs/machineId"
},
"order": {
"type": "integer"
},
"category": {
"$ref": "#/$defs/category"
},
"ports": {
"type": "array",
"items": {
"$ref": "#/$defs/port"
}
},
"parameters": {
"type": "array",
"items": {
"$ref": "#/$defs/parameter"
}
}
}
},
"library": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"label",
"version",
"sourcePackage",
"temporary",
"order",
"categories",
"components"
],
"properties": {
"id": {
"$ref": "#/$defs/machineId"
},
"label": {
"type": "string",
"minLength": 1
},
"version": {
"$ref": "#/$defs/version"
},
"sourcePackage": {
"type": "string",
"minLength": 1
},
"temporary": {
"type": "boolean"
},
"order": {
"type": "integer"
},
"categories": {
"type": "array",
"items": {
"$ref": "#/$defs/category"
}
},
"components": {
"type": "array",
"items": {
"$ref": "#/$defs/component"
}
}
}
}
}
}
File renamed without changes.
Internal Server Error - 奔熠云-Git
500 Internal Server Error

An error occurred:

An error occurred

Gitea Version: 28.0.0