Files
SystemSimulationApp/docs/standard/component-model-authoring-spec-v1.md
T

23 KiB
Raw Blame History

组件模型建模规范 v1

状态:已在 experimental 临时组件库实施
适用对象:人工开发者、代码生成工具和 AI 编程助手
配套读取规范:组件库分类、发现与读取规范 v1

1. 文档目标

本文档规定一个 Python 仿真元件应如何创建、修改、测试和注册。完成后的模型必须 同时满足四个使用方:

  1. 求解器能够实例化模型并调用方程。
  2. System XML 能够根据稳定类型找到模型。
  3. React Flow 能够自动显示图标、端口和参数。
  4. 结果页面能够根据结构化元数据展示变量。

本文档是模型代码的开发合同。若本文档与当前代码行为不一致,应把它视为缺陷: 先核对实际实现,再在同一次修改中同步代码、测试和文档,禁止让两套规则长期并存。

2. 开始前先判断任务类型

2.1 新增公开模型

公开模型会出现在前端组件库中,也能被 System XML 创建。必须:

  • 放入某个组件库的分类目录。
  • 实现完整模型契约。
  • 加入该库 library.py 的 models 清单。
  • 添加目录、契约、方程和最小仿真测试。

2.2 修改已有公开模型

必须先判断改动是否破坏已有工程:

改动 版本建议 兼容性要求
修复数值实现但不改变契约 修订版本 若提高 modelVersion,既有 XML 会因精确版本不匹配而被拒绝;需明确是否真的变更合同
新增有默认值的参数或结果 次版本 新 XML 必须写全当前参数;本阶段不提供旧文件自动迁移
修改界面名称或图标 库修订版本 不修改机器标识
修改方程的物理语义 根据影响提高次版本或主版本 补充基准和变更说明
删除、改名端口或参数 主版本 当前格式直接拒绝旧端口或参数;如以后需要兼容,再单独设计迁移器
修改 MODEL_TYPE 视为新模型 旧类型必须保留迁移映射

2.3 新增内部模型

仅供固定算例或研究代码使用、不进入前端目录的模型,不加入 library.py。这类模型 应放在对应 examples/ 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。

当前示例是 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。
  5. core/ports.py。
  6. core/metadata.py。
  7. core/catalog.py。
  8. registry.py 中的启动校验。
  9. 与目标模型最接近的测试。

不要只根据文件名、前端图标或旧 XML 猜测模型语义。

4. 文件位置和命名

公开模型放在:

app/simulation/components/<library_id>/<category_id>/<model_module>.py

例如:

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. 公开模型完整契约

每个公开模型类必须在自身类体中显式声明:

MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
DISPLAY = ...

同时必须实现:

@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()。

实现 update_stream_outflows() 或 update_flow_temperature_references() 的公开模型, 还应在该公开类自身显式声明 PRESSURE_FLOW_DEPENDS_ON_STREAM:构成压力/流量方程 会读取这些 hook 写入的焓或温度引用时设为 True,否则设为 False。省略声明、 声明非法值或由自定义子类仅继承父类声明时,求解器会保守使用全网热流闭合;不要 为了获得分块加速而错误声明 False。

pressure_flow_equation_residuals() 返回的每条 EquationResidual.variables 必须完整 列出该残差实际读取的全部代数端口量(p/m_flow/x/v/f),不能只写“主要变量”。 求解器会用这份声明编译稀疏 Jacobian 和独立方程块;漏写依赖可能让有限差分方向 不完整。仓库内置组件会接受结构与数值依赖回归,外部自定义组件当前仍保守使用 全网 dense 回退,直到具备同等的依赖验证边界。

6.2 ThermodynamicVolumeComponent

适用于包含质量和能量状态的气体容腔,例如:

  • 气瓶
  • 贮箱
  • 有容积的管段

至少实现:

  • get_state_vector()。
  • set_state_vector()。
  • refresh_thermodynamic_ports()。
  • state_derivative_from_ports()。
  • pressure_flow_equation_residuals()。

该基类已经提供标准热力学组件结果:

m, U, p, T, rho, u, h

除非物理含义不同,不要重新复制这组结果声明。

6.3 其他基类

