规范仿真模型库并完善前端交互
归档仿真模型并补充组件目录、建模规范与校验。 完善控制台、默认节点、视图适配及前端自动化测试。
This commit is contained in:
1 parent
f7f1078911
commit
bbc88a6bbb
71 files changed
+4555
-787
No files matched your search
+3
-1
@@ -12,11 +12,13 @@ htmlcov/
|
||||
# Local virtual environments
|
||||
.venv/
|
||||
.venv-win/
|
||||
PythonModels/runs/
|
||||
app/data/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
frontend/blob-report/
|
||||
venv/
|
||||
env/
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Python port scaffold for the Modelica-based pressurization system."""
|
||||
|
||||
@@ -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
|
||||
@@ -1,15 +1,16 @@
|
||||
# SystemSimulationApp
|
||||
|
||||
ReactFlow 系统建模与 PythonModels 仿真应用。
|
||||
ReactFlow 系统建模与 `app.simulation` 仿真后端。
|
||||
|
||||
## 后端接口
|
||||
|
||||
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
|
||||
- `POST /api/reactflow/system-xml`:导出 System XML v2。
|
||||
- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为 PythonModels 网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
|
||||
- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
|
||||
- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
|
||||
- `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。
|
||||
- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。
|
||||
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 PythonModels 组件网络。
|
||||
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。
|
||||
- `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。
|
||||
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
|
||||
|
||||
@@ -25,6 +26,10 @@ XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境
|
||||
|
||||
## 文档
|
||||
|
||||
- [开发文档索引](docs/README.md)
|
||||
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md)
|
||||
- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md)
|
||||
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
|
||||
- [System XML v2 协议](docs/system-xml-v2.md)
|
||||
- [System XML v2 XSD](schemas/system-simulation-v2.xsd)
|
||||
- [System XML v1 协议(旧版)](docs/system-xml-v1.md)
|
||||
|
||||
+19
-12
@@ -28,8 +28,8 @@ from app.system_xml import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PythonModels.core.network import SimulationNetwork
|
||||
from PythonModels.core.ports import PortDefinition
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
app = FastAPI(title="System Simulation ReactFlow App")
|
||||
@@ -231,6 +231,13 @@ def frontend_asset(path: str) -> FileResponse:
|
||||
return FileResponse(asset_path)
|
||||
|
||||
|
||||
@app.get("/api/components/catalog")
|
||||
def get_component_catalog() -> dict[str, object]:
|
||||
from app.simulation.registry import build_component_catalog
|
||||
|
||||
return build_component_catalog()
|
||||
|
||||
|
||||
@app.post("/api/reactflow/system-xml")
|
||||
def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
|
||||
try:
|
||||
@@ -572,10 +579,10 @@ def run_system_xml_simulation(
|
||||
progress_callback: SimulationProgressEmitter | None = None,
|
||||
cancel_check: Callable[[], bool] | None = None,
|
||||
) -> dict[str, object]:
|
||||
from PythonModels.core.algebraic import AlgebraicSolveError
|
||||
from PythonModels.core.solver import SolveIVPConfig
|
||||
from PythonModels.core.stream import StreamSolveError
|
||||
from PythonModels.systems.generic import (
|
||||
from app.simulation.solvers.algebraic import AlgebraicSolveError
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
from app.simulation.solvers.stream import StreamSolveError
|
||||
from app.simulation.systems.generic import (
|
||||
GenericFluidSystem,
|
||||
SimulationPreparationError,
|
||||
)
|
||||
@@ -1071,9 +1078,9 @@ def validate_compatible_ports(
|
||||
|
||||
|
||||
def compile_reactflow_network(project: ReactFlowProjectPayload) -> "SimulationNetwork":
|
||||
from PythonModels.core.medium import IdealGasMedium
|
||||
from PythonModels.core.network import SimulationNetwork
|
||||
from PythonModels.registry import get_component_model_spec
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.registry import get_component_model_spec
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
medium = IdealGasMedium()
|
||||
network = SimulationNetwork(name=project.name)
|
||||
@@ -1156,14 +1163,13 @@ def sanitize_project_id(project_id: str) -> str:
|
||||
|
||||
|
||||
def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, object]:
|
||||
from PythonModels.core.solver import SolveIVPConfig
|
||||
from PythonModels.scripts.run_testmodel import (
|
||||
from app.simulation.examples.testmodel.run import (
|
||||
TestModelExecutionConfig,
|
||||
TestModelRunConfig,
|
||||
TestModelSamplingConfig,
|
||||
run_testmodel,
|
||||
)
|
||||
from PythonModels.systems.testmodel import (
|
||||
from app.simulation.examples.testmodel.system import (
|
||||
BranchConfig,
|
||||
CylinderConfig,
|
||||
OrificeConfig,
|
||||
@@ -1171,6 +1177,7 @@ def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, objec
|
||||
TankConfig,
|
||||
TestModelConfig,
|
||||
)
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
|
||||
nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {}
|
||||
for node in project.nodes:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PythonModels
|
||||
# 仿真后端
|
||||
|
||||
`PythonModels` 用于承接 `ModelicaModels` 的 Python 平台移植。
|
||||
`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
|
||||
|
||||
目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。
|
||||
|
||||
@@ -8,35 +8,44 @@
|
||||
|
||||
## 当前目录
|
||||
|
||||
- `core/`: 通用基础设施
|
||||
包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。
|
||||
- `components/`: 元件级 Python 实现
|
||||
目前有 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee` 五类元件。
|
||||
新增或修改元件时先阅读 `components/example.md` 中的建模规范与完整示例。
|
||||
- `systems/`: 系统级装配与闭合
|
||||
当前只有 `TestModelSystem`,对应 `ModelicaModels/Testmodel.mo`。
|
||||
- `reporting/`: 结果导出与对比
|
||||
当前承接主变量 CSV、温度 CSV/SVG、Python 对 OpenModelica 的对比表与误差摘要导出。
|
||||
- `scripts/`: 运行脚本
|
||||
当前入口是 `run_testmodel.py`。
|
||||
- `baselines/`: 提交进仓库的稳定基线
|
||||
当前承接 Python 主变量基线和 Python 对 Modelica 的误差摘要基线。
|
||||
- `runs/`: 每次实际运行的默认输出目录
|
||||
当前脚本默认会在这里创建带时间戳的子目录,用来放这次运行生成的产物。
|
||||
- `core/`: 元件基类、端口、状态、介质、方程和元数据协议。
|
||||
- `solvers/`: ODE、压力流量代数方程和 stream 求解。
|
||||
- `components/experimental/`: 用于验证元件开发规范的临时组件库。
|
||||
- `components/experimental/storage/`: 气瓶和贮箱等储能元件。
|
||||
- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。
|
||||
- `components/experimental/junctions/`: 三通等连接节点。
|
||||
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
|
||||
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和运行入口。
|
||||
- `reporting/`: CSV、SVG、运行报告和 Modelica 对比结果导出。
|
||||
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
|
||||
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
|
||||
|
||||
稳定基准存放在 `tests/baselines/simulation/`,实际运行产物默认写入被 Git 忽略的
|
||||
`app/data/simulation-runs/`。新增或修改元件时,先阅读 `components/example.md`。
|
||||
需要把运行产物写到仓库外时,可以设置 `SIMULATIONAPP_DATA_DIR` 环境变量。
|
||||
|
||||
FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow
|
||||
启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。
|
||||
|
||||
临时组件库的声明入口是 `components/experimental/library.py`。公开模型必须在
|
||||
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
|
||||
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
|
||||
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
|
||||
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。
|
||||
|
||||
当前关键文件:
|
||||
|
||||
- `core/medium.py`: 理想气体近似介质 `IdealGasMedium`
|
||||
- `core/medium.py`: 温度相关的空气近似介质 `IdealGasMedium`
|
||||
- `core/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
||||
- `core/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
||||
- `components/pipe.py`: 单阻容管道近似,入口压降 + 出口直连内容腔
|
||||
- `components/tee.py`: 三通的最小 stream 混合 helper
|
||||
- `systems/testmodel.py`: `Testmodel` 的系统装配壳与外部运行入口
|
||||
- `systems/testmodel_closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
|
||||
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
||||
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
||||
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
||||
- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
|
||||
- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
|
||||
- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
|
||||
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
|
||||
- `scripts/run_testmodel.py`: 基线运行与程序化执行入口
|
||||
- `tests/test_pythonmodels_regression.py`: 当前 Python 基线回归测试
|
||||
- `examples/testmodel/run.py`: 基线运行与程序化执行入口
|
||||
- `tests/`: 当前组件契约、XML、通用系统和结果导出测试
|
||||
|
||||
## 当前阶段进度
|
||||
|
||||
@@ -74,7 +83,7 @@
|
||||
本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态:
|
||||
|
||||
1. `M2`:已完成当前阶段首版
|
||||
- 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `systems/testmodel.py` 拆到新的 `systems/testmodel_closure.py`
|
||||
- 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `examples/testmodel/system.py` 拆到 `examples/testmodel/closure.py`
|
||||
- `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节
|
||||
|
||||
2. `M3`:已完成当前阶段首版
|
||||
@@ -179,19 +188,18 @@
|
||||
最小运行方式:
|
||||
|
||||
```bash
|
||||
python3 -m PythonModels.scripts.run_testmodel
|
||||
python -m app.simulation.examples.testmodel.run
|
||||
```
|
||||
|
||||
如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如:
|
||||
|
||||
```python
|
||||
from PythonModels.core.solver import SolveIVPConfig
|
||||
from PythonModels.scripts.run_testmodel import (
|
||||
from app.simulation.examples.testmodel.run import (
|
||||
TestModelRunConfig,
|
||||
TestModelSamplingConfig,
|
||||
run_testmodel,
|
||||
)
|
||||
from PythonModels.systems.testmodel import (
|
||||
from app.simulation.examples.testmodel.system import (
|
||||
BranchConfig,
|
||||
CylinderConfig,
|
||||
OrificeConfig,
|
||||
@@ -199,6 +207,7 @@ from PythonModels.systems.testmodel import (
|
||||
TankConfig,
|
||||
TestModelConfig,
|
||||
)
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
|
||||
run_config = TestModelRunConfig(
|
||||
model=TestModelConfig(
|
||||
@@ -219,7 +228,7 @@ result = run_testmodel(run_config=run_config)
|
||||
如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行:
|
||||
|
||||
```python
|
||||
from PythonModels.scripts.run_testmodel import (
|
||||
from app.simulation.examples.testmodel.run import (
|
||||
prepare_testmodel_run,
|
||||
run_prepared_testmodel,
|
||||
TestModelRunConfig,
|
||||
@@ -239,12 +248,13 @@ print(result.used_modelica_reference)
|
||||
1. 构建 `TestModelSystem`
|
||||
2. 打印原始初值向量与约束一致后的初值向量
|
||||
3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s`
|
||||
4. 将结果写入 `PythonModels/runs/` 下本次运行专属的时间戳目录
|
||||
4. 将结果写入 `app/data/simulation-runs/` 下本次运行专属的时间戳目录
|
||||
5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果
|
||||
|
||||
当前脚本默认不会再把运行结果直接写到提交基线目录,而是会在 `PythonModels/runs/` 下创建一个带时间戳的子目录,例如:
|
||||
当前脚本默认不会把运行结果直接写到提交基线目录,而是会在
|
||||
`app/data/simulation-runs/` 下创建一个带时间戳的子目录,例如:
|
||||
|
||||
- `PythonModels/runs/testmodel_20260512_103000_123456/`
|
||||
- `app/data/simulation-runs/testmodel_20260512_103000_123456/`
|
||||
|
||||
该目录里通常会包含:
|
||||
|
||||
@@ -258,7 +268,7 @@ print(result.used_modelica_reference)
|
||||
## 基线结果
|
||||
|
||||
当前基线对比摘要来自:
|
||||
[testmodel_modelica_comparison_summary.txt](/home/lujz/projects/pressurization-transfer-system/PythonModels/baselines/testmodel/testmodel_modelica_comparison_summary.txt)
|
||||
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt)
|
||||
|
||||
当前四个主变量的最大误差为:
|
||||
|
||||
@@ -319,24 +329,24 @@ print(result.used_modelica_reference)
|
||||
`PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。
|
||||
- `core/state.py`: 正常
|
||||
`VolumeState` 只负责 `[m, U]` 状态打包。
|
||||
- `core/network.py`: 正常
|
||||
- `systems/network.py`: 正常
|
||||
负责状态向量拼装和连接摘要,不参与物理求解。
|
||||
- `core/solver.py`: 正常
|
||||
- `solvers/solver.py`: 正常
|
||||
已支持 SciPy、RK4 回退和零时长仿真。
|
||||
- `components/*.py`: 正常
|
||||
- `components/experimental/**/*.py`: 正常
|
||||
都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。
|
||||
- `systems/testmodel.py`: 是当前最重要的技术债集中区
|
||||
- `examples/testmodel/system.py`: 是当前最重要的技术债集中区
|
||||
这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。
|
||||
- `scripts/run_testmodel.py`: 正常
|
||||
- `examples/testmodel/run.py`: 正常
|
||||
已不是“最小打印脚本”,而是当前结果导出和对比入口。
|
||||
- `baselines/`: 是当前稳定基线,不应该随着日常运行频繁改动。
|
||||
- `runs/`: 是当前默认运行产物目录,不是手写源代码,也不应该当作提交基线使用。
|
||||
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
|
||||
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
|
||||
|
||||
## 当前主技术债
|
||||
|
||||
目前最主要的技术债,可以直接理解成下面 4 件事:
|
||||
|
||||
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
|
||||
2. `systems/testmodel.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
|
||||
2. `examples/testmodel/system.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
|
||||
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
|
||||
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
|
||||
@@ -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
|
||||
MODEL_TYPE = "example_component"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (...)
|
||||
PARAMETERS = (...)
|
||||
RESULT_VARIABLES = (...)
|
||||
DISPLAY = ...
|
||||
```
|
||||
|
||||
- `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。
|
||||
- `MODEL_VERSION`:模型契约版本,采用 `主版本.次版本.修订版本`。
|
||||
- `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。
|
||||
- `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。
|
||||
- `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。
|
||||
- `DISPLAY`:组件库名称、分类、图标、排序和端口画布位置,不参与物理求解。
|
||||
|
||||
元件构造函数还必须:
|
||||
|
||||
@@ -24,6 +38,7 @@ RESULT_VARIABLES = (...)
|
||||
2. 使用 `set_parameter_values()` 保存规范化后的输入参数。
|
||||
3. 使用 `register_declared_port()` 创建已声明端口。
|
||||
4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。
|
||||
5. 实现统一的类方法 `create()`,接收规范化后的 SI 参数。
|
||||
|
||||
## 二、输入参数与结果变量
|
||||
|
||||
@@ -79,19 +94,21 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from PythonModels.core.base import ThermodynamicVolumeComponent
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.core.metadata import (
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from PythonModels.core.ports import PortDefinition
|
||||
from PythonModels.core.state import VolumeState
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
class ExampleVolume(ThermodynamicVolumeComponent):
|
||||
MODEL_TYPE = "example_volume"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
|
||||
)
|
||||
@@ -125,6 +142,14 @@ class ExampleVolume(ThermodynamicVolumeComponent):
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="示例容腔",
|
||||
library_id="experimental",
|
||||
category_id="storage",
|
||||
symbol="generic",
|
||||
ports=(PortDisplaySpec("port_a", "left"),),
|
||||
order=90,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -145,6 +170,22 @@ class ExampleVolume(ThermodynamicVolumeComponent):
|
||||
self.state = VolumeState(m=initial_mass, U=initial_energy)
|
||||
self.port_a = self.register_declared_port("port_a")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> ExampleVolume:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
volume=parameters["volume"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return self.state.as_vector()
|
||||
|
||||
@@ -190,27 +231,22 @@ class ExampleVolume(ThermodynamicVolumeComponent):
|
||||
)
|
||||
```
|
||||
|
||||
注册时只引用元件类已经声明的契约,不要再复制参数和端口定义:
|
||||
模型文件不再直接修改全局注册表。完成模型类后,只把类路径加入所属库
|
||||
`library.py` 的 `models` 清单:
|
||||
|
||||
```python
|
||||
def _example_volume_factory(name, medium, values):
|
||||
return ExampleVolume(
|
||||
name=name,
|
||||
medium=medium,
|
||||
volume=values["volume"],
|
||||
p0=values["p0"],
|
||||
T0=values["T0"],
|
||||
)
|
||||
|
||||
|
||||
COMPONENT_MODEL_REGISTRY[ExampleVolume.MODEL_TYPE] = ComponentModelSpec(
|
||||
model_type=ExampleVolume.MODEL_TYPE,
|
||||
ports=ExampleVolume.PORTS,
|
||||
parameters=ExampleVolume.PARAMETERS,
|
||||
factory=_example_volume_factory,
|
||||
models=(
|
||||
# ...已有模型
|
||||
"app.simulation.components.experimental.storage.example_volume:ExampleVolume",
|
||||
)
|
||||
```
|
||||
|
||||
后端会受控导入清单中的类,校验版本、分类、端口、参数、单位、显示信息和默认实例,
|
||||
再自动建立注册表。校验通过后,`GET /api/components/catalog` 会输出该元件,
|
||||
前端刷新时即可加载。
|
||||
当前 `experimental` 仅用于规范验证;正式模型应先建立新的库声明,再把
|
||||
`library_id` 指向正式库。
|
||||
|
||||
完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义:
|
||||
|
||||
```json
|
||||
@@ -231,10 +267,16 @@ COMPONENT_MODEL_REGISTRY[ExampleVolume.MODEL_TYPE] = ComponentModelSpec(
|
||||
|
||||
## 五、新增元件检查清单
|
||||
|
||||
1. `MODEL_TYPE` 是否唯一,并与 XML、前端组件类型一致。
|
||||
1. `MODEL_TYPE` 是否唯一,并与 XML 的模型类型一致。
|
||||
2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。
|
||||
3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。
|
||||
4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。
|
||||
5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。
|
||||
6. 是否只暴露有工程意义的结果,而非内部计算变量。
|
||||
7. 是否补充参数边界、端口契约、结果元数据和最小仿真的自动测试。
|
||||
7. `MODEL_VERSION` 和 `DISPLAY` 是否完整,显示端口是否与物理端口完全一致。
|
||||
8. 是否实现统一的 `create()`,并能用默认参数创建模型。
|
||||
9. 模型类路径是否只加入所属库的 `library.py` 清单。
|
||||
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
|
||||
|
||||
组件库、分类和自动发现的完整规则参见
|
||||
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
|
||||
@@ -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."""
|
||||
+32
-4
@@ -3,16 +3,19 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from math import sqrt
|
||||
|
||||
from PythonModels.core.base import AlgebraicComponent
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.core.metadata import ParameterDefinition
|
||||
from PythonModels.core.ports import PortDefinition
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import ParameterDefinition
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
|
||||
|
||||
class Orifice(AlgebraicComponent):
|
||||
"""Python port of ModelicaModels.Myorifice."""
|
||||
|
||||
MODEL_TYPE = "orifice"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||
@@ -35,6 +38,17 @@ class Orifice(AlgebraicComponent):
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="孔板/阀门",
|
||||
library_id="experimental",
|
||||
category_id="flow",
|
||||
symbol="orifice",
|
||||
ports=(
|
||||
PortDisplaySpec("port_a", "left", order=10),
|
||||
PortDisplaySpec("port_b", "right", order=20),
|
||||
),
|
||||
order=40,
|
||||
)
|
||||
|
||||
def __init__(self, name: str, opening: float = 1.0, K: float = 1e-5) -> None:
|
||||
super().__init__(name=name)
|
||||
@@ -44,6 +58,20 @@ class Orifice(AlgebraicComponent):
|
||||
self.port_a = self.register_declared_port("port_a")
|
||||
self.port_b = self.register_declared_port("port_b")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Orifice:
|
||||
return cls(
|
||||
name=name,
|
||||
opening=parameters["opening"],
|
||||
K=parameters["K"],
|
||||
)
|
||||
|
||||
@property
|
||||
def K_eff(self) -> float:
|
||||
return self.K * max(self.opening, 0.001)
|
||||
@@ -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",)
|
||||
+36
-5
@@ -3,17 +3,19 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from math import pi
|
||||
|
||||
from PythonModels.core.base import AlgebraicComponent
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.core.metadata import ParameterDefinition
|
||||
from PythonModels.core.medium import IdealGasMedium
|
||||
from PythonModels.core.ports import PortDefinition
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import ParameterDefinition
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
|
||||
|
||||
class ResistivePipe(AlgebraicComponent):
|
||||
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
|
||||
|
||||
MODEL_TYPE = "pipe"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||
@@ -63,6 +65,17 @@ class ResistivePipe(AlgebraicComponent):
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="管段",
|
||||
library_id="experimental",
|
||||
category_id="flow",
|
||||
symbol="pipe",
|
||||
ports=(
|
||||
PortDisplaySpec("port_a", "left", order=10),
|
||||
PortDisplaySpec("port_b", "right", order=20),
|
||||
),
|
||||
order=30,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -101,6 +114,24 @@ class ResistivePipe(AlgebraicComponent):
|
||||
self.port_b.p = p0
|
||||
self.port_b.h_outflow = initial_h
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> ResistivePipe:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
L=parameters["length"],
|
||||
D=parameters["diameter"],
|
||||
lambda_darcy=parameters["lambda_darcy"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def pressure_drop(self, m_flow_a: float, p_a: float, p_b: float) -> float:
|
||||
average_pressure = max(0.5 * (p_a + p_b), 1.0)
|
||||
density = max(self.medium.density(average_pressure, self.T0), 1e-12)
|
||||
@@ -0,0 +1 @@
|
||||
"""Flow junction components."""
|
||||
+28
-3
@@ -2,15 +2,18 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from PythonModels.core.base import AlgebraicComponent
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.core.ports import PortDefinition
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
|
||||
|
||||
class Tee(AlgebraicComponent):
|
||||
"""Python port of ModelicaModels.Mytee."""
|
||||
|
||||
MODEL_TYPE = "tee"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
||||
@@ -18,6 +21,18 @@ class Tee(AlgebraicComponent):
|
||||
)
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="三通",
|
||||
library_id="experimental",
|
||||
category_id="junctions",
|
||||
symbol="tee",
|
||||
ports=(
|
||||
PortDisplaySpec("port_in", "left", order=10),
|
||||
PortDisplaySpec("port_out1", "right", order=20),
|
||||
PortDisplaySpec("port_out2", "right", order=30),
|
||||
),
|
||||
order=50,
|
||||
)
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name=name)
|
||||
@@ -26,6 +41,16 @@ class Tee(AlgebraicComponent):
|
||||
self.port_out1 = self.register_declared_port("port_out1")
|
||||
self.port_out2 = self.register_declared_port("port_out2")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Tee:
|
||||
return cls(name=name)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
@@ -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."""
|
||||
+32
-6
@@ -2,21 +2,23 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from PythonModels.core.base import ThermodynamicVolumeComponent
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.core.metadata import (
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from PythonModels.core.ports import PortDefinition
|
||||
from PythonModels.core.state import VolumeState
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
class Cylinder(ThermodynamicVolumeComponent):
|
||||
"""Python port of ModelicaModels.Mycylinder."""
|
||||
|
||||
MODEL_TYPE = "cylinder"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
@@ -48,6 +50,14 @@ class Cylinder(ThermodynamicVolumeComponent):
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="气瓶",
|
||||
library_id="experimental",
|
||||
category_id="storage",
|
||||
symbol="cylinder",
|
||||
ports=(PortDisplaySpec("port_b", "right"),),
|
||||
order=10,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -66,6 +76,22 @@ class Cylinder(ThermodynamicVolumeComponent):
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
self.port_b = self.register_declared_port("port_b")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Cylinder:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
V=parameters["volume"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return self.state.as_vector()
|
||||
|
||||
+32
-6
@@ -2,21 +2,23 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from PythonModels.core.base import ThermodynamicVolumeComponent
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.core.metadata import (
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from PythonModels.core.ports import PortDefinition
|
||||
from PythonModels.core.state import VolumeState
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
class Tank(ThermodynamicVolumeComponent):
|
||||
"""Python port of ModelicaModels.Mytank."""
|
||||
|
||||
MODEL_TYPE = "tank"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
@@ -48,6 +50,14 @@ class Tank(ThermodynamicVolumeComponent):
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="贮箱",
|
||||
library_id="experimental",
|
||||
category_id="storage",
|
||||
symbol="tank",
|
||||
ports=(PortDisplaySpec("port_a", "left"),),
|
||||
order=20,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -66,6 +76,22 @@ class Tank(ThermodynamicVolumeComponent):
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
self.port_a = self.register_declared_port("port_a")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Tank:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
V=parameters["volume"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return self.state.as_vector()
|
||||
|
||||
File renamed without changes.
@@ -2,23 +2,29 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, ClassVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.core.metadata import (
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
ResultVariableDefinition,
|
||||
ResultVariableMetadata,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from PythonModels.core.ports import PortDefinition, PortState
|
||||
from app.simulation.core.ports import PortDefinition, PortState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
|
||||
|
||||
class Component(ABC):
|
||||
MODEL_TYPE: ClassVar[str | None] = None
|
||||
MODEL_VERSION: ClassVar[str | None] = None
|
||||
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
||||
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
||||
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
||||
DISPLAY: ClassVar[ComponentDisplaySpec | None] = None
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
@@ -174,6 +180,20 @@ class Component(ABC):
|
||||
for definition in self.PARAMETERS
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Component:
|
||||
"""Create a catalog model from normalized SI parameters."""
|
||||
|
||||
raise NotImplementedError(
|
||||
f"Component model {cls.__name__} must implement create()."
|
||||
)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
"""Return algebraic residuals after the network assigns port states."""
|
||||
|
||||
@@ -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 typing import Literal
|
||||
|
||||
from PythonModels.core.ports import VariableRole
|
||||
from app.simulation.core.ports import VariableRole
|
||||
|
||||
|
||||
EquationOwner = Literal["connection", "component"]
|
||||
File renamed without changes.
@@ -8,6 +8,22 @@ from typing import Literal
|
||||
ResultVariableScope = Literal["component", "port"]
|
||||
|
||||
|
||||
SI_UNIT_BY_QUANTITY: dict[str, str] = {
|
||||
"dimensionless": "",
|
||||
"density": "kg/m³",
|
||||
"flow_coefficient": "kg/(s*Pa^0.5)",
|
||||
"internal_energy": "J",
|
||||
"length": "m",
|
||||
"mass": "kg",
|
||||
"mass_flow": "kg/s",
|
||||
"pressure": "Pa",
|
||||
"specific_enthalpy": "J/kg",
|
||||
"specific_internal_energy": "J/kg",
|
||||
"temperature": "K",
|
||||
"volume": "m3",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterDefinition:
|
||||
"""User-configurable model input expressed in the backend SI contract."""
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1 @@
|
||||
"""Reference systems and regression examples."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Legacy TestModel reference system."""
|
||||
+7
-7
@@ -3,13 +3,13 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from PythonModels.components.cylinder import Cylinder
|
||||
from PythonModels.components.orifice import Orifice
|
||||
from PythonModels.components.pipe import Pipe
|
||||
from PythonModels.components.tank import Tank
|
||||
from PythonModels.components.tee import Tee
|
||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from PythonModels.core.state import VolumeState
|
||||
from app.simulation.components.experimental.flow.orifice import Orifice
|
||||
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
|
||||
from app.simulation.components.experimental.junctions.tee import Tee
|
||||
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||
from app.simulation.components.experimental.storage.tank import Tank
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
+26
-7
@@ -2,21 +2,22 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from PythonModels.core.base import ThermodynamicVolumeComponent
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.core.metadata import (
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from PythonModels.core.ports import PortDefinition
|
||||
from PythonModels.core.state import VolumeState
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
class Pipe(ThermodynamicVolumeComponent):
|
||||
"""Python port of ModelicaModels.Mypipe."""
|
||||
"""Dynamic pipe retained for the fixed TestModel compatibility example."""
|
||||
|
||||
MODEL_TYPE = "pipe"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||
@@ -99,6 +100,24 @@ class Pipe(ThermodynamicVolumeComponent):
|
||||
self.port_a = self.register_declared_port("port_a")
|
||||
self.port_b = self.register_declared_port("port_b")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Pipe:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
L=parameters["length"],
|
||||
D=parameters["diameter"],
|
||||
lambda_darcy=parameters["lambda_darcy"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return self.state.as_vector()
|
||||
|
||||
@@ -4,7 +4,18 @@ from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.reporting import (
|
||||
from app.simulation.examples.testmodel.closure import TestModelSolveDiagnostics
|
||||
from app.simulation.examples.testmodel.system import (
|
||||
InitializationDiagnostics,
|
||||
TestModelConfig,
|
||||
TestModelSystem,
|
||||
)
|
||||
from app.simulation.paths import (
|
||||
MODELICA_TESTMODEL_RESULT_PATH,
|
||||
PROJECT_ROOT,
|
||||
SIMULATION_RUNS_DIR,
|
||||
)
|
||||
from app.simulation.reporting import (
|
||||
COMPARISON_KEYS,
|
||||
PRIMARY_KEYS,
|
||||
TestModelArtifacts,
|
||||
@@ -13,13 +24,7 @@ from PythonModels.reporting import (
|
||||
load_modelica_series,
|
||||
write_testmodel_run_report,
|
||||
)
|
||||
from PythonModels.core.solver import SolveIVPConfig
|
||||
from PythonModels.systems.testmodel import (
|
||||
InitializationDiagnostics,
|
||||
TestModelConfig,
|
||||
TestModelSystem,
|
||||
)
|
||||
from PythonModels.systems.testmodel_closure import TestModelSolveDiagnostics
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -90,9 +95,9 @@ def _sample_times(t_start: float, t_stop: float, step: float) -> list[float]:
|
||||
return [t_start + index * step for index in range(point_count + 1)]
|
||||
|
||||
|
||||
def _default_run_output_dir(pythonmodels_root: Path) -> Path:
|
||||
def _default_run_output_dir() -> Path:
|
||||
timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f")
|
||||
return pythonmodels_root / "runs" / timestamp
|
||||
return SIMULATION_RUNS_DIR / timestamp
|
||||
|
||||
|
||||
def prepare_testmodel_run(
|
||||
@@ -102,22 +107,20 @@ def prepare_testmodel_run(
|
||||
modelica_result_path: Path | None = None,
|
||||
) -> PreparedTestModelRun:
|
||||
run_config = run_config or TestModelRunConfig()
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
pythonmodels_root = Path(__file__).resolve().parents[1]
|
||||
resolved_output_dir = (
|
||||
output_dir
|
||||
or run_config.paths.output_dir
|
||||
or _default_run_output_dir(pythonmodels_root)
|
||||
or _default_run_output_dir()
|
||||
)
|
||||
resolved_modelica_result_path = (
|
||||
modelica_result_path
|
||||
or run_config.paths.modelica_result_path
|
||||
or repo_root / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
|
||||
or MODELICA_TESTMODEL_RESULT_PATH
|
||||
)
|
||||
t_eval = tuple(run_config.sample_times())
|
||||
return PreparedTestModelRun(
|
||||
run_config=run_config,
|
||||
repo_root=repo_root,
|
||||
repo_root=PROJECT_ROOT,
|
||||
output_dir=resolved_output_dir,
|
||||
modelica_result_path=resolved_modelica_result_path,
|
||||
t_eval=t_eval,
|
||||
@@ -3,21 +3,21 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from PythonModels.components.cylinder import Cylinder
|
||||
from PythonModels.components.orifice import Orifice
|
||||
from PythonModels.components.pipe import Pipe
|
||||
from PythonModels.components.tank import Tank
|
||||
from PythonModels.components.tee import Tee
|
||||
from PythonModels.core.medium import IdealGasMedium
|
||||
from PythonModels.core.network import SimulationNetwork
|
||||
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
|
||||
from PythonModels.systems.testmodel_closure import (
|
||||
from app.simulation.components.experimental.flow.orifice import Orifice
|
||||
from app.simulation.components.experimental.junctions.tee import Tee
|
||||
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||
from app.simulation.components.experimental.storage.tank import Tank
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.examples.testmodel.closure import (
|
||||
BranchClosureComponents,
|
||||
InitializationDiagnostics,
|
||||
TestModelClosure,
|
||||
TestModelClosureComponents,
|
||||
TestModelSnapshot,
|
||||
)
|
||||
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
|
||||
from app.simulation.solvers.solver import SolveIVPConfig, integrate_ode
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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,
|
||||
MODELICA_COMPARISON_COLUMNS,
|
||||
PRIMARY_KEYS,
|
||||
File renamed without changes.
@@ -0,0 +1 @@
|
||||
"""Numerical solvers used by simulation systems."""
|
||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from math import sqrt
|
||||
|
||||
from PythonModels.core.network import SimulationNetwork
|
||||
from PythonModels.core.ports import PortState, VariableRole
|
||||
from app.simulation.core.ports import PortState, VariableRole
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
class AlgebraicSolveError(RuntimeError):
|
||||
File renamed without changes.
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.core.base import DynamicComponent
|
||||
from PythonModels.core.network import Endpoint, SimulationNetwork
|
||||
from app.simulation.core.base import DynamicComponent
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
class StreamSolveError(RuntimeError):
|
||||
File renamed without changes.
@@ -5,12 +5,12 @@ from dataclasses import dataclass
|
||||
from math import floor, isfinite
|
||||
from typing import Literal
|
||||
|
||||
from PythonModels.core.algebraic import PressureFlowSolver
|
||||
from PythonModels.core.base import DynamicComponent
|
||||
from PythonModels.core.metadata import ResultVariableMetadata
|
||||
from PythonModels.core.network import Endpoint, SimulationNetwork
|
||||
from PythonModels.core.solver import ODESolution, SolveIVPConfig, integrate_ode
|
||||
from PythonModels.core.stream import StreamResolver
|
||||
from app.simulation.core.base import DynamicComponent
|
||||
from app.simulation.core.metadata import ResultVariableMetadata
|
||||
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
|
||||
from app.simulation.solvers.stream import StreamResolver
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
SimulationProgressCallback = Callable[[float, str], None]
|
||||
@@ -2,10 +2,10 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.core.base import Component, DynamicComponent
|
||||
from PythonModels.core.equations import EquationResidual
|
||||
from PythonModels.core.metadata import ResultVariableMetadata
|
||||
from PythonModels.core.ports import PortState
|
||||
from app.simulation.core.base import Component, DynamicComponent
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import ResultVariableMetadata
|
||||
from app.simulation.core.ports import PortState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
+2
-2
@@ -9,8 +9,8 @@ from typing import Literal
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from PythonModels.core.ports import PortDefinition
|
||||
from PythonModels.registry import (
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.registry import (
|
||||
COMPONENT_MODEL_REGISTRY,
|
||||
ParameterSpec,
|
||||
)
|
||||
|
||||
@@ -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` 受控登记公开模型。
|
||||
- 不在前端复制后端端口、参数或默认值作为正式来源。
|
||||
- 不覆盖用户已有改动。
|
||||
- 不自行猜测缺失的物理方程。
|
||||
- 修改后运行针对性测试和完整回归,并报告未完成的验证。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,7 +2,7 @@
|
||||
|
||||
> 此版本仅用于识别旧文件。新项目使用 [System XML v2](system-xml-v2.md),物理连接在 v2 中改为无序端点。
|
||||
|
||||
System XML 是 ReactFlow 前端与 PythonModels 仿真层之间的稳定交换格式。
|
||||
System XML 是 ReactFlow 前端与 `app.simulation` 仿真层之间的稳定交换格式。
|
||||
|
||||
## 基本约定
|
||||
|
||||
@@ -10,7 +10,7 @@ System XML 是 ReactFlow 前端与 PythonModels 仿真层之间的稳定交换
|
||||
- `unitSystem` 固定为 `SI`。组件参数必须保存为 SI 基准值,界面显示单位不进入数值换算语义。
|
||||
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
|
||||
- `Component.id` 是稳定的仿真实例 ID;`name` 是用户可编辑的显示名称。
|
||||
- `Component.type` 是 PythonModels 模型类型;`componentType` 是前端组件类型。
|
||||
- `Component.type` 是仿真后端模型类型;`componentType` 是前端组件类型。
|
||||
- `Connection.source/target` 只标识图形拓扑端点,不代表仿真中的实际流动方向。正流和倒流由求解器决定。
|
||||
- v1 文档不携带结果数据;仿真结果通过运行接口返回。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# System XML v2 协议
|
||||
|
||||
System XML v2 是 ReactFlow 建模前端与 PythonModels 仿真层之间的交换格式。v2 将物理端口与信号端口分开,并移除了物理连接中的方向语义。
|
||||
System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的交换格式。v2 将物理端口与信号端口分开,并移除了物理连接中的方向语义。
|
||||
|
||||
## 基本约定
|
||||
|
||||
@@ -56,7 +56,7 @@ System XML v2 是 ReactFlow 建模前端与 PythonModels 仿真层之间的交
|
||||
|
||||
物理端口的流向由求解结果决定。一个名义出口的 `m_flow > 0` 表示该端口发生实际流入,可在结果层标记为倒流。
|
||||
|
||||
三通的三个端口当前保留 PythonModels 已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。
|
||||
三通的三个端口当前保留仿真模型已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。
|
||||
|
||||
## Connection
|
||||
|
||||
@@ -80,11 +80,11 @@ System XML v2 是 ReactFlow 建模前端与 PythonModels 仿真层之间的交
|
||||
|
||||
XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。
|
||||
|
||||
## PythonModels 编译接口
|
||||
## 仿真模型编译接口
|
||||
|
||||
`POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作:
|
||||
|
||||
1. 按 `node.data.modelType` 创建 PythonModels 组件实例,并写入 SI 参数。
|
||||
1. 按 `node.data.modelType` 创建 `app.simulation` 组件实例,并写入 SI 参数。
|
||||
2. 将前端端口声明与组件注册端口逐项比对。
|
||||
3. 按画布实际 `edges` 创建无方向物理连接,而不是按组件类型或拖入顺序推断拓扑。
|
||||
4. 检查端口存在性、物理域兼容性、重复连接和未连接端口。
|
||||
@@ -140,7 +140,7 @@ XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求
|
||||
|
||||
## 第二阶段:XML 解析与校验
|
||||
|
||||
第二阶段已经实现从 System XML v2 回到 PythonModels 网络的完整入口。解析过程固定分为三层:
|
||||
第二阶段已经实现从 System XML v2 回到仿真网络的完整入口。解析过程固定分为三层:
|
||||
|
||||
| 层级 | `layer` | 负责内容 |
|
||||
|---|---|---|
|
||||
@@ -169,7 +169,7 @@ XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求
|
||||
|
||||
- `POST /api/system-xml/validate`:无论成功与否都返回校验报告,便于编辑器实时显示问题。
|
||||
- `POST /api/system-xml/parse`:成功时返回规范化工程 JSON;失败时返回 HTTP `422` 和结构化诊断。
|
||||
- `POST /api/system-xml/compile-model`:成功时返回 PythonModels 网络、仿真设置和校验报告;失败时返回 HTTP `422`。
|
||||
- `POST /api/system-xml/compile-model`:成功时返回仿真网络、仿真设置和校验报告;失败时返回 HTTP `422`。
|
||||
- `POST /api/system-xml/simulate`:完成校验、编译、仿真准备、代数闭合、stream 传播和时间积分;成功时返回组件与端口时间序列,失败时返回 HTTP `422` 和仿真层诊断。
|
||||
|
||||
示例:
|
||||
@@ -182,13 +182,13 @@ Invoke-RestMethod `
|
||||
-InFile .\test\system.xml
|
||||
```
|
||||
|
||||
组件参数和端口定义集中在 `PythonModels/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。
|
||||
组件参数和端口定义集中在 `app/simulation/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。
|
||||
|
||||
## 第三阶段:XML 驱动仿真 MVP
|
||||
|
||||
第三阶段当前已经打通:
|
||||
|
||||
1. XML 中的组件、参数和无方向物理连接编译成 PythonModels 网络。
|
||||
1. XML 中的组件、参数和无方向物理连接编译成 `app.simulation` 网络。
|
||||
2. 仿真准备层检查未连接端口、方程数量、无储能代数孤岛和无阻力储能直连。
|
||||
3. SciPy 非线性最小二乘求解每个时刻的端口压力与质量流量。
|
||||
4. 根据求解后的实际流向迭代传播 `h_outflow`,并在三通处执行质量流量加权混合。
|
||||
|
||||
Generated
+64
@@ -15,6 +15,7 @@
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
@@ -85,6 +86,22 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
||||
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
@@ -1368,6 +1385,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"test": "playwright test",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.11.2",
|
||||
@@ -16,6 +18,7 @@
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
WorkspaceViewTabs,
|
||||
type WorkspaceView,
|
||||
} from "./WorkspaceViewTabs";
|
||||
import { AutoFitView } from "./AutoFitView";
|
||||
|
||||
type ResultPortDefinition = {
|
||||
name: string;
|
||||
@@ -1134,7 +1135,7 @@ export function SimulationResultsView({
|
||||
edgesFocusable={false}
|
||||
edgesReconnectable={false}
|
||||
elementsSelectable
|
||||
fitView
|
||||
minZoom={0.1}
|
||||
nodeTypes={nodeTypes}
|
||||
nodes={readOnlyNodes}
|
||||
nodesConnectable={false}
|
||||
@@ -1146,8 +1147,15 @@ export function SimulationResultsView({
|
||||
selectionOnDrag={false}
|
||||
zoomOnDoubleClick={false}
|
||||
>
|
||||
<AutoFitView
|
||||
enabled={readOnlyNodes.length > 0}
|
||||
expectedNodeCount={readOnlyNodes.length}
|
||||
requestKey={snapshot.id}
|
||||
/>
|
||||
<Background gap={18} />
|
||||
<Controls />
|
||||
<Controls
|
||||
fitViewOptions={{ duration: 180, minZoom: 0.1, padding: 0.18 }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+150
-176
@@ -369,15 +369,6 @@ textarea {
|
||||
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 {
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid #e3e8ef;
|
||||
@@ -385,13 +376,90 @@ textarea {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.palette-list,
|
||||
.palette-list {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.parameter-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
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 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -523,6 +591,34 @@ textarea {
|
||||
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::after {
|
||||
position: absolute;
|
||||
@@ -678,6 +774,22 @@ textarea {
|
||||
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-tank,
|
||||
.symbol-pipe,
|
||||
@@ -842,138 +954,6 @@ textarea {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-rows: 62px minmax(0, 1fr);
|
||||
@@ -2032,11 +2012,6 @@ textarea {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.simulation-console.minimized.has-unread {
|
||||
border-color: #1d6fb8;
|
||||
animation: simulation-console-attention 1.2s ease-out 2;
|
||||
}
|
||||
|
||||
.simulation-console-collapsed {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 34px minmax(0, 1fr);
|
||||
@@ -2082,7 +2057,7 @@ textarea {
|
||||
|
||||
.simulation-console-collapsed-open {
|
||||
display: grid;
|
||||
grid-template-columns: 18px auto minmax(0, 1fr) auto 18px;
|
||||
grid-template-columns: 18px auto minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
@@ -2109,7 +2084,6 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.simulation-console-collapsed-message,
|
||||
.simulation-console-collapsed-status {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -2126,21 +2100,6 @@ textarea {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2361,21 +2320,36 @@ textarea {
|
||||
color: #a9322d;
|
||||
}
|
||||
|
||||
@keyframes simulation-console-attention {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.2), 0 10px 28px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
.simulation-console-entry-message {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.simulation-console-entry.xml {
|
||||
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) {
|
||||
.simulation-console.minimized.has-unread {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.simulation-console-progress-track span {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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("仿真结果");
|
||||
});
|
||||
@@ -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.
File renamed without changes.
|
500 Internal Server Error
An error occurred: An error occurred Gitea Version: 28.0.0 |