如果现有基类不能表达模型,应先评估是否缺少一种通用组件能力。不要为了一个模型 直接把专用判断塞入 SimulationNetwork 或求解器。

7. 端口建模规范

当前气动模型使用:

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:

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 单位
acceleration m/s2
area m2
dimensionless 空字符串
density kg/m³
flow_coefficient kg/(s*Pa^0.5)
force N
heat_transfer_coefficient W/(m2*K)
internal_energy J
length m
mass kg
mass_flow kg/s
pressure Pa
specific_enthalpy J/kg
specific_internal_energy J/kg
temperature K
time s
translational_damping N/(m/s)
translational_stiffness N/m
velocity m/s
volume m3
volume_flow m3/s
windage N/(m/s)^2

新增物理量时必须先扩展后端受控单位表,再评估前端是否需要单位换算选项。禁止在 单个模型中私自拼写新的同义 quantity。

构造函数必须调用:

self.set_parameter_values(
    {
        "volume": volume,
        "p0": p0,
        "T0": T0,
    }
)

保存值、方程计算和结果输出都使用 SI。前端显示单位变化不能改变后端参数语义。

9. 结果变量规范

9.1 组件级结果

组件自身状态或派生量使用 ResultVariableDefinition:

ResultVariableDefinition(
    name="pressure_drop",
    label="压降",
    quantity="pressure",
    unit="Pa",
    category="derived",
    order=10,
)

声明后必须在 component_result_values() 返回同名值:

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:

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() 是注册器创建模型的唯一入口:

@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 与方程主要约束的物理角色一致。
  • 对零压差、零流量和反向流动给出有限结果。
  • 必要正则化必须有物理解释,并通过边界测试保护。
  • 不得用画布坐标、连接线方向或组件名称决定方程。

12.1 可因果执行的残差语义

后端只会对经过结构门控的内置模型启用完全因果执行。除完整声明 variables 外,这些模型还必须遵守以下可执行语义:

  • role="effort", relation="state" 的残差写成 端口 effort - 状态给定值,被约束的端口量系数必须为 +1。
  • role="effort", relation="equal" 的残差写成两个同类 effort 的差。
  • role="flow", relation="sumToZero" 按“流入组件为正”的约定求和,待消元 flow 的系数必须为 +1。
  • role="flow", relation="constitutive" 写成 待消元 flow - 本构计算值,待消元 flow 的系数必须为 +1。
  • pressure_flow_equation_values() 必须是无副作用的只读计算,返回顺序和长度 必须与 pressure_flow_equation_residuals() 的编译结果永久一致;不得在求残差时 修改端口、状态或活动集缓存。

求解器仍会在首次闭合、离散事件之后和固定周期执行完整残差审计。结构不满足、 运行时覆盖不完整或审计不通过时,会立即熔断到原有残差/非线性求解路径。现场诊断 时可在启动进程前设置 SIMULATION_CAUSAL_FAST_PATH=0,一键关闭该优化而不改变模型 文件。

动态模型还必须:

  • 状态向量长度稳定。
  • get_state_vector() 和 set_state_vector() 互为逆操作。
  • 状态导数满足质量和能量守恒约定。
  • 初始化默认值能够产生有限介质状态。

13. 可复制的代数模型模板

下面是一个符合当前规范的两端口代数阻力模板。复制后必须根据真实物理模型修改 类型、参数、方程、名称和测试,不能只改类名就注册。

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"
    PRESSURE_FLOW_DEPENDS_ON_STREAM = False
    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"]

真实现有模型可参考:

14. 注册模型

模型文件完成后,只修改所属库的 library.py:

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. 能进入通用求解器的模型,再添加短时仿真测试。

推荐先运行:

.\.venv-win\Scripts\python.exe -m unittest `
  tests.test_component_registry `
  tests.test_component_catalog `
  tests.test_component_metadata

然后运行完整回归:

.\.venv-win\Scripts\python.exe -m unittest discover -s tests

目录契约影响前端时还要运行:

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 的任务输入卡

为了减少猜测,新增模型前建议先填写:

模型中文名称:
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 能映射到正确模型。
  • 最小系统能够编译;声称可仿真的模型必须产生有限结果。
  • 针对性测试、完整回归和必要的前端构建通过。
  • 文档记录了模型假设、适用范围和已知限制。