同步仿真框架并接入AMESim气动组件

This commit is contained in:
huojiarong committed 2026-07-30 03:53:10 +00:00
1 parent 420bafeb4e
commit db4bdb4b70
109 files changed
+26920 -420

No files matched your search

+974 -18
View File
File diff suppressed because it is too large. Load diff
+352
View File
@@ -0,0 +1,352 @@
# 仿真后端
`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。
当前状态不是“只有骨架”,而是“`Testmodel` 已有一版可运行的 ODE 近似实现,并具备基础结果导出与对比能力”。
## 当前目录
- `core/`: 元件基类、端口、状态、介质、方程和元数据协议。
- `solvers/`: ODE、压力流量代数方程和 stream 求解。
- `components/experimental/`: 用于验证元件开发规范的临时组件库。
- `components/experimental/storage/`: 气瓶和贮箱等储能元件。
- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。
- `components/experimental/junctions/`: 三通等连接节点。
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和运行入口。
- `reporting/`: CSV、SVG、运行报告和 Modelica 对比结果导出。
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
稳定基准存放在 `tests/baselines/simulation/`,实际运行产物默认写入被 Git 忽略的
`app/data/simulation-runs/`。新增或修改元件时,先阅读 `components/example.md`。
需要把运行产物写到仓库外时,可以设置 `SIMULATIONAPP_DATA_DIR` 环境变量。
FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow
启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。
临时组件库的声明入口是 `components/experimental/library.py`。公开模型必须在
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。
当前关键文件:
- `core/medium.py`: 理想气体近似介质 `IdealGasMedium`
- `core/medium.py`: 温度相关的空气近似介质 `IdealGasMedium`
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
- `examples/testmodel/run.py`: 基线运行与程序化执行入口
- `tests/`: 当前组件契约、XML、通用系统和结果导出测试
## 当前阶段进度
这一阶段原先有 4 件重点工作,现在的状态如下:
1. `mytee1` 的 stream/焓传播语义:已完成当前阶段收紧
现在如果只有一条支路发生倒流,下游来流焓统一按 `tank.h` 处理,不再临时借另一条支路的焓来凑。
2. 下游初始化/约束处理:已完成当前阶段收口
之前是“直接改对象状态再开始积分”,现在已经收成显式的 `consistent_initial_state_vector()` 初始化入口。当前这一步会在不改下游总质量、总内能的前提下,把几段直接相连的体积拉回同一个连接压力。
3. 自动校验:已完成当前阶段首版
已经补了标准库 `unittest` 回归测试,先把初始化投影是否守恒、是否污染原始状态,以及 4 个主变量的提交基线锁住。
4. 更严格介质模型:已完成当前阶段首版
已经从固定 `cp/cv` 的理想气体近似,推进到随温度变化的空气近似,并接上了内能反解和初始化求根。
如果只看结果,可以把这一阶段理解成:
- 连接器语义:首轮收紧已完成
- 初始化入口:首轮收口已完成
- 基线验证:首轮保护已完成
- 介质精化:首轮近似已完成
## 当前阶段收口
上一轮 `N0-N3` 已全部完成首版,当前可以简单理解为:
1. `N0`:系统层里最明显的流向/焓判断已经继续下沉到组件 helper。
2. `N1`:模型参数和运行参数已经收口到配置对象。
3. `N2`:运行接口已经分成“准备请求”和“执行请求”两层。
4. `N3`:结果导出和命令行报告格式化已经统一收口到 `reporting/`。
这一轮结束后,项目已经不缺“能不能跑”的能力,下一步更重要的是把后续开发最容易卡住的地方先处理掉。
## 本次推送更新
本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态:
1. `M2`:已完成当前阶段首版
- 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `examples/testmodel/system.py` 拆到 `examples/testmodel/closure.py`
- `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节
2. `M3`:已完成当前阶段首版
- 已给两条支路入口流量固定点求解、下游公共压力投影补了显式诊断
- 诊断内容至少包含 `converged / iterations / residual`
- 已支持严格模式;内部求解不收敛时可以直接抛错,而不是静默返回最后一个近似值
- `run_testmodel()` 的结构化结果和 `testmodel_run_report.txt` 已能带出最后一次内部闭合求解诊断
3. `M4`:已完成当前阶段首版
- 自动测试已不再只盯最终主变量结果
- 现在已经覆盖:
- 改支路参数后,初始支路入口流量是否按预期变化
- 更偏激配置下,初始化和内部闭合是否仍然收敛
- 有无 Modelica 参考两种运行路径下,程序接口与产物行为是否一致
4. `M5`:已启动
- 当前已经明确选择优先走“更容易扩展”的方向,而不是先追求更贴近 Modelica
- 已完成第一步:把闭合器内部原来大量写死的 `upper/lower` 双支路逻辑,收成可复用的 `BranchClosureComponents / BranchClosureState` 结构
- 当前已继续推进到 `G1-G5` 的首轮兼容层改造:`snapshot` 已提供通用分支集合,系统层结果生成已拆成“通用键生成 + 旧键别名派生”两层,报告层已开始优先消费通用分支键,旧导出列名仍通过兼容映射保留,兼容测试已显式保护分支顺序和旧导出语义
## 下一阶段接手建议
如果继续往前推进,建议按下面顺序做,而不是再零散补功能:
1. `G1`:已完成当前阶段首轮兼容接入
- `TestModelSnapshot` 已新增 `branches` 集合
- 每个分支当前至少带 `name / pipe / inlet_flow / outlet_flow / inlet_h / inlet_flow_diagnostics`
- `pipe_upper / pipe_lower / branch_inlet_flows / branch_outlet_flows` 目前仍保留为兼容属性,供旧调用方继续使用
2. `G2`:已完成当前阶段首轮内部迁移
- `evaluate_solution()` 已改成从 `snapshot.branches` 读取数据,再通过显式分支名映射写回当前旧列名
- `rhs()` 里的分支导数计算已改成通过通用 helper 按分支循环生成,再按当前状态向量顺序拼回
- 当前外部导出列名仍保持兼容:
- `mypipe.p`
- `mypipe1.p`
- `branch_upper.in/out`
- `branch_lower.in/out`
3. `G3`:已完成当前阶段首轮兼容测试
- 当前测试已经显式保护:
- `branches` 顺序是否稳定
- `snapshot` 新字段和兼容字段是否一致
- 旧导出列名是否仍映射到正确分支语义
- 参数变化后 `upper/lower` 的名字和顺序是否不会被打乱
4. `G4`:已完成当前阶段首轮兼容拆层
- `evaluate_solution()` 现在会同时产出:
- 通用分支键:`branch.<branch_name>.p/in/out`
- 旧兼容键:`mypipe.p`、`mypipe1.p`、`branch_upper.*`、`branch_lower.*`
- 报告层当前已开始优先读取通用分支键,旧键只作为兼容后备
- 当前已经把“内部统一表达”和“旧接口兼容导出”拆成两层,但还没有把所有报告/导出逻辑都迁干净
5. `G5`:已完成当前阶段首轮兼容收口
- `evaluate_solution()` 当前会先生成通用分支键,再统一派生旧兼容键
- 报告层当前已支持“通用键优先、旧键兼容后备”
- 当前已经把系统层和 reporting 层的主要旧专名读取入口收口到少量 helper 上,后续继续迁移不会再到处散改
6. `P1`:下一阶段建议从这里接手
当前更合适的下一步,不是继续深挖内核通用化,而是切回结果导向主线:
- 定义一份稳定的外部输入参数 schema
- 明确这些结构化参数如何映射到 `TestModelConfig / TestModelRunConfig`
- 建立“结构化参数 -> 仿真执行 -> 结果产物/摘要”的稳定接口
这样可以直接服务后续文档解析、网页入口和报告生成,而不是继续在 `Testmodel` 内部做边际收益越来越低的抽象整理
7. `P2`:在 `P1` 完成后,再推进文档解析或报告生成链路
更现实的顺序应是:
- 先把结构化输入跑通
- 再把结果摘要/产物组织成更接近最终产品的输出包
- 最后再接 Word 解析或页面入口
如果后续继续推进,这个 README 也要一起更新,不要长期保留已经失效的路线描述。
## 当前实现了什么
当前代码已经实现:
1. `m`、`U` 作为动态元件主状态,`p`、`T`、`rho`、`u`、`h` 作为派生量。
2. `Cylinder`、`Tank`、`Pipe` 的刚性绝热容腔近似。
3. `Orifice` 的压差开方流量关系。
4. `Tee` 的简化混合焓处理。
5. `Testmodel` 的系统级拓扑映射和一版可运行的 `rhs(t, x)`。
6. 基于 `solve_ivp` 的积分入口,以及 SciPy 不可用时的 RK4 回退。
7. 温度相关空气近似介质,包括 `cp(T)`、`h(T)`、`u(T)` 以及 `u -> T` 反解。
8. 显式一致初值入口 `consistent_initial_state_vector()`,以及可迭代初始化器 `initialize_consistent_state()`。
9. Python 主变量结果导出:
`mytank.p`、`mytank.T`、`mycylinder.p`、`mycylinder.T`
10. 贮箱温度曲线导出:
`testmodel_tank_temperature.csv`
`testmodel_tank_temperature.svg`
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
当前没有实现:
- 通用 DAE 初始化器
- `Modelica.Media.Air.SimpleAir` 的严格复刻
- 一般高指数 DAE、事件和严格 Modelica `inStream/actualStream` 求解器
## 当前怎么运行
最小运行方式:
```bash
python -m app.simulation.examples.testmodel.run
```
如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如:
```python
from app.simulation.examples.testmodel.run import (
TestModelRunConfig,
TestModelSamplingConfig,
run_testmodel,
)
from app.simulation.examples.testmodel.system import (
BranchConfig,
CylinderConfig,
OrificeConfig,
PipeConfig,
TankConfig,
TestModelConfig,
)
from app.simulation.solvers.solver import SolveIVPConfig
run_config = TestModelRunConfig(
model=TestModelConfig(
cylinder=CylinderConfig(p0=30e6),
upper_branch=BranchConfig(
orifice=OrificeConfig(K=8e-6),
pipe=PipeConfig(length=6.0, diameter=0.03),
),
tank=TankConfig(volume=0.12),
),
solver=SolveIVPConfig(t_start=0.0, t_stop=10.0, method="BDF"),
sampling=TestModelSamplingConfig(step=0.05),
)
result = run_testmodel(run_config=run_config)
```
如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行:
```python
from app.simulation.examples.testmodel.run import (
prepare_testmodel_run,
run_prepared_testmodel,
TestModelRunConfig,
)
prepared = prepare_testmodel_run(run_config=TestModelRunConfig())
print(prepared.output_dir)
print(prepared.t_eval)
result = run_prepared_testmodel(prepared)
print(result.artifacts.primary_csv_path)
print(result.used_modelica_reference)
```
当前脚本会:
1. 构建 `TestModelSystem`
2. 打印原始初值向量与约束一致后的初值向量
3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s`
4. 将结果写入 `app/data/simulation-runs/` 下本次运行专属的时间戳目录
5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果
当前脚本默认不会把运行结果直接写到提交基线目录,而是会在
`app/data/simulation-runs/` 下创建一个带时间戳的子目录,例如:
- `app/data/simulation-runs/testmodel_20260512_103000_123456/`
该目录里通常会包含:
- `testmodel_primary_series.csv`
- `testmodel_tank_temperature.csv`
- `testmodel_tank_temperature.svg`
- `testmodel_run_report.txt`
- `testmodel_modelica_comparison.csv`
- `testmodel_modelica_comparison_summary.txt`
## 基线结果
当前基线对比摘要来自:
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt)
当前四个主变量的最大误差为:
- `mytank.p`: `max_abs_error = 134.960857 Pa`, `max_rel_error = 0.006798%`
- `mytank.T`: `max_abs_error = 0.035507 K`, `max_rel_error = 0.009016%`
- `mycylinder.p`: `max_abs_error = 1391.986349 Pa`, `max_rel_error = 0.009447%`
- `mycylinder.T`: `max_abs_error = 0.009069 K`, `max_rel_error = 0.003870%`
这说明在当前基线工况下,Python 版主变量已经能较好贴近 OpenModelica 结果。
## 当前架构判断
如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于:
- 组件级:已完成首版
- 系统闭合:已完成首版
- 积分入口:已完成首版
- 基线结果对齐:已具备初步能力
- 去近似:仍在进行中
所以当前最准确的说法不是“已完成移植”,而是:
`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。
## 已知限制
当前最主要的限制可以直接理解成下面几条:
- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。
- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。
- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
- 通用 XML 求解链路已经支持按实际流向传播和三通混合 stream 焓,但仍是正则化 MVP,不是严格的 Modelica `inStream/actualStream` 框架。
- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。
- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。
- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。
- 当前内核已经足够支撑下一阶段“结构化参数 -> 仿真执行 -> 产物输出”的链路开发,但还没有现成的 Word 参数解析入口和正式报告生成链路。
所以,当前版本适合:
- 架构验证
- 组件接口验证
- 基线工况对比
- 结果导出与误差定位
但当前版本还不适合:
- 直接宣称与 OpenModelica 严格等价
- 作为最终工程结论的唯一依据
- 直接扩展到更复杂拓扑而不补通用连接器语义
## 文件级现状
按代码现状逐项看:
- `core/base.py`: 正常
只提供最小抽象层,没有明显冗余。
- `core/ports.py`: 正常
`PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。
- `core/state.py`: 正常
`VolumeState` 只负责 `[m, U]` 状态打包。
- `systems/network.py`: 正常
负责状态向量拼装和连接摘要,不参与物理求解。
- `solvers/solver.py`: 正常
已支持 SciPy、RK4 回退和零时长仿真。
- `components/experimental/**/*.py`: 正常
都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。
- `examples/testmodel/system.py`: 是当前最重要的技术债集中区
这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。
- `examples/testmodel/run.py`: 正常
已不是“最小打印脚本”,而是当前结果导出和对比入口。
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
## 当前主技术债
目前最主要的技术债,可以直接理解成下面 4 件事:
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
2. `examples/testmodel/system.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
+2
View File
@@ -0,0 +1,2 @@
"""Simulation domain models, solvers, system assembly, and result tools."""
+2
View File
@@ -0,0 +1,2 @@
"""Component implementations for the Python system model."""
@@ -0,0 +1,3 @@
from __future__ import annotations
__all__: list[str] = []
@@ -0,0 +1 @@
"""AMESim pneumatic boundary components."""
@@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class AmesimPnpl01(AlgebraicComponent):
"""AMESim PNPL01 zero pneumatic flow source.
The component behaves as a sealed pneumatic boundary in the current acausal
solver: it does not prescribe pressure, and only constrains its port mass
flow to zero.
"""
MODEL_TYPE = "amesim_pnpl01"
MODEL_VERSION = "0.1.0"
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="PNPL01 零气动流边界",
library_id="amesim",
category_id="boundary",
symbol="generic",
ports=(PortDisplaySpec("port_1", "left", order=10),),
order=10,
)
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.port_1 = self.register_declared_port("port_1")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimPnpl01:
return cls(name=name)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:zero_mass_flow",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_1.m_flow",),
role="flow",
value=self.port_1.m_flow,
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
if "port_1" in connected_h:
self.port_1.h_outflow = connected_h["port_1"]
@@ -0,0 +1 @@
"""AMESim pneumatic flow components."""
@@ -0,0 +1,610 @@
from __future__ import annotations
from collections.abc import Mapping
from math import isclose, sqrt
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
ResultVariableDefinition,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class AmesimPnor001(AlgebraicComponent):
"""AMESim PNOR001 constant-flow-coefficient pneumatic orifice.
This public component preserves the PNOR001 catalog/XML contract and uses a
finite bidirectional compressible-orifice approximation. The Siemens
`pn2rcqfix_` details remain a later calibration target.
"""
MODEL_TYPE = "amesim_pnor001"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
"cq",
0.72,
label="流量系数 Cq",
quantity="dimensionless",
unit="",
minimum=1.0e-10,
maximum=1.0,
),
ParameterDefinition(
"area",
5.0e-6,
label="孔口面积",
quantity="area",
unit="m2",
minimum=0.0,
maximum=1.0,
),
ParameterDefinition(
"Cv",
0.5,
label="流量系数 Cv",
quantity="dimensionless",
unit="",
minimum=0.0,
),
ParameterDefinition(
"Kv",
0.4,
label="流量系数 Kv",
quantity="dimensionless",
unit="",
minimum=0.0,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
),
ParameterDefinition(
"flowset",
1.0,
label="流量系数设置",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=3.0,
),
)
RESULT_VARIABLES = (
ResultVariableDefinition(
"cm",
label="质量流量参数",
quantity="dimensionless",
unit="",
category="derived",
order=10,
),
ResultVariableDefinition(
"gasvel",
label="缩流截面气体速度",
quantity="velocity",
unit="m/s",
category="derived",
order=20,
),
)
DISPLAY = ComponentDisplaySpec(
label="PNOR001 常系数气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
),
order=10,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
cq: float = 0.72,
area: float = 5.0e-6,
Cv: float = 0.5,
Kv: float = 0.4,
gi: float = 1.0,
flowset: float = 1.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"cq": cq,
"area": area,
"Cv": Cv,
"Kv": Kv,
"gi": gi,
"flowset": flowset,
}
)
self.medium = medium
self.cq = float(cq)
self.area = float(area)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", gi)
self.flowset = self._integer_parameter("flowset", flowset)
if self.flowset not in {1, 2, 3}:
raise ValueError("PNOR001 flowset must be 1, 2, or 3.")
initial_h = medium.specific_enthalpy(medium.T_ref)
self.port_1 = self.register_declared_port("port_1")
self.port_1.h_outflow = initial_h
self.port_2 = self.register_declared_port("port_2")
self.port_2.h_outflow = initial_h
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
rounded = round(value)
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12):
raise ValueError(f"PNOR001 parameter {name} must be an integer value.")
return int(rounded)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimPnor001:
return cls(
name=name,
medium=medium,
cq=parameters["cq"],
area=parameters["area"],
Cv=parameters["Cv"],
Kv=parameters["Kv"],
gi=parameters["gi"],
flowset=parameters["flowset"],
)
@property
def effective_cq(self) -> float:
return self.cq if self.flowset == 1 else 0.72
@property
def effective_area(self) -> float:
if self.flowset == 1:
return self.area
if self.flowset == 2:
return self._area_from_cv(self.Cv, self.effective_cq)
return self._area_from_kv(self.Kv, self.effective_cq)
@staticmethod
def _area_from_cv(Cv: float, cq: float) -> float:
water_density = 999.0
reference_flow_m3_s = Cv * 6.30901964e-5
reference_dp_pa = 6894.75729
return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density))
@staticmethod
def _area_from_kv(Kv: float, cq: float) -> float:
water_density = 999.0
reference_flow_m3_s = Kv / 3600.0
reference_dp_pa = 100000.0
return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density))
def _upstream_temperature(self, port_name: str) -> float:
port = self.get_port(port_name)
if port.h_outflow > 0.0:
return max(port.h_outflow / self.medium.cp_ref, 1.0)
return self.medium.T_ref
def mass_flow(self, p_1: float, p_2: float) -> float:
if p_1 == p_2 or self.effective_area == 0.0:
return 0.0
if p_1 > p_2:
return self._one_way_mass_flow(
upstream_pressure=p_1,
downstream_pressure=p_2,
upstream_temperature=self._upstream_temperature("port_1"),
)
return -self._one_way_mass_flow(
upstream_pressure=p_2,
downstream_pressure=p_1,
upstream_temperature=self._upstream_temperature("port_2"),
)
def _one_way_mass_flow(
self,
*,
upstream_pressure: float,
downstream_pressure: float,
upstream_temperature: float,
) -> float:
p_up = max(upstream_pressure, 1.0)
p_down = max(min(downstream_pressure, p_up), 0.0)
T_up = max(upstream_temperature, 1.0)
gamma = max(self.medium.gamma, 1.000001)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
if pressure_ratio <= critical_ratio:
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * (
2.0 / (gamma + 1.0)
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
else:
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** (
(gamma + 1.0) / gamma
)
flow_factor = sqrt(
max(
2.0
* gamma
* expansion
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
0.0,
)
)
return self.effective_cq * self.effective_area * p_up * flow_factor
def component_result_values(self) -> Mapping[str, float]:
p_1 = max(self.port_1.p, 1.0)
p_2 = max(self.port_2.p, 1.0)
m_flow = abs(self.mass_flow(self.port_1.p, self.port_2.p))
upstream_pressure = max(p_1, p_2)
upstream_temperature = self._upstream_temperature(
"port_1" if p_1 >= p_2 else "port_2"
)
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
area = max(self.effective_area, 1.0e-18)
return {
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
"gasvel": m_flow / (density * area),
}
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_1.m_flow",
f"{self.name}.port_2.m_flow",
),
role="flow",
value=self.port_1.m_flow + self.port_2.m_flow,
),
EquationResidual(
id=f"{self.name}:pressure_flow_relation",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_1.p",
f"{self.name}.port_2.p",
f"{self.name}.port_1.m_flow",
),
role="flow",
value=self.port_1.m_flow
- self.mass_flow(self.port_1.p, self.port_2.p),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_1.h_outflow = connected_h["port_2"]
self.port_2.h_outflow = connected_h["port_1"]
class AmesimPnvo001FixedOpening(AlgebraicComponent):
"""Fixed-opening public variant of AMESim PNVO001.
Full PNVO001 has a signal input port. The current public component library
does not support signal simulation, so this model exposes the pneumatic
ports and replaces the signal with a normalized `opening` parameter.
"""
MODEL_TYPE = "amesim_pnvo001_fixed"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
"cq",
0.72,
label="流量系数 Cq",
quantity="dimensionless",
unit="",
minimum=1.0e-10,
maximum=1.0,
),
ParameterDefinition(
"area0",
5.0e-6,
label="最大孔口面积",
quantity="area",
unit="m2",
minimum=0.0,
maximum=1.0,
),
ParameterDefinition(
"Cv",
0.5,
label="最大流量系数 Cv",
quantity="dimensionless",
unit="",
minimum=0.0,
),
ParameterDefinition(
"Kv",
0.4,
label="最大流量系数 Kv",
quantity="dimensionless",
unit="",
minimum=0.0,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
),
ParameterDefinition(
"flowset",
1.0,
label="流量系数设置",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=3.0,
),
ParameterDefinition(
"opening",
1.0,
label="固定开度",
quantity="dimensionless",
unit="",
minimum=0.0,
maximum=1.0,
),
)
RESULT_VARIABLES = (
ResultVariableDefinition(
"xv",
label="有效开度",
quantity="dimensionless",
unit="",
category="derived",
order=10,
),
ResultVariableDefinition(
"cm",
label="质量流量参数",
quantity="dimensionless",
unit="",
category="derived",
order=20,
),
ResultVariableDefinition(
"gasvel",
label="缩流截面气体速度",
quantity="velocity",
unit="m/s",
category="derived",
order=30,
),
)
DISPLAY = ComponentDisplaySpec(
label="PNVO001 固定开度气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
ports=(
PortDisplaySpec("port_2", "left", order=10),
PortDisplaySpec("port_3", "right", order=20),
),
order=30,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
cq: float = 0.72,
area0: float = 5.0e-6,
Cv: float = 0.5,
Kv: float = 0.4,
gi: float = 1.0,
flowset: float = 1.0,
opening: float = 1.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"cq": cq,
"area0": area0,
"Cv": Cv,
"Kv": Kv,
"gi": gi,
"flowset": flowset,
"opening": opening,
}
)
self.medium = medium
self.cq = float(cq)
self.area0 = float(area0)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", gi)
self.flowset = self._integer_parameter("flowset", flowset)
if self.flowset not in {1, 2, 3}:
raise ValueError("PNVO001 fixed-opening flowset must be 1, 2, or 3.")
self.opening = min(1.0, max(0.0, float(opening)))
initial_h = medium.specific_enthalpy(medium.T_ref)
self.port_2 = self.register_declared_port("port_2")
self.port_2.h_outflow = initial_h
self.port_3 = self.register_declared_port("port_3")
self.port_3.h_outflow = initial_h
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
rounded = round(value)
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12):
raise ValueError(f"PNVO001 fixed-opening parameter {name} must be an integer value.")
return int(rounded)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimPnvo001FixedOpening:
return cls(
name=name,
medium=medium,
cq=parameters["cq"],
area0=parameters["area0"],
Cv=parameters["Cv"],
Kv=parameters["Kv"],
gi=parameters["gi"],
flowset=parameters["flowset"],
opening=parameters["opening"],
)
@property
def effective_cq(self) -> float:
return self.cq if self.flowset == 1 else 0.72
@property
def maximum_area(self) -> float:
if self.flowset == 1:
return self.area0
if self.flowset == 2:
return AmesimPnor001._area_from_cv(self.Cv, self.effective_cq)
return AmesimPnor001._area_from_kv(self.Kv, self.effective_cq)
@property
def effective_area(self) -> float:
return self.opening * self.maximum_area
def _upstream_temperature(self, port_name: str) -> float:
port = self.get_port(port_name)
if port.h_outflow > 0.0:
return max(port.h_outflow / self.medium.cp_ref, 1.0)
return self.medium.T_ref
def mass_flow(self, p_2: float, p_3: float) -> float:
if p_2 == p_3 or self.effective_area == 0.0:
return 0.0
if p_2 > p_3:
return self._one_way_mass_flow(
upstream_pressure=p_2,
downstream_pressure=p_3,
upstream_temperature=self._upstream_temperature("port_2"),
)
return -self._one_way_mass_flow(
upstream_pressure=p_3,
downstream_pressure=p_2,
upstream_temperature=self._upstream_temperature("port_3"),
)
def _one_way_mass_flow(
self,
*,
upstream_pressure: float,
downstream_pressure: float,
upstream_temperature: float,
) -> float:
p_up = max(upstream_pressure, 1.0)
p_down = max(min(downstream_pressure, p_up), 0.0)
T_up = max(upstream_temperature, 1.0)
gamma = max(self.medium.gamma, 1.000001)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
if pressure_ratio <= critical_ratio:
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * (
2.0 / (gamma + 1.0)
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
else:
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** (
(gamma + 1.0) / gamma
)
flow_factor = sqrt(
max(
2.0
* gamma
* expansion
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
0.0,
)
)
return self.effective_cq * self.effective_area * p_up * flow_factor
def component_result_values(self) -> Mapping[str, float]:
p_2 = max(self.port_2.p, 1.0)
p_3 = max(self.port_3.p, 1.0)
m_flow = abs(self.mass_flow(self.port_2.p, self.port_3.p))
upstream_pressure = max(p_2, p_3)
upstream_temperature = self._upstream_temperature(
"port_2" if p_2 >= p_3 else "port_3"
)
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
area = max(self.effective_area, 1.0e-18)
return {
"xv": self.opening,
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
"gasvel": m_flow / (density * area),
}
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_2.m_flow",
f"{self.name}.port_3.m_flow",
),
role="flow",
value=self.port_2.m_flow + self.port_3.m_flow,
),
EquationResidual(
id=f"{self.name}:pressure_flow_relation",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_2.p",
f"{self.name}.port_3.p",
f"{self.name}.port_2.m_flow",
),
role="flow",
value=self.port_2.m_flow
- self.mass_flow(self.port_2.p, self.port_3.p),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_2.h_outflow = connected_h["port_3"]
self.port_3.h_outflow = connected_h["port_2"]
@@ -0,0 +1,310 @@
from __future__ import annotations
from collections.abc import Mapping
from math import isclose, log10, pi, sqrt
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
ResultVariableDefinition,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class AmesimPnl00r(AlgebraicComponent):
"""AMESim PNL00R pneumatic pipe friction resistance.
The public model exposes the AMESim PNL00R catalog/XML contract and uses
an auditable Darcy-Weisbach resistance with Reynolds/roughness-dependent
friction. Exact `pn2pipefr_` parity is left for the later model tuning pass.
"""
MODEL_TYPE = "amesim_pnl00r"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
"diam",
0.01,
label="管径",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"le",
1.0,
label="管长",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"rr",
1.0e-5,
label="相对粗糙度",
quantity="dimensionless",
unit="",
minimum=0.0,
maximum=0.1,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
),
)
RESULT_VARIABLES = (
ResultVariableDefinition(
"re",
label="Reynolds 数",
quantity="dimensionless",
unit="",
category="derived",
order=10,
),
ResultVariableDefinition(
"cm",
label="质量流量参数",
quantity="dimensionless",
unit="",
category="derived",
order=20,
),
ResultVariableDefinition(
"v",
label="平均气体速度",
quantity="velocity",
unit="m/s",
category="derived",
order=30,
),
ResultVariableDefinition(
"ff",
label="摩擦因子",
quantity="dimensionless",
unit="",
category="derived",
order=40,
),
)
DISPLAY = ComponentDisplaySpec(
label="PNL00R 气动管路阻力",
library_id="amesim",
category_id="flow",
symbol="pipe",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
),
order=20,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
diam: float = 0.01,
le: float = 1.0,
rr: float = 1.0e-5,
gi: float = 1.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values({"diam": diam, "le": le, "rr": rr, "gi": gi})
self.medium = medium
self.diam = float(diam)
self.le = float(le)
self.rr = float(rr)
self.gi = self._integer_parameter("gi", gi)
self.area = pi * self.diam * self.diam / 4.0
initial_h = medium.specific_enthalpy(medium.T_ref)
self.port_1 = self.register_declared_port("port_1")
self.port_1.h_outflow = initial_h
self.port_2 = self.register_declared_port("port_2")
self.port_2.h_outflow = initial_h
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
rounded = round(value)
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12):
raise ValueError(f"PNL00R parameter {name} must be an integer value.")
return int(rounded)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimPnl00r:
return cls(
name=name,
medium=medium,
diam=parameters["diam"],
le=parameters["le"],
rr=parameters["rr"],
gi=parameters["gi"],
)
def _port_temperature(self, port_name: str) -> float:
port = self.get_port(port_name)
if port.h_outflow > 0.0:
return max(port.h_outflow / self.medium.cp_ref, 1.0)
return self.medium.T_ref
@staticmethod
def _dynamic_viscosity(temperature_k: float) -> float:
if temperature_k <= 0.0:
raise ValueError("temperature_k must be positive")
reference_temperature = 293.15
reference_viscosity = 1.82e-5
sutherland_constant = 110.4
return (
reference_viscosity
* (temperature_k / reference_temperature) ** 1.5
* (reference_temperature + sutherland_constant)
/ (temperature_k + sutherland_constant)
)
def reynolds_number(self, mass_flow: float, temperature: float) -> float:
viscosity = self._dynamic_viscosity(temperature)
return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity)
def friction_factor(self, reynolds_number: float) -> float:
if reynolds_number <= 0.0:
return 64_000_000.0
laminar = 64.0 / reynolds_number
if reynolds_number <= 2300.0:
return laminar
turbulent = 1.0 / (
-1.8 * log10((self.rr / 3.7) ** 1.11 + 6.9 / reynolds_number)
) ** 2
if reynolds_number >= 4000.0:
return turbulent
fraction = (reynolds_number - 2300.0) / 1700.0
return laminar + fraction * (turbulent - laminar)
def darcy_pressure_drop(
self,
mass_flow: float,
*,
density: float,
temperature: float,
) -> float:
if mass_flow == 0.0:
return 0.0
reynolds = self.reynolds_number(mass_flow, temperature)
friction = self.friction_factor(reynolds)
velocity = mass_flow / (density * self.area)
magnitude = (
friction
* (self.le / self.diam)
* density
* velocity
* velocity
/ 2.0
)
return magnitude if mass_flow > 0.0 else -magnitude
def _mass_flow_for_pressure_drop(
self,
pressure_drop: float,
*,
density: float,
temperature: float,
) -> float:
if pressure_drop <= 0.0:
return 0.0
upper = 1.0e-9
while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop:
upper *= 10.0
if upper > 1.0e3:
raise ValueError("unable to bracket PNL00R resistance flow")
lower = 0.0
for _ in range(48):
middle = 0.5 * (lower + upper)
if self.darcy_pressure_drop(middle, density=density, temperature=temperature) < pressure_drop:
lower = middle
else:
upper = middle
return 0.5 * (lower + upper)
def mass_flow(self, p_1: float, p_2: float) -> float:
if p_1 == p_2:
return 0.0
pressure_difference = p_1 - p_2
upstream_pressure = max(p_1, p_2, 1.0)
upstream_temperature = self._port_temperature("port_1" if pressure_difference > 0.0 else "port_2")
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
magnitude = self._mass_flow_for_pressure_drop(
abs(pressure_difference),
density=density,
temperature=upstream_temperature,
)
return magnitude if pressure_difference > 0.0 else -magnitude
def component_result_values(self) -> Mapping[str, float]:
m_flow = self.mass_flow(self.port_1.p, self.port_2.p)
upstream_pressure = max(self.port_1.p, self.port_2.p, 1.0)
upstream_temperature = self._port_temperature(
"port_1" if self.port_1.p >= self.port_2.p else "port_2"
)
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
reynolds = self.reynolds_number(m_flow, upstream_temperature)
velocity = m_flow / (density * self.area)
cm = abs(m_flow) / max(self.area * upstream_pressure, 1.0e-18)
return {
"re": reynolds,
"cm": cm,
"v": velocity,
"ff": self.friction_factor(reynolds),
}
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_1.m_flow",
f"{self.name}.port_2.m_flow",
),
role="flow",
value=self.port_1.m_flow + self.port_2.m_flow,
),
EquationResidual(
id=f"{self.name}:pressure_flow_relation",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_1.p",
f"{self.name}.port_2.p",
f"{self.name}.port_1.m_flow",
),
role="flow",
value=self.port_1.m_flow
- self.mass_flow(self.port_1.p, self.port_2.p),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_1.h_outflow = connected_h["port_2"]
self.port_2.h_outflow = connected_h["port_1"]
@@ -0,0 +1,5 @@
from __future__ import annotations
from app.simulation.components.amesim.junctions.nodes import AmesimP4Node2, AmesimPn3Node2
__all__ = ["AmesimP4Node2", "AmesimPn3Node2"]
@@ -0,0 +1,149 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition, PortState
class _AmesimPneumaticNode(AlgebraicComponent):
"""Shared implementation for AMESim pneumatic junction submodels."""
REFERENCE_PORT = "port_2"
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
for definition in self.PORTS:
setattr(self, definition.name, self.register_declared_port(definition.name))
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
reference = self.get_port(self.REFERENCE_PORT)
residuals: list[EquationResidual] = []
for definition in self.PORTS:
if definition.name == self.REFERENCE_PORT:
continue
port = self.get_port(definition.name)
residuals.append(
EquationResidual(
id=f"{self.name}:{definition.name}_pressure_reference",
owner="component",
owner_id=self.name,
relation="equal",
variables=(
f"{self.name}.{definition.name}.p",
f"{self.name}.{self.REFERENCE_PORT}.p",
),
role="effort",
value=port.p - reference.p,
)
)
residuals.append(
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=tuple(
f"{self.name}.{definition.name}.m_flow"
for definition in self.PORTS
),
role="flow",
value=sum(self.get_port(definition.name).m_flow for definition in self.PORTS),
)
)
return tuple(residuals)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
incoming = [
(port.m_flow, connected_h[name])
for name, port in self.ports.items()
if port.m_flow > 1e-12
]
total_flow = sum(m_flow for m_flow, _ in incoming)
if total_flow > 1e-12:
mixed_h = sum(m_flow * h for m_flow, h in incoming) / total_flow
else:
mixed_h = connected_h.get(
self.REFERENCE_PORT,
sum(connected_h.values()) / len(connected_h) if connected_h else 0.0,
)
for port in self.ports.values():
port.h_outflow = mixed_h
class AmesimPn3Node2(_AmesimPneumaticNode):
"""AMESim PN3NODE2 pneumatic three-port junction."""
MODEL_TYPE = "amesim_pn3node2"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="PN3NODE2 三端气动节点",
library_id="amesim",
category_id="junctions",
symbol="tee",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
PortDisplaySpec("port_3", "right", order=30),
),
order=10,
)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimPn3Node2:
return cls(name=name)
class AmesimP4Node2(_AmesimPneumaticNode):
"""AMESim P4NODE2 pneumatic four-port junction."""
MODEL_TYPE = "amesim_p4node2"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="P4NODE2 四端气动节点",
library_id="amesim",
category_id="junctions",
symbol="generic",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
PortDisplaySpec("port_3", "right", order=30),
PortDisplaySpec("port_4", "right", order=40),
),
order=20,
)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimP4Node2:
return cls(name=name)
@@ -0,0 +1,31 @@
"""AMESim-compatible public component library."""
from app.simulation.core.catalog import (
ComponentCategorySpec,
ComponentLibrarySpec,
)
LIBRARY = ComponentLibrarySpec(
id="amesim",
label="AMESim 组件库",
version="0.1.0",
source_package="app.simulation.components.amesim",
temporary=True,
order=200,
categories=(
ComponentCategorySpec(id="storage", label="储能元件", order=10),
ComponentCategorySpec(id="flow", label="流动元件", order=20),
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
ComponentCategorySpec(id="boundary", label="边界元件", order=40),
),
models=(
"app.simulation.components.amesim.boundary.sources:AmesimPnpl01",
"app.simulation.components.amesim.storage.chambers:AmesimPnch023",
"app.simulation.components.amesim.flow.orifices:AmesimPnor001",
"app.simulation.components.amesim.flow.orifices:AmesimPnvo001FixedOpening",
"app.simulation.components.amesim.flow.pipes:AmesimPnl00r",
"app.simulation.components.amesim.junctions.nodes:AmesimPn3Node2",
"app.simulation.components.amesim.junctions.nodes:AmesimP4Node2",
),
)
@@ -0,0 +1 @@
"""AMESim pneumatic storage components."""
@@ -0,0 +1,250 @@
from __future__ import annotations
from collections.abc import Mapping
from math import isclose
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
class AmesimPnch023(ThermodynamicVolumeComponent):
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
The AMESim submodel owns pressure and temperature states and exposes two
pneumatic flow ports. This public component maps those states onto the
framework's mass/internal-energy volume state and keeps the AMESim
heat-transfer contract `kth * sth * (extemp - T)`.
"""
MODEL_TYPE = "amesim_pnch023"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
"cvol",
0.057,
label="气室容积",
quantity="volume",
unit="m3",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"kth",
0.0,
label="换热系数",
quantity="heat_transfer_coefficient",
unit="W/(m2*K)",
minimum=0.0,
),
ParameterDefinition(
"sth",
0.1,
label="换热面积",
quantity="area",
unit="m2",
minimum=0.0,
),
ParameterDefinition(
"extemp",
293.15,
label="外部温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
),
ParameterDefinition(
"p0",
100000.0,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
293.15,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
label="PNCH023 固定容积气室",
library_id="amesim",
category_id="storage",
symbol="tank",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
),
order=10,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
cvol: float = 0.057,
kth: float = 0.0,
sth: float = 0.1,
extemp: float = 293.15,
gi: float = 1.0,
p0: float = 100000.0,
T0: float = 293.15,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"cvol": cvol,
"kth": kth,
"sth": sth,
"extemp": extemp,
"gi": gi,
"p0": p0,
"T0": T0,
}
)
self.medium = medium
self.cvol = float(cvol)
self.kth = float(kth)
self.sth = float(sth)
self.extemp = float(extemp)
self.gi = self._integer_parameter("gi", gi)
self.p0 = float(p0)
self.T0 = float(T0)
m0 = self.p0 * self.cvol / (medium.R_gas * self.T0)
U0 = m0 * medium.specific_internal_energy(self.T0)
self.state = VolumeState(m=m0, U=U0)
initial_h = medium.specific_enthalpy(self.T0)
self.port_1 = self.register_declared_port("port_1")
self.port_1.p = self.p0
self.port_1.h_outflow = initial_h
self.port_2 = self.register_declared_port("port_2")
self.port_2.p = self.p0
self.port_2.h_outflow = initial_h
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
rounded = round(value)
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12):
raise ValueError(f"PNCH023 parameter {name} must be an integer value.")
return int(rounded)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimPnch023:
return cls(
name=name,
medium=medium,
cvol=parameters["cvol"],
kth=parameters["kth"],
sth=parameters["sth"],
extemp=parameters["extemp"],
gi=parameters["gi"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.cvol)
self.port_1.p = props.p
self.port_1.h_outflow = props.h
self.port_2.p = props.p
self.port_2.h_outflow = props.h
return props
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
return self.properties()
def thermal_energy_flow_w(self, temperature: float) -> float:
return self.kth * self.sth * (self.extemp - temperature)
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
props = self.properties()
inlet_h_1 = self.connection_inlet_enthalpy(
port_m_flow=self.port_1.m_flow,
connected_h=connected_h["port_1"],
internal_h=props.h,
)
inlet_h_2 = self.connection_inlet_enthalpy(
port_m_flow=self.port_2.m_flow,
connected_h=connected_h["port_2"],
internal_h=props.h,
)
derivative = VolumeState(
m=self.port_1.m_flow + self.port_2.m_flow,
U=(
self.port_1.m_flow * inlet_h_1
+ self.port_2.m_flow * inlet_h_2
+ self.thermal_energy_flow_w(props.T)
),
)
return derivative.as_vector()
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.cvol,
).p
return (
EquationResidual(
id=f"{self.name}:port_1_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_1.p", f"{self.name}.state"),
role="effort",
value=self.port_1.p - pressure,
),
EquationResidual(
id=f"{self.name}:port_2_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_2.p", f"{self.name}.state"),
role="effort",
value=self.port_2.p - pressure,
),
)
+282
View File
@@ -0,0 +1,282 @@
# 元件建模规范与示例
规范的权威版本位于
[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
修改中同步,不能让示例形成另一套规则。
本文档是 `app/simulation/components` 下新增元件的最小开发规范。当前
`experimental` 是用于验证规范的临时组件库;后续正式模型应建立独立组件库,
不要继续堆放在 `experimental` 中。
目标是让元件的端口、输入参数和可展示结果都由元件类显式声明,避免 XML
校验、求解器和前端分别维护同一份含义。
## 一、元件类必须声明的内容
每个对外注册的元件类至少需要声明以下六个类属性:
```python
MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
DISPLAY = ...
```
- `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。
- `MODEL_VERSION`:模型契约版本,采用 `主版本.次版本.修订版本`。
- `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。
- `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。
- `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。
- `DISPLAY`:组件库名称、分类、图标、排序和端口画布位置,不参与物理求解。
元件构造函数还必须:
1. 调用 `super().__init__(name)`。
2. 使用 `set_parameter_values()` 保存规范化后的输入参数。
3. 使用 `register_declared_port()` 创建已声明端口。
4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。
5. 实现统一的类方法 `create()`,接收规范化后的 SI 参数。
## 二、输入参数与结果变量
输入参数和仿真结果必须分开声明:
- 输入参数描述一次仿真开始前由用户配置的量,例如 `volume`、`p0`、`T0`。
- 结果变量描述随时间变化、允许绘图的量,例如 `p`、`T`、`m`、`m_flow`。
- 求解器缓存、中间残差和调试字段不得自动暴露为结果变量。
- 参数名和结果变量名使用稳定的英文机器标识;`label` 专门用于界面显示。
参数定义示例:
```python
ParameterDefinition(
name="volume",
label="容积",
quantity="volume",
unit="m3",
default=0.1,
minimum=0.0,
minimum_exclusive=True,
)
```
结果变量定义示例:
```python
ResultVariableDefinition(
name="p",
label="压力",
quantity="pressure",
unit="Pa",
category="thermodynamic",
order=30,
)
```
## 三、命名和单位约定
- 模型类型、参数、端口和变量名使用 `snake_case`,已有热力学惯例 `T`、`U` 可以保留。
- 输入参数保存和计算统一使用 SI 基准值;界面单位换算不能改变后端存储值。
- 无量纲参数的 `unit` 使用空字符串。
- `quantity` 表示稳定的物理量类型,例如 `pressure`、`temperature`、`mass_flow`,不能使用界面文案代替。
- 正质量流量统一定义为流入元件,即 `positiveFlowDirection="intoComponent"`。
- 端口变量 `p`、`m_flow`、`h_outflow` 的连接规则由 `PortDefinition.pneumatic()` 统一提供。
## 四、完整示例:单端口储气容腔
下面的示例展示一个可直接接入当前框架的动态元件。真实新增元件时应放入独立的 `.py` 文件,并补充对应测试。
```python
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
class ExampleVolume(ThermodynamicVolumeComponent):
MODEL_TYPE = "example_volume"
MODEL_VERSION = "1.0.0"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
name="volume",
label="容积",
quantity="volume",
unit="m3",
default=0.1,
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
name="p0",
label="初始压力",
quantity="pressure",
unit="Pa",
default=100000.0,
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
name="T0",
label="初始温度",
quantity="temperature",
unit="K",
default=300.0,
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
label="示例容腔",
library_id="experimental",
category_id="storage",
symbol="generic",
ports=(PortDisplaySpec("port_a", "left"),),
order=90,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
volume: float = 0.1,
p0: float = 100000.0,
T0: float = 300.0,
) -> None:
super().__init__(name)
self.set_parameter_values(
{"volume": volume, "p0": p0, "T0": T0}
)
self.medium = medium
self.V = volume
initial_mass = p0 * volume / (medium.R_gas * T0)
initial_energy = initial_mass * medium.specific_internal_energy(T0)
self.state = VolumeState(m=initial_mass, U=initial_energy)
self.port_a = self.register_declared_port("port_a")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> ExampleVolume:
return cls(
name=name,
medium=medium,
volume=parameters["volume"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
properties = self.medium.properties_from_mU(
self.state.m, self.state.U, self.V
)
self.port_a.p = properties.p
self.port_a.h_outflow = properties.h
return properties
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
properties = self.refresh_thermodynamic_ports()
inlet_h = self.connection_inlet_enthalpy(
port_m_flow=self.port_a.m_flow,
connected_h=connected_h["port_a"],
internal_h=properties.h,
)
return [self.port_a.m_flow, self.port_a.m_flow * inlet_h]
def pressure_flow_equation_residuals(
self,
) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m, self.state.U, self.V
).p
return (
EquationResidual(
id=f"{self.name}:port_a_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_a.p", f"{self.name}.state"),
role="effort",
value=self.port_a.p - pressure,
),
)
```
模型文件不再直接修改全局注册表。完成模型类后,只把类路径加入所属库
`library.py` 的 `models` 清单:
```python
models=(
# ...已有模型
"app.simulation.components.experimental.storage.example_volume:ExampleVolume",
)
```
后端会受控导入清单中的类,校验版本、分类、端口、参数、单位、显示信息和默认实例,
再自动建立注册表。校验通过后,`GET /api/components/catalog` 会输出该元件,
前端刷新时即可加载。
当前 `experimental` 仅用于规范验证;正式模型应先建立新的库声明,再把
`library_id` 指向正式库。
完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义:
```json
{
"key": "example_volume_1.port_a.m_flow",
"componentId": "example_volume_1",
"componentType": "example_volume",
"scope": "port",
"portName": "port_a",
"name": "m_flow",
"label": "质量流量",
"quantity": "mass_flow",
"unit": "kg/s",
"category": "flow",
"order": 20
}
```
## 五、新增元件检查清单
1. `MODEL_TYPE` 是否唯一,并与 XML 的模型类型一致。
2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。
3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。
4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。
5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。
6. 是否只暴露有工程意义的结果,而非内部计算变量。
7. `MODEL_VERSION` 和 `DISPLAY` 是否完整,显示端口是否与物理端口完全一致。
8. 是否实现统一的 `create()`,并能用默认参数创建模型。
9. 模型类路径是否只加入所属库的 `library.py` 清单。
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
组件库、分类和自动发现的完整规则参见
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
@@ -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."""
@@ -0,0 +1,118 @@
from __future__ import annotations
from collections.abc import Mapping
from math import sqrt
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class Orifice(AlgebraicComponent):
"""Python port of ModelicaModels.Myorifice."""
MODEL_TYPE = "orifice"
MODEL_VERSION = "1.0.0"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
)
PARAMETERS = (
ParameterDefinition(
"K",
1e-5,
label="流量系数",
quantity="flow_coefficient",
unit="kg/(s*Pa^0.5)",
minimum=0.0,
),
ParameterDefinition(
"opening",
1.0,
label="开度",
minimum=0.0,
maximum=1.0,
),
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="孔板/阀门",
library_id="experimental",
category_id="flow",
symbol="orifice",
ports=(
PortDisplaySpec("port_a", "left", order=10),
PortDisplaySpec("port_b", "right", order=20),
),
order=40,
)
def __init__(self, name: str, opening: float = 1.0, K: float = 1e-5) -> None:
super().__init__(name=name)
self.set_parameter_values({"K": K, "opening": opening})
self.opening = opening
self.K = K
self.port_a = self.register_declared_port("port_a")
self.port_b = self.register_declared_port("port_b")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Orifice:
return cls(
name=name,
opening=parameters["opening"],
K=parameters["K"],
)
@property
def K_eff(self) -> float:
return self.K * max(self.opening, 0.001)
def mass_flow(self, p_a: float, p_b: float) -> float:
dp = p_a - p_b
if dp == 0.0:
return 0.0
return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_a.m_flow",
f"{self.name}.port_b.m_flow",
),
role="flow",
value=self.port_a.m_flow + self.port_b.m_flow,
),
EquationResidual(
id=f"{self.name}:pressure_flow_relation",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_a.p",
f"{self.name}.port_b.p",
f"{self.name}.port_a.m_flow",
),
role="flow",
value=self.port_a.m_flow
- self.mass_flow(self.port_a.p, self.port_b.p),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_a.h_outflow = connected_h["port_b"]
self.port_b.h_outflow = connected_h["port_a"]
@@ -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",)
@@ -0,0 +1,185 @@
from __future__ import annotations
from collections.abc import Mapping
from math import pi
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class ResistivePipe(AlgebraicComponent):
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
MODEL_TYPE = "pipe"
MODEL_VERSION = "1.0.0"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
)
PARAMETERS = (
ParameterDefinition(
"length",
5.0,
label="长度",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"diameter",
0.02,
label="直径",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"lambda_darcy",
0.02,
label="摩阻系数",
minimum=0.0,
),
ParameterDefinition(
"p0",
1e5,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="管段",
library_id="experimental",
category_id="flow",
symbol="pipe",
ports=(
PortDisplaySpec("port_a", "left", order=10),
PortDisplaySpec("port_b", "right", order=20),
),
order=30,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
L: float = 5.0,
D: float = 0.02,
lambda_darcy: float = 0.02,
p0: float = 1e5,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"length": L,
"diameter": D,
"lambda_darcy": lambda_darcy,
"p0": p0,
"T0": T0,
}
)
self.medium = medium
self.L = L
self.D = D
self.lambda_darcy = lambda_darcy
self.p0 = p0
self.T0 = T0
self.area = pi * D * D / 4.0
initial_h = medium.specific_enthalpy(T0)
self.port_a = self.register_declared_port("port_a")
self.port_a.p = p0
self.port_a.h_outflow = initial_h
self.port_b = self.register_declared_port("port_b")
self.port_b.p = p0
self.port_b.h_outflow = initial_h
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> ResistivePipe:
return cls(
name=name,
medium=medium,
L=parameters["length"],
D=parameters["diameter"],
lambda_darcy=parameters["lambda_darcy"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def pressure_drop(self, m_flow_a: float, p_a: float, p_b: float) -> float:
average_pressure = max(0.5 * (p_a + p_b), 1.0)
density = max(self.medium.density(average_pressure, self.T0), 1e-12)
resistance = self.lambda_darcy * (self.L / self.D)
return (
resistance
* m_flow_a
* abs(m_flow_a)
/ (2.0 * density * self.area * self.area)
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_a.m_flow",
f"{self.name}.port_b.m_flow",
),
role="flow",
value=self.port_a.m_flow + self.port_b.m_flow,
),
EquationResidual(
id=f"{self.name}:darcy_pressure_loss",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_a.p",
f"{self.name}.port_b.p",
f"{self.name}.port_a.m_flow",
),
role="effort",
value=(
self.port_a.p
- self.port_b.p
- self.pressure_drop(
self.port_a.m_flow,
self.port_a.p,
self.port_b.p,
)
),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_a.h_outflow = connected_h["port_b"]
self.port_b.h_outflow = connected_h["port_a"]
@@ -0,0 +1 @@
"""Flow junction components."""
@@ -0,0 +1,266 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class Tee(AlgebraicComponent):
"""Python port of ModelicaModels.Mytee."""
MODEL_TYPE = "tee"
MODEL_VERSION = "1.0.0"
PORTS = (
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"),
)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="三通",
library_id="experimental",
category_id="junctions",
symbol="tee",
ports=(
PortDisplaySpec("port_in", "left", order=10),
PortDisplaySpec("port_out1", "right", order=20),
PortDisplaySpec("port_out2", "right", order=30),
),
order=50,
)
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.port_in = self.register_declared_port("port_in")
self.port_out1 = self.register_declared_port("port_out1")
self.port_out2 = self.register_declared_port("port_out2")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Tee:
return cls(name=name)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:common_pressure_out1",
owner="component",
owner_id=self.name,
relation="equal",
variables=(f"{self.name}.port_in.p", f"{self.name}.port_out1.p"),
role="effort",
value=self.port_in.p - self.port_out1.p,
),
EquationResidual(
id=f"{self.name}:common_pressure_out2",
owner="component",
owner_id=self.name,
relation="equal",
variables=(f"{self.name}.port_in.p", f"{self.name}.port_out2.p"),
role="effort",
value=self.port_in.p - self.port_out2.p,
),
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_in.m_flow",
f"{self.name}.port_out1.m_flow",
f"{self.name}.port_out2.m_flow",
),
role="flow",
value=(
self.port_in.m_flow
+ self.port_out1.m_flow
+ self.port_out2.m_flow
),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
incoming = [
(port.m_flow, connected_h[name])
for name, port in self.ports.items()
if port.m_flow > 1e-12
]
total_flow = sum(m_flow for m_flow, _ in incoming)
if total_flow > 1e-12:
mixed_h = sum(
m_flow * enthalpy for m_flow, enthalpy in incoming
) / total_flow
else:
values = list(connected_h.values())
mixed_h = sum(values) / len(values) if values else 0.0
for port in self.ports.values():
port.h_outflow = mixed_h
def mixed_inlet_enthalpy(
self,
branch1_m_flow: float,
branch1_h: float,
branch2_m_flow: float,
branch2_h: float,
fallback_h: float = 0.0,
) -> float:
positive_1 = max(branch1_m_flow, 0.0)
positive_2 = max(branch2_m_flow, 0.0)
total = positive_1 + positive_2
if total <= 1e-9:
return fallback_h
return (positive_1 * branch1_h + positive_2 * branch2_h) / total
def inlet_stream_enthalpy(
self,
branch1_m_flow: float,
branch1_h: float,
branch2_m_flow: float,
branch2_h: float,
fallback_h: float,
) -> float:
"""Approximate `inStream(port_in.h_outflow)` for the current tee topology."""
return self.mixed_inlet_enthalpy(
branch1_m_flow,
branch1_h,
branch2_m_flow,
branch2_h,
fallback_h=fallback_h,
)
def branch_actual_stream_enthalpy(
self,
branch_m_flow: float,
branch_h: float,
inlet_h: float,
) -> float:
"""Approximate `actualStream(branch.h_outflow)` for a tee branch port."""
return inlet_h if branch_m_flow > 0.0 else branch_h
@staticmethod
def _solve_linear_2x2(
a11: float,
a12: float,
a21: float,
a22: float,
b1: float,
b2: float,
) -> tuple[float, float] | None:
determinant = a11 * a22 - a12 * a21
if abs(determinant) <= 1e-12:
return None
x1 = (b1 * a22 - b2 * a12) / determinant
x2 = (a11 * b2 - a21 * b1) / determinant
return x1, x2
def solve_branch_outlet_flows_from_energy_balance(
self,
*,
ratio_branch1: float,
ratio_branch2: float,
inlet_h_branch1: float,
inlet_h_branch2: float,
branch1_h: float,
branch2_h: float,
inlet_h: float,
q_in_branch1: float,
q_in_branch2: float,
tolerance: float = 1e-12,
) -> tuple[float, float]:
"""Solve branch outlet flows for the current three-port downstream tee use-case."""
rhs_branch1 = q_in_branch1 * inlet_h_branch1
rhs_branch2 = q_in_branch2 * inlet_h_branch2
def solve_both_forward() -> tuple[float, float] | None:
return self._solve_linear_2x2(
(1.0 + ratio_branch1) * branch1_h,
ratio_branch1 * branch2_h,
ratio_branch2 * branch1_h,
(1.0 + ratio_branch2) * branch2_h,
rhs_branch1,
rhs_branch2,
)
def solve_one_reverse(
*,
branch1_reverse: bool,
) -> tuple[float, float] | None:
if branch1_reverse:
return self._solve_linear_2x2(
inlet_h * (1.0 + ratio_branch1),
ratio_branch1 * inlet_h,
ratio_branch2 * inlet_h,
branch2_h + ratio_branch2 * inlet_h,
rhs_branch1,
rhs_branch2,
)
return self._solve_linear_2x2(
branch1_h + ratio_branch1 * inlet_h,
ratio_branch1 * inlet_h,
ratio_branch2 * inlet_h,
inlet_h * (1.0 + ratio_branch2),
rhs_branch1,
rhs_branch2,
)
def solve_both_reverse() -> tuple[float, float] | None:
return self._solve_linear_2x2(
inlet_h * (1.0 + ratio_branch1),
ratio_branch1 * inlet_h,
ratio_branch2 * inlet_h,
inlet_h * (1.0 + ratio_branch2),
rhs_branch1,
rhs_branch2,
)
candidate_solvers = (
(
solve_both_forward,
lambda q1, q2: q1 >= -tolerance and q2 >= -tolerance,
),
(
lambda: solve_one_reverse(branch1_reverse=True),
lambda q1, q2: q1 < -tolerance and q2 >= -tolerance and q1 + q2 > tolerance,
),
(
lambda: solve_one_reverse(branch1_reverse=True),
lambda q1, q2: q1 < -tolerance and q2 >= -tolerance and q1 + q2 <= tolerance,
),
(
lambda: solve_one_reverse(branch1_reverse=False),
lambda q1, q2: q2 < -tolerance and q1 >= -tolerance and q1 + q2 > tolerance,
),
(
lambda: solve_one_reverse(branch1_reverse=False),
lambda q1, q2: q2 < -tolerance and q1 >= -tolerance and q1 + q2 <= tolerance,
),
(
solve_both_reverse,
lambda q1, q2: q1 < -tolerance and q2 < -tolerance,
),
)
for solver, predicate in candidate_solvers:
candidate = solver()
if candidate is None:
continue
q_out_branch1, q_out_branch2 = candidate
if predicate(q_out_branch1, q_out_branch2):
return q_out_branch1, q_out_branch2
return solve_both_forward() or (0.0, 0.0)
@@ -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."""
@@ -0,0 +1,155 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
class Cylinder(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mycylinder."""
MODEL_TYPE = "cylinder"
MODEL_VERSION = "1.0.0"
PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),)
PARAMETERS = (
ParameterDefinition(
"volume",
0.01,
label="容积",
quantity="volume",
unit="m3",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"p0",
35e6,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
label="气瓶",
library_id="experimental",
category_id="storage",
symbol="cylinder",
ports=(PortDisplaySpec("port_b", "right"),),
order=10,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
V: float = 0.01,
p0: float = 35e6,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values({"volume": V, "p0": p0, "T0": T0})
self.medium = medium
self.V = V
m0 = p0 * V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_b = self.register_declared_port("port_b")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Cylinder:
return cls(
name=name,
medium=medium,
V=parameters["volume"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
self.port_b.p = props.p
self.port_b.h_outflow = props.h
return props
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
return self.properties()
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
properties = self.properties()
derivative = self.derivatives_from_connection(
connected_h=connected_h["port_b"],
port_m_flow=self.port_b.m_flow,
internal_h=properties.h,
)
return derivative.as_vector()
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.V,
).p
return (
EquationResidual(
id=f"{self.name}:port_b_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_b.p", f"{self.name}.state"),
role="effort",
value=self.port_b.p - pressure,
),
)
def derivatives_from_connection(
self,
*,
connected_h: float,
port_m_flow: float,
internal_h: float,
) -> VolumeState:
inlet_h = self.connection_inlet_enthalpy(
port_m_flow=port_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
return self.derivatives(inlet_h, port_m_flow)
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
return VolumeState(m=m_flow, U=m_flow * inlet_h)
@@ -0,0 +1,155 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
class Tank(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mytank."""
MODEL_TYPE = "tank"
MODEL_VERSION = "1.0.0"
PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),)
PARAMETERS = (
ParameterDefinition(
"volume",
0.1,
label="容积",
quantity="volume",
unit="m3",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"p0",
1e5,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
label="贮箱",
library_id="experimental",
category_id="storage",
symbol="tank",
ports=(PortDisplaySpec("port_a", "left"),),
order=20,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
V: float = 0.1,
p0: float = 1e5,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values({"volume": V, "p0": p0, "T0": T0})
self.medium = medium
self.V = V
m0 = p0 * V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_a = self.register_declared_port("port_a")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Tank:
return cls(
name=name,
medium=medium,
V=parameters["volume"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
self.port_a.p = props.p
self.port_a.h_outflow = props.h
return props
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
return self.properties()
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
properties = self.properties()
derivative = self.derivatives_from_connection(
connected_h=connected_h["port_a"],
port_m_flow=self.port_a.m_flow,
internal_h=properties.h,
)
return derivative.as_vector()
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.V,
).p
return (
EquationResidual(
id=f"{self.name}:port_a_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_a.p", f"{self.name}.state"),
role="effort",
value=self.port_a.p - pressure,
),
)
def derivatives_from_connection(
self,
*,
connected_h: float,
port_m_flow: float,
internal_h: float,
) -> VolumeState:
inlet_h = self.connection_inlet_enthalpy(
port_m_flow=port_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
return self.derivatives(inlet_h, port_m_flow)
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
return VolumeState(m=m_flow, U=m_flow * inlet_h)
+2
View File
@@ -0,0 +1,2 @@
"""Core abstractions for the Python system model."""
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from app.simulation.core.catalog import ComponentDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
ResultVariableDefinition,
ResultVariableMetadata,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.ports import PortDefinition, PortState
if TYPE_CHECKING:
from app.simulation.core.medium import IdealGasMedium
class Component(ABC):
MODEL_TYPE: ClassVar[str | None] = None
MODEL_VERSION: ClassVar[str | None] = None
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
DISPLAY: ClassVar[ComponentDisplaySpec | None] = None
def __init__(self, name: str) -> None:
self.name = name
self.model_type = self.MODEL_TYPE or self.__class__.__name__.lower()
self._ports: dict[str, PortState] = {}
self._parameter_values: dict[str, float] = {}
@property
def ports(self) -> dict[str, PortState]:
return dict(self._ports)
@property
def port_definitions(self) -> tuple[PortDefinition, ...]:
return tuple(
port.definition
for port in self._ports.values()
if port.definition is not None
)
def register_port(self, port: PortState) -> PortState:
definition = port.definition
if definition is None:
raise ValueError(f"Component {self.name} cannot register an undefined port.")
if definition.name in self._ports:
raise ValueError(f"Duplicate port {self.name}.{definition.name}.")
self._ports[definition.name] = port
return port
def register_declared_port(self, name: str) -> PortState:
try:
definition = next(item for item in self.PORTS if item.name == name)
except StopIteration as exc:
raise ValueError(
f"Component model {self.model_type} does not declare port {name}."
) from exc
return self.register_port(PortState(definition=definition))
def set_parameter_values(self, values: Mapping[str, float]) -> None:
definitions = {definition.name: definition for definition in self.PARAMETERS}
unknown = sorted(set(values) - set(definitions))
if unknown:
raise ValueError(
f"Component {self.name} contains unsupported parameters: "
+ ", ".join(unknown)
+ "."
)
missing = sorted(set(definitions) - set(values))
if missing:
raise ValueError(
f"Component {self.name} is missing parameters: "
+ ", ".join(missing)
+ "."
)
resolved: dict[str, float] = {}
for name, definition in definitions.items():
value = float(values[name])
message = definition.validation_message(value)
if message is not None:
raise ValueError(
f"Parameter '{name}' on component '{self.name}' {message}."
)
resolved[name] = value
self._parameter_values = resolved
@property
def parameter_values(self) -> dict[str, float]:
return dict(self._parameter_values)
def get_port(self, name: str) -> PortState:
try:
return self._ports[name]
except KeyError as exc:
raise ValueError(f"Component {self.name} has no port named {name}.") from exc
def component_result_values(self) -> Mapping[str, float]:
return {}
def result_values(self) -> dict[str, float]:
component_values = dict(self.component_result_values())
declared = {definition.name: definition for definition in self.RESULT_VARIABLES}
unknown = sorted(set(component_values) - set(declared))
if unknown:
raise ValueError(
f"Component {self.name} returned undeclared result variables: "
+ ", ".join(unknown)
+ "."
)
values: dict[str, float] = {}
for name, definition in declared.items():
if not definition.visible:
continue
if name not in component_values:
raise ValueError(
f"Component {self.name} did not provide declared result variable {name}."
)
values[name] = float(component_values[name])
for port_definition in self.port_definitions:
port = self.get_port(port_definition.name)
for variable in port_definition.variables:
if not variable.result_visible:
continue
values[f"{port_definition.name}.{variable.name}"] = float(
getattr(port, variable.name)
)
return values
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
metadata = [
ResultVariableMetadata(
key=f"{self.name}.{definition.name}",
component_id=self.name,
component_type=self.model_type,
scope="component",
name=definition.name,
label=definition.label,
quantity=definition.quantity,
unit=definition.unit,
category=definition.category,
order=definition.order,
)
for definition in self.RESULT_VARIABLES
if definition.visible
]
for port_definition in self.port_definitions:
for variable in port_definition.variables:
if not variable.result_visible:
continue
metadata.append(
ResultVariableMetadata(
key=f"{self.name}.{port_definition.name}.{variable.name}",
component_id=self.name,
component_type=self.model_type,
scope="port",
port_name=port_definition.name,
name=variable.name,
label=variable.label or variable.name,
quantity=variable.quantity or variable.name,
unit=variable.unit,
category=variable.role,
order=variable.order,
)
)
return tuple(metadata)
def parameter_interface_dicts(self) -> list[dict[str, object]]:
return [
definition.as_interface_dict(
value=self._parameter_values.get(definition.name)
)
for definition in self.PARAMETERS
]
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Component:
"""Create a catalog model from normalized SI parameters."""
raise NotImplementedError(
f"Component model {cls.__name__} must implement create()."
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
"""Return algebraic residuals after the network assigns port states."""
return ()
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
"""Update connector outflow properties from current flow directions."""
return None
class DynamicComponent(Component):
state_size = 2
@staticmethod
def actual_stream_enthalpy(
port_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
"""Approximate `actualStream(port.h_outflow)` for a mixed control volume port."""
return connected_h if port_m_flow > 0.0 else internal_h
def connection_inlet_enthalpy(
self,
port_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
"""Resolve the enthalpy convected into this control volume through one port."""
return self.actual_stream_enthalpy(
port_m_flow=port_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
@abstractmethod
def get_state_vector(self) -> list[float]:
raise NotImplementedError
@abstractmethod
def set_state_vector(self, values: list[float]) -> None:
raise NotImplementedError
def refresh_thermodynamic_ports(self) -> Any:
raise NotImplementedError
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
raise NotImplementedError
class ThermodynamicVolumeComponent(DynamicComponent):
"""Two-state gas volume exposing the shared thermodynamic result contract."""
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
def component_result_values(self) -> Mapping[str, float]:
state = self.get_state_vector()
if len(state) < 2:
raise ValueError(
f"Thermodynamic component {self.name} must expose mass and energy states."
)
properties = self.refresh_thermodynamic_ports()
return {
"m": float(state[0]),
"U": float(state[1]),
"p": float(properties.p),
"T": float(properties.T),
"rho": float(properties.rho),
"u": float(properties.u),
"h": float(properties.h),
}
class AlgebraicComponent(Component):
"""Stateless element described by algebraic constraints only."""
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
PortDisplaySide = Literal["left", "right"]
@dataclass(frozen=True)
class ComponentCategorySpec:
"""A presentation-only category declared by one component library."""
id: str
label: str
order: int = 0
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"order": self.order,
}
@dataclass(frozen=True)
class PortDisplaySpec:
"""Canvas placement for one port without changing its physical contract."""
name: str
side: PortDisplaySide
order: int = 0
@dataclass(frozen=True)
class ComponentDisplaySpec:
"""Frontend metadata co-located with a component implementation."""
label: str
library_id: str
category_id: str
symbol: str
ports: tuple[PortDisplaySpec, ...]
order: int = 0
@property
def port_by_name(self) -> dict[str, PortDisplaySpec]:
return {port.name: port for port in self.ports}
@dataclass(frozen=True)
class ComponentLibrarySpec:
"""Manifest for one explicitly enabled component library."""
id: str
label: str
version: str
source_package: str
categories: tuple[ComponentCategorySpec, ...]
models: tuple[str, ...]
temporary: bool = False
order: int = 0
@property
def category_by_id(self) -> dict[str, ComponentCategorySpec]:
return {category.id: category for category in self.categories}
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"version": self.version,
"sourcePackage": self.source_package,
"temporary": self.temporary,
"order": self.order,
"categories": [
category.as_catalog_dict()
for category in sorted(
self.categories,
key=lambda item: (item.order, item.id),
)
],
}
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from app.simulation.core.ports import VariableRole
EquationOwner = Literal["connection", "component"]
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
@dataclass(frozen=True)
class EquationResidual:
"""One executable scalar equation in the pressure-flow subsystem."""
id: str
owner: EquationOwner
owner_id: str
relation: EquationRelation
variables: tuple[str, ...]
value: float
role: VariableRole | None = None
def as_definition_dict(self) -> dict[str, object]:
return {
"id": self.id,
"owner": self.owner,
"ownerId": self.owner_id,
"relation": self.relation,
"variables": list(self.variables),
"role": self.role,
}
def as_interface_dict(self) -> dict[str, object]:
return {**self.as_definition_dict(), "residual": self.value}
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ThermodynamicProperties:
p: float
T: float
rho: float
u: float
h: float
@dataclass(frozen=True)
class IdealGasMedium:
"""Temperature-dependent ideal-gas air approximation.
This is still not a strict clone of `Modelica.Media.Air.SimpleAir`.
The small linear `cp(T)` term is kept configurable for calibration, but the
current default is calibrated against the committed Testmodel baseline and
therefore falls back to the constant-heat-capacity limit.
"""
name: str = "SimpleAirApprox"
R_gas: float = 287.0
cp_ref: float = 1005.0
T_ref: float = 300.0
cp_slope: float = 0.0
@property
def cv(self) -> float:
return self.cv_at_temperature(self.T_ref)
@property
def gamma(self) -> float:
return self.cp_at_temperature(self.T_ref) / self.cv
def cp_at_temperature(self, T: float) -> float:
return self.cp_ref + self.cp_slope * (T - self.T_ref)
def cv_at_temperature(self, T: float) -> float:
return self.cp_at_temperature(T) - self.R_gas
def density(self, p: float, T: float) -> float:
return p / (self.R_gas * T)
def specific_internal_energy(self, T: float) -> float:
delta_T = T - self.T_ref
return (
self.cv * self.T_ref
+ self.cv * delta_T
+ 0.5 * self.cp_slope * delta_T * delta_T
)
def specific_enthalpy(self, T: float) -> float:
delta_T = T - self.T_ref
return (
self.cp_ref * self.T_ref
+ self.cp_ref * delta_T
+ 0.5 * self.cp_slope * delta_T * delta_T
)
def temperature_from_internal_energy(self, u: float) -> float:
reference_internal_energy = self.cv * self.T_ref
delta_u = u - reference_internal_energy
if abs(self.cp_slope) <= 1e-15:
return self.T_ref + delta_u / self.cv
a = 0.5 * self.cp_slope
b = self.cv
c = -delta_u
discriminant = max(b * b - 4.0 * a * c, 0.0)
positive_root = (-b + discriminant**0.5) / (2.0 * a)
negative_root = (-b - discriminant**0.5) / (2.0 * a)
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
return self.T_ref + delta_T
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
if m <= 0.0:
raise ValueError("Mass must stay positive when recovering temperature.")
return self.temperature_from_internal_energy(U / m)
def pressure(self, m: float, T: float, V: float) -> float:
if V <= 0.0:
raise ValueError("Volume must stay positive.")
return m * self.R_gas * T / V
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
T = self.temperature_from_mass_internal_energy(m, U)
p = self.pressure(m, T, V)
rho = m / V
u = U / m
h = self.specific_enthalpy(T)
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Literal
ResultVariableScope = Literal["component", "port"]
SI_UNIT_BY_QUANTITY: dict[str, str] = {
"area": "m2",
"dimensionless": "",
"density": "kg/m³",
"flow_coefficient": "kg/(s*Pa^0.5)",
"heat_transfer_coefficient": "W/(m2*K)",
"internal_energy": "J",
"length": "m",
"mass": "kg",
"mass_flow": "kg/s",
"pressure": "Pa",
"specific_enthalpy": "J/kg",
"specific_internal_energy": "J/kg",
"temperature": "K",
"velocity": "m/s",
"volume": "m3",
}
@dataclass(frozen=True)
class ParameterDefinition:
"""User-configurable model input expressed in the backend SI contract."""
name: str
default: float
label: str = ""
quantity: str = "dimensionless"
unit: str = ""
minimum: float | None = None
maximum: float | None = None
minimum_exclusive: bool = False
def validation_message(self, value: float) -> str | None:
if not isfinite(value):
return "must be finite"
if self.minimum is not None:
if self.minimum_exclusive and value <= self.minimum:
return f"must be greater than {self.minimum:g}"
if not self.minimum_exclusive and value < self.minimum:
return f"must be at least {self.minimum:g}"
if self.maximum is not None and value > self.maximum:
return f"must be at most {self.maximum:g}"
return None
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
payload: dict[str, object] = {
"name": self.name,
"label": self.label or self.name,
"quantity": self.quantity,
"unit": self.unit,
"default": self.default,
"minimumExclusive": self.minimum_exclusive,
}
if self.minimum is not None:
payload["minimum"] = self.minimum
if self.maximum is not None:
payload["maximum"] = self.maximum
if value is not None:
payload["value"] = value
return payload
@dataclass(frozen=True)
class ResultVariableDefinition:
"""Component-relative declaration of a user-visible simulation result."""
name: str
label: str
quantity: str
unit: str = ""
category: str = "derived"
order: int = 0
visible: bool = True
@dataclass(frozen=True)
class ResultVariableMetadata:
"""A result declaration bound to one concrete component instance."""
key: str
component_id: str
component_type: str
scope: ResultVariableScope
name: str
label: str
quantity: str
unit: str
category: str
order: int
port_name: str | None = None
def as_dict(self) -> dict[str, object]:
return {
"key": self.key,
"componentId": self.component_id,
"componentType": self.component_type,
"scope": self.scope,
"portName": self.port_name,
"name": self.name,
"label": self.label,
"quantity": self.quantity,
"unit": self.unit,
"category": self.category,
"order": self.order,
}
THERMODYNAMIC_VOLUME_RESULT_VARIABLES = (
ResultVariableDefinition(
name="m",
label="质量",
quantity="mass",
unit="kg",
category="state",
order=10,
),
ResultVariableDefinition(
name="U",
label="内能",
quantity="internal_energy",
unit="J",
category="state",
order=20,
),
ResultVariableDefinition(
name="p",
label="压力",
quantity="pressure",
unit="Pa",
category="thermodynamic",
order=30,
),
ResultVariableDefinition(
name="T",
label="温度",
quantity="temperature",
unit="K",
category="thermodynamic",
order=40,
),
ResultVariableDefinition(
name="rho",
label="密度",
quantity="density",
unit="kg/m³",
category="thermodynamic",
order=50,
),
ResultVariableDefinition(
name="u",
label="比内能",
quantity="specific_internal_energy",
unit="J/kg",
category="thermodynamic",
order=60,
),
ResultVariableDefinition(
name="h",
label="比焓",
quantity="specific_enthalpy",
unit="J/kg",
category="thermodynamic",
order=70,
),
)
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
PortKind = Literal["physical", "signal"]
PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"]
ActualFlowDirection = Literal["in", "out", "stagnant"]
VariableRole = Literal["effort", "flow", "stream", "signal"]
ConnectionRule = Literal["equal", "sumToZero", "streamMix", "directed"]
@dataclass(frozen=True)
class PortVariableDefinition:
name: str
role: VariableRole
connection_rule: ConnectionRule
label: str = field(default="", compare=False)
quantity: str = field(default="", compare=False)
unit: str = field(default="", compare=False)
result_visible: bool = field(default=True, compare=False)
order: int = field(default=0, compare=False)
def as_interface_dict(self) -> dict[str, object]:
return {
"name": self.name,
"role": self.role,
"connectionRule": self.connection_rule,
"label": self.label or self.name,
"quantity": self.quantity or self.name,
"unit": self.unit,
"resultVisible": self.result_visible,
"order": self.order,
}
@dataclass(frozen=True)
class PortDefinition:
"""Stable connector contract shared by components, XML, and the compiler."""
name: str
kind: PortKind
domain: str
nominal_role: PortNominalRole
positive_flow_direction: Literal["intoComponent"] | None = None
variables: tuple[PortVariableDefinition, ...] = ()
@classmethod
def pneumatic(
cls,
name: str,
*,
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
) -> PortDefinition:
return cls(
name=name,
kind="physical",
domain="pneumatic",
nominal_role=nominal_role,
positive_flow_direction="intoComponent",
variables=(
PortVariableDefinition(
"p",
"effort",
"equal",
label="压力",
quantity="pressure",
unit="Pa",
order=10,
),
PortVariableDefinition(
"m_flow",
"flow",
"sumToZero",
label="质量流量",
quantity="mass_flow",
unit="kg/s",
order=20,
),
PortVariableDefinition(
"h_outflow",
"stream",
"streamMix",
label="流出比焓",
quantity="specific_enthalpy",
unit="J/kg",
order=30,
),
),
)
def as_interface_dict(self) -> dict[str, object]:
return {
"name": self.name,
"kind": self.kind,
"domain": self.domain,
"nominalRole": self.nominal_role,
"positiveFlowDirection": self.positive_flow_direction,
"variables": [variable.as_interface_dict() for variable in self.variables],
}
@dataclass
class PortState:
"""Python-side analogue of a Modelica fluid port."""
p: float = 0.0
m_flow: float = 0.0
h_outflow: float = 0.0
definition: PortDefinition | None = field(default=None, repr=False, compare=False)
@classmethod
def pneumatic(
cls,
name: str,
*,
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
) -> PortState:
return cls(definition=PortDefinition.pneumatic(name, nominal_role=nominal_role))
@property
def inflow_rate(self) -> float:
return max(self.m_flow, 0.0)
@property
def outflow_rate(self) -> float:
return max(-self.m_flow, 0.0)
def actual_direction(self, tolerance: float = 1e-12) -> ActualFlowDirection:
if self.m_flow > tolerance:
return "in"
if self.m_flow < -tolerance:
return "out"
return "stagnant"
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class VolumeState:
"""Primary dynamic state for rigid adiabatic control volumes."""
m: float
U: float
def as_vector(self) -> list[float]:
return [self.m, self.U]
@classmethod
def from_vector(cls, values: list[float]) -> "VolumeState":
if len(values) != 2:
raise ValueError("VolumeState requires exactly two values: [m, U].")
return cls(m=values[0], U=values[1])
+1
View File
@@ -0,0 +1 @@
"""Reference systems and regression examples."""
@@ -0,0 +1,17 @@
from __future__ import annotations
from app.simulation.examples.test_mql.run import (
PreparedTestMqlRun,
TestMqlRunResult,
prepare_test_mql_run,
run_prepared_test_mql,
run_test_mql,
)
__all__ = [
"PreparedTestMqlRun",
"TestMqlRunResult",
"prepare_test_mql_run",
"run_prepared_test_mql",
"run_test_mql",
]
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR
from PythonModels.scripts.run_test_mql import format_test_mql_summary
from PythonModels.systems.test_mql import (
TestMqlRunConfig,
TestMqlSimulationResult,
TestMqlSystem,
)
@dataclass(frozen=True)
class PreparedTestMqlRun:
run_config: TestMqlRunConfig
repo_root: Path
output_dir: Path
@dataclass(frozen=True)
class TestMqlRunResult:
run_config: TestMqlRunConfig
prepared_run: PreparedTestMqlRun
system: TestMqlSystem
result: TestMqlSimulationResult
summary_path: Path
def _default_run_output_dir() -> Path:
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
return SIMULATION_RUNS_DIR / timestamp
def prepare_test_mql_run(
*,
run_config: TestMqlRunConfig | None = None,
output_dir: Path | None = None,
) -> PreparedTestMqlRun:
return PreparedTestMqlRun(
run_config=run_config or TestMqlRunConfig(),
repo_root=PROJECT_ROOT,
output_dir=output_dir or _default_run_output_dir(),
)
def run_prepared_test_mql(prepared_run: PreparedTestMqlRun) -> TestMqlRunResult:
system = TestMqlSystem()
result = system.simulate(prepared_run.run_config)
prepared_run.output_dir.mkdir(parents=True, exist_ok=True)
summary_path = prepared_run.output_dir / "test_mql_model_summary.txt"
summary_path.write_text(format_test_mql_summary(system), encoding="utf-8")
return TestMqlRunResult(
run_config=prepared_run.run_config,
prepared_run=prepared_run,
system=system,
result=result,
summary_path=summary_path,
)
def run_test_mql(
*,
run_config: TestMqlRunConfig | None = None,
output_dir: Path | None = None,
) -> TestMqlRunResult:
return run_prepared_test_mql(
prepare_test_mql_run(run_config=run_config, output_dir=output_dir)
)
def main() -> None:
run = run_test_mql()
print(format_test_mql_summary(run.system), end="")
print(f"Samples: {len(run.result.t)}")
print(f"Output directory: {run.prepared_run.output_dir}")
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
"""Legacy TestModel reference system."""
@@ -0,0 +1,668 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
from app.simulation.components.experimental.flow.orifice import Orifice
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
from app.simulation.components.experimental.junctions.tee import Tee
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.state import VolumeState
@dataclass(frozen=True)
class BranchInletFlowDiagnostics:
converged: bool
iterations: int
residual: float
m_flow: float
inlet_pressure: float
@dataclass(frozen=True)
class DownstreamPressureDiagnostics:
converged: bool
iterations: int
residual: float
pressure: float
target_total_internal_energy: float
@dataclass(frozen=True)
class TestModelSolveDiagnostics:
upper_branch_inlet: BranchInletFlowDiagnostics
lower_branch_inlet: BranchInletFlowDiagnostics
downstream_pressure_projection: DownstreamPressureDiagnostics | None
@dataclass(frozen=True)
class BranchClosureComponents:
name: str
orifice: Orifice
pipe: Pipe
@dataclass(frozen=True)
class BranchClosureState:
name: str
pipe: ThermodynamicProperties
inlet_flow: float
outlet_flow: float
inlet_h: float
inlet_flow_diagnostics: BranchInletFlowDiagnostics
@dataclass(frozen=True)
class BranchSnapshot:
name: str
pipe: ThermodynamicProperties
inlet_flow: float
outlet_flow: float
inlet_h: float
inlet_flow_diagnostics: BranchInletFlowDiagnostics
@dataclass(frozen=True)
class TestModelSnapshot:
cylinder: ThermodynamicProperties
tank: ThermodynamicProperties
tee_upstream_h: float
tee_downstream_h: float
branches: tuple[BranchSnapshot, ...] = field(default_factory=tuple)
solve_diagnostics: TestModelSolveDiagnostics | None = None
@property
def pipe_upper(self) -> ThermodynamicProperties:
return self.branches[0].pipe
@property
def pipe_lower(self) -> ThermodynamicProperties:
return self.branches[1].pipe
@property
def branch_inlet_flows(self) -> tuple[float, ...]:
return tuple(branch.inlet_flow for branch in self.branches)
@property
def branch_outlet_flows(self) -> tuple[float, ...]:
return tuple(branch.outlet_flow for branch in self.branches)
@dataclass(frozen=True)
class InitializationDiagnostics:
converged: bool
iterations: int
max_state_delta: float
max_flow_delta: float
max_enthalpy_delta: float
downstream_pressure_spread: float
state_vector: tuple[float, ...]
@dataclass(frozen=True)
class TestModelClosureComponents:
cylinder: Cylinder
upstream_tee: Tee
upper_branch: BranchClosureComponents
lower_branch: BranchClosureComponents
downstream_tee: Tee
tank: Tank
def branches(self) -> tuple[BranchClosureComponents, BranchClosureComponents]:
return (self.upper_branch, self.lower_branch)
class TestModelClosure:
"""Owns Testmodel-specific closure, projection and port-writeback logic."""
def __init__(
self,
*,
medium: IdealGasMedium,
components: TestModelClosureComponents,
initial_state_vector: Callable[[], list[float]],
apply_state_vector: Callable[[list[float]], None],
) -> None:
self.medium = medium
self.components = components
self._initial_state_vector = initial_state_vector
self._apply_state_vector = apply_state_vector
self.last_solve_diagnostics: TestModelSolveDiagnostics | None = None
self.last_downstream_pressure_diagnostics: DownstreamPressureDiagnostics | None = None
@staticmethod
def _downstream_pressure_spread(snapshot: TestModelSnapshot) -> float:
downstream_pressures = tuple(branch.pipe.p for branch in snapshot.branches) + (
snapshot.tank.p,
)
return max(downstream_pressures) - min(downstream_pressures)
@staticmethod
def _initialization_flow_delta(
previous_snapshot: TestModelSnapshot | None,
current_snapshot: TestModelSnapshot,
) -> float:
if previous_snapshot is None:
return max(abs(branch.outlet_flow) for branch in current_snapshot.branches)
return max(
abs(curr - prev)
for curr, prev in zip(
current_snapshot.branch_outlet_flows,
previous_snapshot.branch_outlet_flows,
)
)
@staticmethod
def _initialization_enthalpy_delta(
previous_snapshot: TestModelSnapshot | None,
current_snapshot: TestModelSnapshot,
) -> float:
if previous_snapshot is None:
return abs(current_snapshot.tee_downstream_h - current_snapshot.tank.h)
return max(
abs(current_snapshot.tee_upstream_h - previous_snapshot.tee_upstream_h),
abs(current_snapshot.tee_downstream_h - previous_snapshot.tee_downstream_h),
)
def consistent_initial_state_vector(self) -> list[float]:
return list(self.initialize_consistent_state().state_vector)
def initialize_consistent_state(
self,
max_iterations: int = 12,
state_tolerance: float = 1e-9,
flow_tolerance: float = 1e-9,
enthalpy_tolerance: float = 1e-6,
pressure_tolerance: float = 1e-6,
strict_internal_solvers: bool = False,
) -> InitializationDiagnostics:
raw_state = self._initial_state_vector()
previous_snapshot: TestModelSnapshot | None = None
diagnostics: InitializationDiagnostics | None = None
for iteration in range(1, max_iterations + 1):
state_before_projection = self._initial_state_vector()
self.snapshot(state_before_projection, strict=strict_internal_solvers)
self.project_downstream_pressure_constraints(strict=strict_internal_solvers)
state_after_projection = self._initial_state_vector()
snapshot_after_projection = self.snapshot(
state_after_projection,
strict=strict_internal_solvers,
)
max_state_delta = max(
abs(after - before)
for before, after in zip(state_before_projection, state_after_projection)
)
max_flow_delta = self._initialization_flow_delta(
previous_snapshot,
snapshot_after_projection,
)
max_enthalpy_delta = self._initialization_enthalpy_delta(
previous_snapshot,
snapshot_after_projection,
)
downstream_pressure_spread = self._downstream_pressure_spread(
snapshot_after_projection,
)
diagnostics = InitializationDiagnostics(
converged=(
max_state_delta <= state_tolerance
and max_flow_delta <= flow_tolerance
and max_enthalpy_delta <= enthalpy_tolerance
and downstream_pressure_spread <= pressure_tolerance
),
iterations=iteration,
max_state_delta=max_state_delta,
max_flow_delta=max_flow_delta,
max_enthalpy_delta=max_enthalpy_delta,
downstream_pressure_spread=downstream_pressure_spread,
state_vector=tuple(state_after_projection),
)
previous_snapshot = snapshot_after_projection
if diagnostics.converged:
self._apply_state_vector(raw_state)
return diagnostics
assert diagnostics is not None
self._apply_state_vector(raw_state)
return diagnostics
def _solve_branch_inlet_flow(
self,
orifice: Orifice,
pipe: Pipe,
p_upstream: float,
pipe_props: ThermodynamicProperties,
*,
strict: bool = False,
) -> tuple[float, BranchInletFlowDiagnostics]:
m_flow = orifice.mass_flow(p_upstream, pipe_props.p)
rho = max(pipe_props.rho, 1e-9)
p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p)
residual = abs(orifice.mass_flow(p_upstream, p_inlet) - m_flow)
converged = False
iterations = 0
for iteration in range(1, 9):
p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p)
next_m_flow = orifice.mass_flow(p_upstream, p_inlet)
residual = abs(next_m_flow - m_flow)
iterations = iteration
if residual <= 1e-9 * max(1.0, abs(next_m_flow)):
m_flow = next_m_flow
converged = True
break
m_flow = next_m_flow
diagnostics = BranchInletFlowDiagnostics(
converged=converged,
iterations=iterations,
residual=residual,
m_flow=m_flow,
inlet_pressure=p_inlet,
)
if strict and not diagnostics.converged:
raise RuntimeError(
f"Branch inlet flow solve did not converge for {pipe.name}: residual={residual:.6e}"
)
return m_flow, diagnostics
def _solve_downstream_branch_flows(
self,
cylinder: ThermodynamicProperties,
tank: ThermodynamicProperties,
branch_states: tuple[BranchClosureState, BranchClosureState],
) -> tuple[float, float]:
return self._solve_downstream_branch_flows_from_state(
inlet_h_upper=branch_states[0].inlet_h,
inlet_h_lower=branch_states[1].inlet_h,
pipe_upper_h=max(branch_states[0].pipe.h, 1e-9),
pipe_lower_h=max(branch_states[1].pipe.h, 1e-9),
tank_h=max(tank.h, 1e-9),
q_in_upper=branch_states[0].inlet_flow,
q_in_lower=branch_states[1].inlet_flow,
)
def _project_volume_energy_to_pressure(
self,
component: Pipe | Tank,
target_pressure: float,
) -> None:
target_temperature = target_pressure * component.V / (
max(component.state.m, 1e-12) * self.medium.R_gas
)
target_internal_energy = (
component.state.m * self.medium.specific_internal_energy(target_temperature)
)
component.state = VolumeState(m=component.state.m, U=target_internal_energy)
def _downstream_total_internal_energy_for_pressure(
self,
target_pressure: float,
downstream_components: tuple[Pipe | Tank, ...],
) -> float:
total_internal_energy = 0.0
for component in downstream_components:
target_temperature = target_pressure * component.V / (
max(component.state.m, 1e-12) * self.medium.R_gas
)
total_internal_energy += (
component.state.m * self.medium.specific_internal_energy(target_temperature)
)
return total_internal_energy
def _solve_downstream_common_pressure(
self,
downstream_components: tuple[Pipe | Tank, ...],
target_total_internal_energy: float,
*,
strict: bool = False,
) -> tuple[float, DownstreamPressureDiagnostics]:
lower_pressure = 1.0
upper_pressure = max(component.properties().p for component in downstream_components)
upper_pressure = max(upper_pressure, 1e5)
def residual(pressure: float) -> float:
return (
self._downstream_total_internal_energy_for_pressure(
pressure,
downstream_components,
)
- target_total_internal_energy
)
upper_residual = residual(upper_pressure)
iteration_count = 0
while upper_residual < 0.0:
upper_pressure *= 2.0
upper_residual = residual(upper_pressure)
final_pressure = 0.5 * (lower_pressure + upper_pressure)
final_residual = residual(final_pressure)
converged = False
for iteration in range(1, 81):
middle_pressure = 0.5 * (lower_pressure + upper_pressure)
middle_residual = residual(middle_pressure)
iteration_count = iteration
final_pressure = middle_pressure
final_residual = middle_residual
if abs(middle_residual) <= 1e-12 * max(1.0, target_total_internal_energy):
converged = True
break
if middle_residual > 0.0:
upper_pressure = middle_pressure
else:
lower_pressure = middle_pressure
diagnostics = DownstreamPressureDiagnostics(
converged=converged,
iterations=iteration_count,
residual=final_residual,
pressure=final_pressure,
target_total_internal_energy=target_total_internal_energy,
)
if strict and not diagnostics.converged:
raise RuntimeError(
"Downstream common-pressure solve did not converge: "
f"residual={final_residual:.6e}"
)
return final_pressure, diagnostics
def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None:
downstream_components = (
self.components.upper_branch.pipe,
self.components.lower_branch.pipe,
self.components.tank,
)
total_internal_energy = sum(component.state.U for component in downstream_components)
common_pressure, diagnostics = self._solve_downstream_common_pressure(
downstream_components,
total_internal_energy,
strict=strict,
)
self.last_downstream_pressure_diagnostics = diagnostics
for component in downstream_components:
self._project_volume_energy_to_pressure(component, common_pressure)
def _downstream_connection_enthalpy(
self,
q_out_upper: float,
q_out_lower: float,
pipe_upper_h: float,
pipe_lower_h: float,
tank_h: float,
) -> float:
return self.components.downstream_tee.inlet_stream_enthalpy(
q_out_lower,
pipe_lower_h,
q_out_upper,
pipe_upper_h,
fallback_h=tank_h,
)
def _solve_downstream_branch_flows_from_state(
self,
*,
inlet_h_upper: float,
inlet_h_lower: float,
pipe_upper_h: float,
pipe_lower_h: float,
tank_h: float,
q_in_upper: float,
q_in_lower: float,
) -> tuple[float, float]:
return self.components.downstream_tee.solve_branch_outlet_flows_from_energy_balance(
ratio_branch1=self.components.upper_branch.pipe.V / self.components.tank.V,
ratio_branch2=self.components.lower_branch.pipe.V / self.components.tank.V,
inlet_h_branch1=inlet_h_upper,
inlet_h_branch2=inlet_h_lower,
branch1_h=pipe_upper_h,
branch2_h=pipe_lower_h,
inlet_h=tank_h,
q_in_branch1=q_in_upper,
q_in_branch2=q_in_lower,
)
def _evaluate_branch_states(
self,
cylinder: ThermodynamicProperties,
) -> tuple[BranchClosureState, BranchClosureState]:
states: list[BranchClosureState] = []
for branch in self.components.branches():
pipe_properties = branch.pipe.properties()
inlet_flow, inlet_flow_diagnostics = self._solve_branch_inlet_flow(
branch.orifice,
branch.pipe,
cylinder.p,
pipe_properties,
)
inlet_h = branch.pipe.port_a_inlet_enthalpy(
port_a_m_flow=inlet_flow,
connected_h=cylinder.h,
internal_h=pipe_properties.h,
)
states.append(
BranchClosureState(
name=branch.name,
pipe=pipe_properties,
inlet_flow=inlet_flow,
outlet_flow=0.0,
inlet_h=inlet_h,
inlet_flow_diagnostics=inlet_flow_diagnostics,
)
)
return (states[0], states[1])
@staticmethod
def _with_branch_outlet_flows(
branch_states: tuple[BranchClosureState, BranchClosureState],
outlet_flows: tuple[float, float],
) -> tuple[BranchClosureState, BranchClosureState]:
return (
BranchClosureState(
name=branch_states[0].name,
pipe=branch_states[0].pipe,
inlet_flow=branch_states[0].inlet_flow,
outlet_flow=outlet_flows[0],
inlet_h=branch_states[0].inlet_h,
inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics,
),
BranchClosureState(
name=branch_states[1].name,
pipe=branch_states[1].pipe,
inlet_flow=branch_states[1].inlet_flow,
outlet_flow=outlet_flows[1],
inlet_h=branch_states[1].inlet_h,
inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics,
),
)
@staticmethod
def _branch_snapshots(
branch_states: tuple[BranchClosureState, BranchClosureState],
) -> tuple[BranchSnapshot, BranchSnapshot]:
return (
BranchSnapshot(
name=branch_states[0].name,
pipe=branch_states[0].pipe,
inlet_flow=branch_states[0].inlet_flow,
outlet_flow=branch_states[0].outlet_flow,
inlet_h=branch_states[0].inlet_h,
inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics,
),
BranchSnapshot(
name=branch_states[1].name,
pipe=branch_states[1].pipe,
inlet_flow=branch_states[1].inlet_flow,
outlet_flow=branch_states[1].outlet_flow,
inlet_h=branch_states[1].inlet_h,
inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics,
),
)
def snapshot(
self,
state_vector: list[float] | None = None,
*,
strict: bool = False,
) -> TestModelSnapshot:
if state_vector is not None:
self._apply_state_vector(list(state_vector))
cylinder = self.components.cylinder.properties()
tank = self.components.tank.properties()
branch_states = self._evaluate_branch_states(cylinder)
if strict:
for branch_state in branch_states:
if not branch_state.inlet_flow_diagnostics.converged:
raise RuntimeError(
"Branch inlet flow solve did not converge for "
f"{branch_state.name}: residual="
f"{branch_state.inlet_flow_diagnostics.residual:.6e}"
)
outlet_flows = self._solve_downstream_branch_flows(cylinder, tank, branch_states)
branch_states = self._with_branch_outlet_flows(branch_states, outlet_flows)
tee_upstream_h = self.components.upstream_tee.inlet_stream_enthalpy(
-branch_states[0].inlet_flow,
branch_states[0].pipe.h,
-branch_states[1].inlet_flow,
branch_states[1].pipe.h,
fallback_h=cylinder.h,
)
tee_downstream_h = self._downstream_connection_enthalpy(
branch_states[0].outlet_flow,
branch_states[1].outlet_flow,
branch_states[0].pipe.h,
branch_states[1].pipe.h,
tank.h,
)
self._write_port_states(
cylinder,
tank,
branch_states,
tee_upstream_h,
tee_downstream_h,
)
solve_diagnostics = TestModelSolveDiagnostics(
upper_branch_inlet=branch_states[0].inlet_flow_diagnostics,
lower_branch_inlet=branch_states[1].inlet_flow_diagnostics,
downstream_pressure_projection=self.last_downstream_pressure_diagnostics,
)
self.last_solve_diagnostics = solve_diagnostics
branch_snapshots = self._branch_snapshots(branch_states)
return TestModelSnapshot(
cylinder=cylinder,
tank=tank,
tee_upstream_h=tee_upstream_h,
tee_downstream_h=tee_downstream_h,
branches=branch_snapshots,
solve_diagnostics=solve_diagnostics,
)
def _write_port_states(
self,
cylinder: ThermodynamicProperties,
tank: ThermodynamicProperties,
branch_states: tuple[BranchClosureState, BranchClosureState],
tee_upstream_h: float,
tee_downstream_h: float,
) -> None:
cylinder_m_flow = -sum(branch_state.inlet_flow for branch_state in branch_states)
tank_m_flow = sum(branch_state.outlet_flow for branch_state in branch_states)
self.components.cylinder.port_b.m_flow = cylinder_m_flow
self.components.upstream_tee.port_in.p = cylinder.p
self.components.upstream_tee.port_out1.p = cylinder.p
self.components.upstream_tee.port_out2.p = cylinder.p
self.components.upstream_tee.port_in.m_flow = -cylinder_m_flow
self.components.upstream_tee.port_in.h_outflow = tee_upstream_h
self.components.upstream_tee.port_out1.h_outflow = cylinder.h
self.components.upstream_tee.port_out2.h_outflow = cylinder.h
self.components.upstream_tee.port_out1.m_flow = -branch_states[0].inlet_flow
self.components.upstream_tee.port_out2.m_flow = -branch_states[1].inlet_flow
for branch_components, branch_state in zip(self.components.branches(), branch_states):
branch_components.orifice.port_a.p = cylinder.p
branch_components.orifice.port_b.p = branch_components.pipe.inlet_pressure(
branch_state.inlet_flow,
max(branch_state.pipe.rho, 1e-9),
branch_state.pipe.p,
)
branch_components.orifice.port_a.m_flow = branch_state.inlet_flow
branch_components.orifice.port_b.m_flow = -branch_state.inlet_flow
branch_components.orifice.port_a.h_outflow = cylinder.h
branch_components.orifice.port_b.h_outflow = branch_state.pipe.h
branch_components.pipe.port_a.p = branch_components.orifice.port_b.p
branch_components.pipe.port_a.m_flow = branch_state.inlet_flow
branch_components.pipe.port_b.m_flow = -branch_state.outlet_flow
branch_components.pipe.port_b.p = branch_state.pipe.p
self.components.downstream_tee.port_in.p = tank.p
self.components.downstream_tee.port_out1.p = tank.p
self.components.downstream_tee.port_out2.p = tank.p
self.components.downstream_tee.port_in.m_flow = -tank_m_flow
self.components.downstream_tee.port_out1.m_flow = branch_states[1].outlet_flow
self.components.downstream_tee.port_out2.m_flow = branch_states[0].outlet_flow
self.components.downstream_tee.port_in.h_outflow = tee_downstream_h
self.components.downstream_tee.port_out1.h_outflow = tank.h
self.components.downstream_tee.port_out2.h_outflow = tank.h
self.components.tank.port_a.m_flow = tank_m_flow
def _branch_derivative_states(
self,
snapshot: TestModelSnapshot,
) -> tuple[VolumeState, VolumeState]:
derivative_states: list[VolumeState] = []
for branch_components, branch_snapshot in zip(self.components.branches(), snapshot.branches):
derivative_states.append(
branch_components.pipe.derivatives_from_connections(
port_a_m_flow=branch_snapshot.inlet_flow,
connected_h_a=snapshot.cylinder.h,
port_b_m_flow=-branch_snapshot.outlet_flow,
connected_h_b=snapshot.tank.h,
internal_h=branch_snapshot.pipe.h,
)
)
return (derivative_states[0], derivative_states[1])
def rhs(self, state_vector: list[float]) -> list[float]:
snapshot = self.snapshot(state_vector)
cylinder_m_flow = -sum(branch.inlet_flow for branch in snapshot.branches)
tank_m_flow = sum(branch.outlet_flow for branch in snapshot.branches)
d_cylinder = self.components.cylinder.derivatives_from_connection(
connected_h=snapshot.tee_upstream_h,
port_m_flow=cylinder_m_flow,
internal_h=snapshot.cylinder.h,
)
branch_derivatives = self._branch_derivative_states(snapshot)
d_tank = self.components.tank.derivatives_from_connection(
connected_h=snapshot.tee_downstream_h,
port_m_flow=tank_m_flow,
internal_h=snapshot.tank.h,
)
return [
d_cylinder.m,
d_cylinder.U,
branch_derivatives[0].m,
branch_derivatives[0].U,
branch_derivatives[1].m,
branch_derivatives[1].U,
d_tank.m,
d_tank.U,
]
@@ -0,0 +1,272 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
class Pipe(ThermodynamicVolumeComponent):
"""Dynamic pipe retained for the fixed TestModel compatibility example."""
MODEL_TYPE = "pipe"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
)
PARAMETERS = (
ParameterDefinition(
"length",
5.0,
label="长度",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"diameter",
0.02,
label="直径",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"lambda_darcy",
0.02,
label="摩阻系数",
minimum=0.0,
),
ParameterDefinition(
"p0",
1e5,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
def __init__(
self,
name: str,
medium: IdealGasMedium,
L: float = 5.0,
D: float = 0.02,
lambda_darcy: float = 0.02,
p0: float = 1e5,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"length": L,
"diameter": D,
"lambda_darcy": lambda_darcy,
"p0": p0,
"T0": T0,
}
)
self.medium = medium
self.L = L
self.D = D
self.lambda_darcy = lambda_darcy
self.area = 3.141592653589793 * D * D / 4.0
self.V = self.area * L
m0 = p0 * self.V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_a = self.register_declared_port("port_a")
self.port_b = self.register_declared_port("port_b")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Pipe:
return cls(
name=name,
medium=medium,
L=parameters["length"],
D=parameters["diameter"],
lambda_darcy=parameters["lambda_darcy"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
self.port_b.p = props.p
self.port_a.h_outflow = props.h
self.port_b.h_outflow = props.h
return props
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
return self.properties()
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
properties = self.properties()
derivative = self.derivatives_from_connections(
port_a_m_flow=self.port_a.m_flow,
connected_h_a=connected_h["port_a"],
port_b_m_flow=self.port_b.m_flow,
connected_h_b=connected_h["port_b"],
internal_h=properties.h,
)
return derivative.as_vector()
def inlet_pressure(self, m_flow_a: float, rho: float, core_pressure: float) -> float:
resistance = self.lambda_darcy * (self.L / self.D)
dynamic_term = m_flow_a * abs(m_flow_a) / (2.0 * rho * self.area * self.area)
return core_pressure + resistance * dynamic_term
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
properties = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.V,
)
expected_inlet_pressure = self.inlet_pressure(
self.port_a.m_flow,
max(properties.rho, 1e-12),
properties.p,
)
return (
EquationResidual(
id=f"{self.name}:darcy_pressure_loss",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_a.p",
f"{self.name}.port_a.m_flow",
f"{self.name}.state",
),
role="effort",
value=self.port_a.p - expected_inlet_pressure,
),
EquationResidual(
id=f"{self.name}:port_b_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_b.p", f"{self.name}.state"),
role="effort",
value=self.port_b.p - properties.p,
),
)
def port_a_inlet_enthalpy(
self,
*,
port_a_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
return self.connection_inlet_enthalpy(
port_m_flow=port_a_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
def port_b_inlet_enthalpy(
self,
*,
port_b_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
return self.connection_inlet_enthalpy(
port_m_flow=port_b_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
def connection_inlet_enthalpies(
self,
*,
port_a_m_flow: float,
connected_h_a: float,
port_b_m_flow: float,
connected_h_b: float,
internal_h: float,
) -> tuple[float, float]:
return (
self.port_a_inlet_enthalpy(
port_a_m_flow=port_a_m_flow,
connected_h=connected_h_a,
internal_h=internal_h,
),
self.port_b_inlet_enthalpy(
port_b_m_flow=port_b_m_flow,
connected_h=connected_h_b,
internal_h=internal_h,
),
)
def derivatives_from_connections(
self,
*,
port_a_m_flow: float,
connected_h_a: float,
port_b_m_flow: float,
connected_h_b: float,
internal_h: float,
) -> VolumeState:
inlet_h_a, inlet_h_b = self.connection_inlet_enthalpies(
port_a_m_flow=port_a_m_flow,
connected_h_a=connected_h_a,
port_b_m_flow=port_b_m_flow,
connected_h_b=connected_h_b,
internal_h=internal_h,
)
return self.derivatives(
inlet_h_a=inlet_h_a,
inlet_h_b=inlet_h_b,
m_flow_a=port_a_m_flow,
m_flow_b=port_b_m_flow,
)
def derivatives(
self,
inlet_h_a: float,
inlet_h_b: float,
m_flow_a: float,
m_flow_b: float,
) -> VolumeState:
dm_dt = m_flow_a + m_flow_b
dU_dt = m_flow_a * inlet_h_a + m_flow_b * inlet_h_b
return VolumeState(m=dm_dt, U=dU_dt)
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from app.simulation.examples.testmodel.closure import TestModelSolveDiagnostics
from app.simulation.examples.testmodel.system import (
InitializationDiagnostics,
TestModelConfig,
TestModelSystem,
)
from app.simulation.paths import (
MODELICA_TESTMODEL_RESULT_PATH,
PROJECT_ROOT,
SIMULATION_RUNS_DIR,
)
from app.simulation.reporting import (
COMPARISON_KEYS,
PRIMARY_KEYS,
TestModelArtifacts,
export_testmodel_artifacts,
format_testmodel_run_report,
load_modelica_series,
write_testmodel_run_report,
)
from app.simulation.solvers.solver import SolveIVPConfig
@dataclass(frozen=True)
class TestModelSamplingConfig:
step: float = 0.1
@dataclass(frozen=True)
class TestModelPathConfig:
output_dir: Path | None = None
modelica_result_path: Path | None = None
@dataclass(frozen=True)
class TestModelExecutionConfig:
use_modelica_reference_if_available: bool = True
@dataclass(frozen=True)
class TestModelRunConfig:
model: TestModelConfig = field(default_factory=TestModelConfig)
solver: SolveIVPConfig = field(default_factory=SolveIVPConfig)
sampling: TestModelSamplingConfig = field(default_factory=TestModelSamplingConfig)
paths: TestModelPathConfig = field(default_factory=TestModelPathConfig)
execution: TestModelExecutionConfig = field(default_factory=TestModelExecutionConfig)
@property
def sample_step(self) -> float:
return self.sampling.step
def sample_times(self) -> list[float]:
return _sample_times(
self.solver.t_start,
self.solver.t_stop,
step=self.sampling.step,
)
@dataclass(frozen=True)
class PreparedTestModelRun:
run_config: TestModelRunConfig
repo_root: Path
output_dir: Path
modelica_result_path: Path
t_eval: tuple[float, ...]
use_modelica_reference_if_available: bool
modelica_reference_exists: bool
@dataclass(frozen=True)
class TestModelRunResult:
run_config: TestModelRunConfig
prepared_run: PreparedTestModelRun
system: TestModelSystem
initialization: InitializationDiagnostics
raw_initial_state: tuple[float, ...]
consistent_initial_state: tuple[float, ...]
solution: object
series: dict[str, list[float]]
solve_diagnostics: TestModelSolveDiagnostics | None
artifacts: TestModelArtifacts
comparison_summary: dict[str, tuple[float, float]] | None
used_modelica_reference: bool
def _sample_times(t_start: float, t_stop: float, step: float) -> list[float]:
point_count = int(round((t_stop - t_start) / step))
return [t_start + index * step for index in range(point_count + 1)]
def _default_run_output_dir() -> Path:
timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f")
return SIMULATION_RUNS_DIR / timestamp
def prepare_testmodel_run(
*,
run_config: TestModelRunConfig | None = None,
output_dir: Path | None = None,
modelica_result_path: Path | None = None,
) -> PreparedTestModelRun:
run_config = run_config or TestModelRunConfig()
resolved_output_dir = (
output_dir
or run_config.paths.output_dir
or _default_run_output_dir()
)
resolved_modelica_result_path = (
modelica_result_path
or run_config.paths.modelica_result_path
or MODELICA_TESTMODEL_RESULT_PATH
)
t_eval = tuple(run_config.sample_times())
return PreparedTestModelRun(
run_config=run_config,
repo_root=PROJECT_ROOT,
output_dir=resolved_output_dir,
modelica_result_path=resolved_modelica_result_path,
t_eval=t_eval,
use_modelica_reference_if_available=run_config.execution.use_modelica_reference_if_available,
modelica_reference_exists=resolved_modelica_result_path.exists(),
)
def run_prepared_testmodel(prepared_run: PreparedTestModelRun) -> TestModelRunResult:
run_config = prepared_run.run_config
system = TestModelSystem(config=run_config.model)
raw_initial_state = tuple(system.initial_state_vector())
initialization = system.initialize_consistent_state()
consistent_initial_state = tuple(initialization.state_vector)
solution = system.simulate(config=run_config.solver, t_eval=list(prepared_run.t_eval))
series = system.evaluate_solution(solution)
solve_diagnostics = system.last_solve_diagnostics
modelica_series = None
used_modelica_reference = False
if (
prepared_run.use_modelica_reference_if_available
and prepared_run.modelica_reference_exists
):
modelica_series = load_modelica_series(
prepared_run.modelica_result_path,
COMPARISON_KEYS,
)
used_modelica_reference = True
artifacts, comparison_summary = export_testmodel_artifacts(
output_dir=prepared_run.output_dir,
series=series,
modelica_series=modelica_series,
)
report_text = format_testmodel_run_report(
network_summary=system.network.summary(),
initialization=initialization,
raw_initial_state=raw_initial_state,
consistent_initial_state=consistent_initial_state,
solution=solution,
series=series,
solve_diagnostics=solve_diagnostics,
artifacts=artifacts,
comparison_summary=comparison_summary,
)
write_testmodel_run_report(prepared_run.output_dir, report_text)
return TestModelRunResult(
run_config=run_config,
prepared_run=prepared_run,
system=system,
initialization=initialization,
raw_initial_state=raw_initial_state,
consistent_initial_state=consistent_initial_state,
solution=solution,
series=series,
solve_diagnostics=solve_diagnostics,
artifacts=artifacts,
comparison_summary=comparison_summary,
used_modelica_reference=used_modelica_reference,
)
def run_testmodel(
*,
run_config: TestModelRunConfig | None = None,
output_dir: Path | None = None,
modelica_result_path: Path | None = None,
) -> TestModelRunResult:
prepared_run = prepare_testmodel_run(
run_config=run_config,
output_dir=output_dir,
modelica_result_path=modelica_result_path,
)
return run_prepared_testmodel(prepared_run)
def main() -> None:
run_config = TestModelRunConfig()
result = run_testmodel(run_config=run_config)
print(
format_testmodel_run_report(
network_summary=result.system.network.summary(),
initialization=result.initialization,
raw_initial_state=result.raw_initial_state,
consistent_initial_state=result.consistent_initial_state,
solution=result.solution,
series=result.series,
solve_diagnostics=result.solve_diagnostics,
artifacts=result.artifacts,
comparison_summary=result.comparison_summary,
),
end="",
)
if __name__ == "__main__":
main()
+303
View File
@@ -0,0 +1,303 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from app.simulation.components.experimental.flow.orifice import Orifice
from app.simulation.components.experimental.junctions.tee import Tee
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.examples.testmodel.closure import (
BranchClosureComponents,
InitializationDiagnostics,
TestModelClosure,
TestModelClosureComponents,
TestModelSnapshot,
)
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
from app.simulation.solvers.solver import SolveIVPConfig, integrate_ode
from app.simulation.systems.network import SimulationNetwork
@dataclass(frozen=True)
class CylinderConfig:
volume: float = 0.01
p0: float = 35e6
T0: float = 300.0
@dataclass(frozen=True)
class OrificeConfig:
K: float = 1e-5
@dataclass(frozen=True)
class TankConfig:
volume: float = 0.1
p0: float = 1e5
T0: float = 300.0
@dataclass(frozen=True)
class PipeConfig:
length: float = 5.0
diameter: float = 0.02
lambda_darcy: float = 0.02
p0: float = 1e5
T0: float = 300.0
@dataclass(frozen=True)
class BranchConfig:
orifice: OrificeConfig = field(default_factory=OrificeConfig)
pipe: PipeConfig = field(default_factory=PipeConfig)
@dataclass(frozen=True)
class TestModelConfig:
cylinder: CylinderConfig = field(default_factory=CylinderConfig)
upper_branch: BranchConfig = field(default_factory=BranchConfig)
lower_branch: BranchConfig = field(default_factory=BranchConfig)
tank: TankConfig = field(default_factory=TankConfig)
class TestModelSystem:
"""Runnable first-pass Python system for the current Testmodel topology.
This version keeps the component split from the Modelica model while keeping
the downstream tee-tank pressure coupling in the ODE framework. The original
Modelica system is a tighter DAE because both pipe outlets discharge into an
ideal lossless junction directly connected to the tank. Here the branch
outlet flows are solved from a pressure-consistent energy balance so the
outlet is no longer driven by an arbitrary conductance parameter.
"""
def __init__(
self,
medium: IdealGasMedium | None = None,
config: TestModelConfig | None = None,
) -> None:
self.medium = medium or IdealGasMedium()
self.config = config or TestModelConfig()
self.mycylinder = Cylinder(
name="mycylinder",
medium=self.medium,
V=self.config.cylinder.volume,
p0=self.config.cylinder.p0,
T0=self.config.cylinder.T0,
)
self.mytee = Tee(name="mytee")
self.myorifice = Orifice(name="myorifice", K=self.config.upper_branch.orifice.K)
self.mypipe = Pipe(
name="mypipe",
medium=self.medium,
L=self.config.upper_branch.pipe.length,
D=self.config.upper_branch.pipe.diameter,
lambda_darcy=self.config.upper_branch.pipe.lambda_darcy,
p0=self.config.upper_branch.pipe.p0,
T0=self.config.upper_branch.pipe.T0,
)
self.myorifice1 = Orifice(name="myorifice1", K=self.config.lower_branch.orifice.K)
self.mypipe1 = Pipe(
name="mypipe1",
medium=self.medium,
L=self.config.lower_branch.pipe.length,
D=self.config.lower_branch.pipe.diameter,
lambda_darcy=self.config.lower_branch.pipe.lambda_darcy,
p0=self.config.lower_branch.pipe.p0,
T0=self.config.lower_branch.pipe.T0,
)
self.mytee1 = Tee(name="mytee1")
self.mytank = Tank(
name="mytank",
medium=self.medium,
V=self.config.tank.volume,
p0=self.config.tank.p0,
T0=self.config.tank.T0,
)
self.network = SimulationNetwork(name="Testmodel")
for component in (
self.mycylinder,
self.mytee,
self.myorifice,
self.mypipe,
self.myorifice1,
self.mypipe1,
self.mytee1,
self.mytank,
):
self.network.add_component(component)
self.network.connect("mycylinder", "port_b", "mytee", "port_in")
self.network.connect("mytee", "port_out1", "myorifice", "port_a")
self.network.connect("myorifice", "port_b", "mypipe", "port_a")
self.network.connect("mypipe", "port_b", "mytee1", "port_out2")
self.network.connect("mytee", "port_out2", "myorifice1", "port_a")
self.network.connect("myorifice1", "port_b", "mypipe1", "port_a")
self.network.connect("mypipe1", "port_b", "mytee1", "port_out1")
self.network.connect("mytee1", "port_in", "mytank", "port_a")
self.closure = TestModelClosure(
medium=self.medium,
components=TestModelClosureComponents(
cylinder=self.mycylinder,
upstream_tee=self.mytee,
upper_branch=BranchClosureComponents(
name="upper_branch",
orifice=self.myorifice,
pipe=self.mypipe,
),
lower_branch=BranchClosureComponents(
name="lower_branch",
orifice=self.myorifice1,
pipe=self.mypipe1,
),
downstream_tee=self.mytee1,
tank=self.mytank,
),
initial_state_vector=self.initial_state_vector,
apply_state_vector=self.apply_state_vector,
)
def initial_state_vector(self) -> list[float]:
return self.network.initial_state_vector()
def apply_state_vector(self, values: list[float]) -> None:
self.network.apply_state_vector(values)
def consistent_initial_state_vector(self) -> list[float]:
return self.closure.consistent_initial_state_vector()
@property
def last_solve_diagnostics(self):
return self.closure.last_solve_diagnostics
def initialize_consistent_state(
self,
max_iterations: int = 12,
state_tolerance: float = 1e-9,
flow_tolerance: float = 1e-9,
enthalpy_tolerance: float = 1e-6,
pressure_tolerance: float = 1e-6,
strict_internal_solvers: bool = False,
) -> InitializationDiagnostics:
return self.closure.initialize_consistent_state(
max_iterations=max_iterations,
state_tolerance=state_tolerance,
flow_tolerance=flow_tolerance,
enthalpy_tolerance=enthalpy_tolerance,
pressure_tolerance=pressure_tolerance,
strict_internal_solvers=strict_internal_solvers,
)
def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None:
self.closure.project_downstream_pressure_constraints(strict=strict)
def snapshot(
self,
state_vector: list[float] | None = None,
*,
strict: bool = False,
) -> TestModelSnapshot:
return self.closure.snapshot(state_vector, strict=strict)
def rhs(self, _t: float, state_vector: list[float]) -> list[float]:
return self.closure.rhs(state_vector)
@staticmethod
def _legacy_branch_series_key_map() -> tuple[tuple[str, str, str], tuple[str, str, str]]:
return (
("upper_branch", "branch_upper.in", "branch_upper.out"),
("lower_branch", "branch_lower.in", "branch_lower.out"),
)
@classmethod
def _legacy_branch_series_keys_by_name(cls) -> dict[str, tuple[str, str]]:
return {
branch_name: (inlet_key, outlet_key)
for branch_name, inlet_key, outlet_key in cls._legacy_branch_series_key_map()
}
@staticmethod
def _generic_branch_series_keys(branch_name: str) -> tuple[str, str, str]:
return (
f"branch.{branch_name}.p",
f"branch.{branch_name}.in",
f"branch.{branch_name}.out",
)
@staticmethod
def _legacy_branch_pressure_keys_by_name() -> dict[str, str]:
return {
"upper_branch": "mypipe.p",
"lower_branch": "mypipe1.p",
}
@classmethod
def _append_legacy_branch_series_aliases(
cls,
series: dict[str, list[float]],
) -> dict[str, list[float]]:
legacy_branch_series_keys = cls._legacy_branch_series_keys_by_name()
legacy_branch_pressure_keys = cls._legacy_branch_pressure_keys_by_name()
for branch_name, (legacy_inlet_key, legacy_outlet_key) in legacy_branch_series_keys.items():
pressure_key, generic_inlet_key, generic_outlet_key = cls._generic_branch_series_keys(
branch_name
)
series[legacy_branch_pressure_keys[branch_name]] = list(series[pressure_key])
series[legacy_inlet_key] = list(series[generic_inlet_key])
series[legacy_outlet_key] = list(series[generic_outlet_key])
return series
def simulate(
self,
config: SolveIVPConfig | None = None,
t_eval: list[float] | None = None,
) -> Any:
return integrate_ode(
rhs=self.rhs,
initial_state=self.consistent_initial_state_vector(),
config=config or SolveIVPConfig(),
t_eval=t_eval,
)
def evaluate_solution(self, solution: Any) -> dict[str, list[float]]:
series = {
"time": [],
"mycylinder.p": [],
"mycylinder.T": [],
"mytank.p": [],
"mytank.T": [],
}
for branch_name, _, _ in self._legacy_branch_series_key_map():
pressure_key, inlet_key, outlet_key = self._generic_branch_series_keys(branch_name)
series[pressure_key] = []
series[inlet_key] = []
series[outlet_key] = []
for index, time_value in enumerate(solution.t):
state_vector = [row[index] for row in solution.y]
snapshot = self.snapshot(state_vector)
series["time"].append(float(time_value))
series["mycylinder.p"].append(snapshot.cylinder.p)
series["mycylinder.T"].append(snapshot.cylinder.T)
series["mytank.p"].append(snapshot.tank.p)
series["mytank.T"].append(snapshot.tank.T)
for branch in snapshot.branches:
pressure_key, generic_inlet_key, generic_outlet_key = self._generic_branch_series_keys(
branch.name
)
series[pressure_key].append(branch.pipe.p)
series[generic_inlet_key].append(branch.inlet_flow)
series[generic_outlet_key].append(branch.outlet_flow)
return self._append_legacy_branch_series_aliases(series)
def build_testmodel() -> SimulationNetwork:
"""Compatibility helper for callers that only need the topology."""
return TestModelSystem().network
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import os
from pathlib import Path
APP_DIR = Path(__file__).resolve().parent.parent
PROJECT_ROOT = APP_DIR.parent
_configured_data_dir = os.getenv("SIMULATIONAPP_DATA_DIR")
DATA_DIR = (
Path(_configured_data_dir).expanduser().resolve()
if _configured_data_dir
else APP_DIR / "data"
)
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
MODELICA_TESTMODEL_RESULT_PATH = (
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
)
+718
View File
@@ -0,0 +1,718 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from importlib import import_module
from math import isfinite
import re
from typing import cast
from app.simulation.core.base import Component
from app.simulation.core.catalog import (
ComponentCategorySpec,
ComponentDisplaySpec,
ComponentLibrarySpec,
PortDisplaySpec,
)
from app.simulation.core.metadata import (
SI_UNIT_BY_QUANTITY,
ParameterDefinition,
ResultVariableDefinition,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition, PortVariableDefinition
ParameterSpec = ParameterDefinition
ENABLED_COMPONENT_LIBRARIES = (
"app.simulation.components.experimental.library:LIBRARY",
"app.simulation.components.amesim.library:LIBRARY",
)
_MACHINE_ID_PATTERN = re.compile(r"[a-z][a-z0-9_]*")
_MEMBER_ID_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
_SEMANTIC_VERSION_PATTERN = re.compile(r"\d+\.\d+\.\d+")
@dataclass(frozen=True)
class ComponentModelSpec:
"""Validated registry entry backed by one component implementation class."""
component_class: type[Component]
library: ComponentLibrarySpec
@property
def model_type(self) -> str:
return cast(str, self.component_class.MODEL_TYPE)
@property
def model_version(self) -> str:
return cast(str, self.component_class.MODEL_VERSION)
@property
def ports(self) -> tuple[PortDefinition, ...]:
return self.component_class.PORTS
@property
def parameters(self) -> tuple[ParameterDefinition, ...]:
return self.component_class.PARAMETERS
@property
def result_variables(self) -> tuple[ResultVariableDefinition, ...]:
return self.component_class.RESULT_VARIABLES
@property
def display(self) -> ComponentDisplaySpec:
return cast(ComponentDisplaySpec, self.component_class.DISPLAY)
@property
def parameter_by_name(self) -> dict[str, ParameterDefinition]:
return {parameter.name: parameter for parameter in self.parameters}
def as_catalog_dict(self) -> dict[str, object]:
category = self.library.category_by_id[self.display.category_id]
display_ports = self.display.port_by_name
ports: list[dict[str, object]] = []
for port in self.ports:
payload = port.as_interface_dict()
payload["side"] = display_ports[port.name].side
payload["order"] = display_ports[port.name].order
ports.append(payload)
ports.sort(key=lambda item: (int(item["order"]), str(item["name"])))
return {
"type": self.model_type,
"modelType": self.model_type,
"modelVersion": self.model_version,
"label": self.display.label,
"symbol": self.display.symbol,
"order": self.display.order,
"category": category.as_catalog_dict(),
"ports": ports,
"parameters": [
parameter.as_interface_dict() for parameter in self.parameters
],
}
def create(
self,
name: str,
medium: IdealGasMedium,
values: Mapping[str, float],
) -> Component:
unknown = sorted(set(values) - set(self.parameter_by_name))
if unknown:
raise ValueError(
f"Component '{name}' contains unsupported parameters: "
+ ", ".join(unknown)
+ "."
)
resolved = {
parameter.name: values.get(parameter.name, parameter.default)
for parameter in self.parameters
}
for parameter in self.parameters:
message = parameter.validation_message(resolved[parameter.name])
if message is not None:
raise ValueError(
f"Parameter '{parameter.name}' on component '{name}' {message}."
)
component = self.component_class.create(
name=name,
medium=medium,
parameters=resolved,
)
self._validate_instance(component, resolved)
return component
def _validate_instance(
self,
component: Component,
resolved_parameters: Mapping[str, float],
) -> None:
if not isinstance(component, self.component_class):
raise ValueError(
f"Component model '{self.model_type}' create() returned "
f"{type(component).__name__}, expected {self.component_class.__name__}."
)
if component.model_type != self.model_type:
raise ValueError(
f"Component implementation '{self.model_type}' created instance "
f"with model type '{component.model_type}'."
)
if component.port_definitions != self.ports:
raise ValueError(
f"Component implementation '{self.model_type}' does not match "
"its declared ports."
)
if component.parameter_values != dict(resolved_parameters):
raise ValueError(
f"Component implementation '{self.model_type}' did not preserve "
"its normalized parameters."
)
def _load_reference(reference: str) -> object:
try:
module_name, attribute_name = reference.rsplit(":", maxsplit=1)
except ValueError as exc:
raise ValueError(
f"Component reference '{reference}' must use 'module:attribute'."
) from exc
if not module_name or not attribute_name:
raise ValueError(
f"Component reference '{reference}' must use 'module:attribute'."
)
try:
module = import_module(module_name)
except Exception as exc:
raise ValueError(
f"Could not import component module '{module_name}' from '{reference}'."
) from exc
try:
return getattr(module, attribute_name)
except AttributeError as exc:
raise ValueError(
f"Component module '{module_name}' has no attribute '{attribute_name}'."
) from exc
def _validate_machine_id(value: object, *, field: str) -> str:
if not isinstance(value, str) or _MACHINE_ID_PATTERN.fullmatch(value) is None:
raise ValueError(
f"{field} must start with a lowercase letter and contain only "
"lowercase letters, digits, and underscores."
)
return value
def _validate_member_id(value: object, *, field: str) -> str:
if not isinstance(value, str) or _MEMBER_ID_PATTERN.fullmatch(value) is None:
raise ValueError(
f"{field} must start with a letter and contain only letters, "
"digits, and underscores."
)
return value
def _validate_version(value: object, *, field: str) -> str:
if (
not isinstance(value, str)
or _SEMANTIC_VERSION_PATTERN.fullmatch(value) is None
):
raise ValueError(f"{field} must use 'major.minor.patch'.")
return value
def _validate_label(value: object, *, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field} must not be empty.")
return value
def _validate_order(value: object, *, field: str) -> int:
if type(value) is not int:
raise ValueError(f"{field} must be an integer.")
return value
def _validate_quantity_unit(
quantity: object,
unit: object,
*,
field: str,
) -> None:
if not isinstance(quantity, str) or not quantity:
raise ValueError(f"{field} quantity must not be empty.")
if not isinstance(unit, str):
raise ValueError(f"{field} unit must be a string.")
try:
expected_unit = SI_UNIT_BY_QUANTITY[quantity]
except KeyError as exc:
raise ValueError(
f"{field} uses unsupported quantity '{quantity}'."
) from exc
if unit != expected_unit:
raise ValueError(
f"{field} uses unit '{unit}' for quantity '{quantity}', "
f"expected SI unit '{expected_unit}'."
)
def _validate_unique_names(
names: Sequence[str],
*,
field: str,
) -> None:
duplicates = sorted({name for name in names if names.count(name) > 1})
if duplicates:
raise ValueError(f"{field} contains duplicate names: {', '.join(duplicates)}.")
def _validate_category(
category: ComponentCategorySpec,
*,
library_id: str,
) -> None:
if not isinstance(category, ComponentCategorySpec):
raise ValueError(
f"Library '{library_id}' categories must use ComponentCategorySpec."
)
_validate_machine_id(
category.id,
field=f"Library '{library_id}' category id",
)
_validate_label(
category.label,
field=f"Library '{library_id}' category '{category.id}' label",
)
_validate_order(
category.order,
field=f"Library '{library_id}' category '{category.id}' order",
)
def validate_component_library(library: ComponentLibrarySpec) -> None:
if not isinstance(library, ComponentLibrarySpec):
raise ValueError("Enabled component libraries must use ComponentLibrarySpec.")
library_id = _validate_machine_id(library.id, field="Component library id")
_validate_label(library.label, field=f"Library '{library_id}' label")
_validate_version(library.version, field=f"Library '{library_id}' version")
_validate_order(library.order, field=f"Library '{library_id}' order")
if type(library.temporary) is not bool:
raise ValueError(f"Library '{library_id}' temporary must be a boolean.")
if (
not isinstance(library.source_package, str)
or not library.source_package.strip()
):
raise ValueError(f"Library '{library_id}' source package must not be empty.")
if not library.categories:
raise ValueError(f"Library '{library_id}' must declare at least one category.")
if not library.models:
raise ValueError(f"Library '{library_id}' must declare at least one model.")
for category in library.categories:
_validate_category(category, library_id=library_id)
category_ids = [category.id for category in library.categories]
_validate_unique_names(
category_ids,
field=f"Library '{library_id}' categories",
)
_validate_unique_names(
list(library.models),
field=f"Library '{library_id}' model references",
)
for reference in library.models:
if not isinstance(reference, str) or ":" not in reference:
raise ValueError(
f"Library '{library_id}' contains invalid model reference "
f"'{reference}'."
)
module_name = reference.rsplit(":", maxsplit=1)[0]
if not (
module_name == library.source_package
or module_name.startswith(f"{library.source_package}.")
):
raise ValueError(
f"Library '{library_id}' model '{reference}' is outside source "
f"package '{library.source_package}'."
)
def _validate_port_variable(
variable: PortVariableDefinition,
*,
model_type: str,
port_name: str,
) -> None:
field = f"Component '{model_type}' port '{port_name}' variable"
if not isinstance(variable, PortVariableDefinition):
raise ValueError(f"{field} must use PortVariableDefinition.")
_validate_member_id(variable.name, field=f"{field} name")
_validate_label(variable.label or variable.name, field=f"{field} label")
_validate_quantity_unit(
variable.quantity or variable.name,
variable.unit,
field=f"{field} '{variable.name}'",
)
_validate_order(variable.order, field=f"{field} '{variable.name}' order")
if variable.role not in {"effort", "flow", "stream", "signal"}:
raise ValueError(f"{field} '{variable.name}' has invalid role.")
if variable.connection_rule not in {
"equal",
"sumToZero",
"streamMix",
"directed",
}:
raise ValueError(
f"{field} '{variable.name}' has invalid connection rule."
)
if type(variable.result_visible) is not bool:
raise ValueError(
f"{field} '{variable.name}' result_visible must be a boolean."
)
def _validate_port(port: PortDefinition, *, model_type: str) -> None:
field = f"Component '{model_type}' port"
if not isinstance(port, PortDefinition):
raise ValueError(f"{field} declarations must use PortDefinition.")
_validate_machine_id(port.name, field=f"{field} name")
_validate_machine_id(port.domain, field=f"{field} '{port.name}' domain")
if port.kind not in {"physical", "signal"}:
raise ValueError(f"{field} '{port.name}' has invalid kind.")
if port.nominal_role not in {
"inlet",
"outlet",
"bidirectional",
"input",
"output",
}:
raise ValueError(f"{field} '{port.name}' has invalid nominal role.")
if port.kind == "physical" and port.positive_flow_direction != "intoComponent":
raise ValueError(
f"{field} '{port.name}' must use positive flow into the component."
)
if port.kind == "physical" and port.nominal_role in {"input", "output"}:
raise ValueError(
f"{field} '{port.name}' uses a signal-only nominal role."
)
if port.kind == "signal" and port.nominal_role not in {"input", "output"}:
raise ValueError(
f"{field} '{port.name}' must use input or output as its nominal role."
)
variable_names = [variable.name for variable in port.variables]
_validate_unique_names(
variable_names,
field=f"{field} '{port.name}' variables",
)
for variable in port.variables:
_validate_port_variable(
variable,
model_type=model_type,
port_name=port.name,
)
def _validate_parameter(
parameter: ParameterDefinition,
*,
model_type: str,
) -> None:
field = f"Component '{model_type}' parameter"
if not isinstance(parameter, ParameterDefinition):
raise ValueError(f"{field} declarations must use ParameterDefinition.")
_validate_member_id(parameter.name, field=f"{field} name")
_validate_label(parameter.label, field=f"{field} '{parameter.name}' label")
_validate_quantity_unit(
parameter.quantity,
parameter.unit,
field=f"{field} '{parameter.name}'",
)
if not isinstance(parameter.default, (int, float)) or not isfinite(
parameter.default
):
raise ValueError(f"{field} '{parameter.name}' default must be finite.")
for boundary_name, boundary in (
("minimum", parameter.minimum),
("maximum", parameter.maximum),
):
if boundary is not None and (
not isinstance(boundary, (int, float)) or not isfinite(boundary)
):
raise ValueError(
f"{field} '{parameter.name}' {boundary_name} must be finite."
)
if type(parameter.minimum_exclusive) is not bool:
raise ValueError(
f"{field} '{parameter.name}' minimum_exclusive must be a boolean."
)
if (
parameter.minimum is not None
and parameter.maximum is not None
and parameter.minimum > parameter.maximum
):
raise ValueError(
f"{field} '{parameter.name}' minimum exceeds its maximum."
)
message = parameter.validation_message(parameter.default)
if message is not None:
raise ValueError(
f"{field} '{parameter.name}' default value {message}."
)
def _validate_result_variable(
variable: ResultVariableDefinition,
*,
model_type: str,
) -> None:
field = f"Component '{model_type}' result variable"
if not isinstance(variable, ResultVariableDefinition):
raise ValueError(
f"{field} declarations must use ResultVariableDefinition."
)
_validate_member_id(variable.name, field=f"{field} name")
_validate_label(variable.label, field=f"{field} '{variable.name}' label")
_validate_quantity_unit(
variable.quantity,
variable.unit,
field=f"{field} '{variable.name}'",
)
_validate_machine_id(
variable.category,
field=f"{field} '{variable.name}' category",
)
_validate_order(variable.order, field=f"{field} '{variable.name}' order")
if type(variable.visible) is not bool:
raise ValueError(
f"{field} '{variable.name}' visible must be a boolean."
)
def validate_component_model_class(
component_class: type[Component],
*,
library: ComponentLibrarySpec,
) -> None:
if not isinstance(component_class, type) or not issubclass(
component_class,
Component,
):
raise ValueError(
f"Library '{library.id}' model entries must be Component subclasses."
)
required_declarations = (
"MODEL_TYPE",
"MODEL_VERSION",
"PORTS",
"PARAMETERS",
"RESULT_VARIABLES",
"DISPLAY",
"create",
)
missing = [
name for name in required_declarations if name not in component_class.__dict__
]
if missing:
raise ValueError(
f"Component class '{component_class.__name__}' must declare: "
+ ", ".join(missing)
+ "."
)
if not isinstance(component_class.__dict__["create"], classmethod):
raise ValueError(
f"Component class '{component_class.__name__}' create must be a classmethod."
)
model_type = _validate_machine_id(
component_class.MODEL_TYPE,
field=f"Component class '{component_class.__name__}' model type",
)
_validate_version(
component_class.MODEL_VERSION,
field=f"Component '{model_type}' model version",
)
display = component_class.DISPLAY
if not isinstance(display, ComponentDisplaySpec):
raise ValueError(
f"Component '{model_type}' DISPLAY must use ComponentDisplaySpec."
)
_validate_label(display.label, field=f"Component '{model_type}' display label")
_validate_machine_id(
display.symbol,
field=f"Component '{model_type}' display symbol",
)
_validate_order(display.order, field=f"Component '{model_type}' display order")
if not isinstance(display.ports, tuple):
raise ValueError(f"Component '{model_type}' DISPLAY ports must be a tuple.")
if display.library_id != library.id:
raise ValueError(
f"Component '{model_type}' references library '{display.library_id}', "
f"expected '{library.id}'."
)
if display.category_id not in library.category_by_id:
raise ValueError(
f"Component '{model_type}' references unknown category "
f"'{display.category_id}' in library '{library.id}'."
)
ports = component_class.PORTS
parameters = component_class.PARAMETERS
result_variables = component_class.RESULT_VARIABLES
if not isinstance(ports, tuple):
raise ValueError(f"Component '{model_type}' PORTS must be a tuple.")
if not isinstance(parameters, tuple):
raise ValueError(f"Component '{model_type}' PARAMETERS must be a tuple.")
if not isinstance(result_variables, tuple):
raise ValueError(
f"Component '{model_type}' RESULT_VARIABLES must be a tuple."
)
for port in ports:
_validate_port(port, model_type=model_type)
for parameter in parameters:
_validate_parameter(parameter, model_type=model_type)
for variable in result_variables:
_validate_result_variable(variable, model_type=model_type)
for port in display.ports:
if not isinstance(port, PortDisplaySpec):
raise ValueError(
f"Component '{model_type}' display ports must use PortDisplaySpec."
)
_validate_machine_id(
port.name,
field=f"Component '{model_type}' display port name",
)
if port.side not in {"left", "right"}:
raise ValueError(
f"Component '{model_type}' display port '{port.name}' "
"must use side 'left' or 'right'."
)
_validate_order(
port.order,
field=f"Component '{model_type}' display port '{port.name}' order",
)
port_names = [port.name for port in ports]
parameter_names = [parameter.name for parameter in parameters]
result_names = [variable.name for variable in result_variables]
display_port_names = [port.name for port in display.ports]
_validate_unique_names(port_names, field=f"Component '{model_type}' ports")
_validate_unique_names(
parameter_names,
field=f"Component '{model_type}' parameters",
)
_validate_unique_names(
result_names,
field=f"Component '{model_type}' result variables",
)
_validate_unique_names(
display_port_names,
field=f"Component '{model_type}' display ports",
)
if set(port_names) != set(display_port_names):
raise ValueError(
f"Component '{model_type}' display ports must exactly match "
"its physical port declarations."
)
def discover_component_registries(
library_references: Sequence[str] = ENABLED_COMPONENT_LIBRARIES,
) -> tuple[
dict[str, ComponentLibrarySpec],
dict[str, ComponentModelSpec],
]:
libraries: dict[str, ComponentLibrarySpec] = {}
models: dict[str, ComponentModelSpec] = {}
for library_reference in library_references:
library = _load_reference(library_reference)
if not isinstance(library, ComponentLibrarySpec):
raise ValueError(
f"Enabled library '{library_reference}' must reference "
"ComponentLibrarySpec."
)
validate_component_library(library)
if library.id in libraries:
raise ValueError(f"Duplicate component library id: {library.id}.")
libraries[library.id] = library
for model_reference in library.models:
component_class = _load_reference(model_reference)
if not isinstance(component_class, type) or not issubclass(
component_class,
Component,
):
raise ValueError(
f"Library '{library.id}' model '{model_reference}' is not "
"a Component subclass."
)
validate_component_model_class(component_class, library=library)
model_type = cast(str, component_class.MODEL_TYPE)
if model_type in models:
existing = models[model_type].component_class
raise ValueError(
f"Duplicate component model type '{model_type}' from "
f"{existing.__module__}.{existing.__name__} and "
f"{component_class.__module__}.{component_class.__name__}."
)
spec = ComponentModelSpec(
component_class=component_class,
library=library,
)
try:
spec.create(
f"__catalog_validation_{model_type}",
IdealGasMedium(),
{},
)
except Exception as exc:
raise ValueError(
f"Component model '{model_type}' cannot be created with "
"its declared defaults."
) from exc
models[model_type] = spec
if not libraries:
raise ValueError("At least one component library must be enabled.")
return libraries, models
COMPONENT_LIBRARY_REGISTRY, COMPONENT_MODEL_REGISTRY = (
discover_component_registries()
)
def validate_component_registries() -> None:
"""Re-run public registry invariants for tests and startup diagnostics."""
discovered_libraries, discovered_models = discover_component_registries()
if tuple(discovered_libraries) != tuple(COMPONENT_LIBRARY_REGISTRY):
raise ValueError("Component library registry differs from discovery output.")
if tuple(discovered_models) != tuple(COMPONENT_MODEL_REGISTRY):
raise ValueError("Component model registry differs from discovery output.")
def build_component_catalog() -> dict[str, object]:
components_by_library: dict[str, list[ComponentModelSpec]] = {
library_id: [] for library_id in COMPONENT_LIBRARY_REGISTRY
}
for component in COMPONENT_MODEL_REGISTRY.values():
components_by_library[component.library.id].append(component)
libraries: list[dict[str, object]] = []
for library in sorted(
COMPONENT_LIBRARY_REGISTRY.values(),
key=lambda item: (item.order, item.id),
):
payload = library.as_catalog_dict()
payload["components"] = [
component.as_catalog_dict()
for component in sorted(
components_by_library[library.id],
key=lambda item: (item.display.order, item.model_type),
)
]
libraries.append(payload)
return {
"schemaVersion": 1,
"libraries": libraries,
}
def get_component_model_spec(model_type: str) -> ComponentModelSpec:
try:
return COMPONENT_MODEL_REGISTRY[model_type]
except KeyError as exc:
raise ValueError(f"Unsupported model type: {model_type}.") from exc
+23
View File
@@ -0,0 +1,23 @@
from app.simulation.reporting.testmodel_outputs import (
COMPARISON_KEYS,
MODELICA_COMPARISON_COLUMNS,
PRIMARY_KEYS,
TestModelArtifacts,
export_testmodel_artifacts,
format_testmodel_run_report,
load_modelica_series,
write_testmodel_run_report,
write_modelica_comparison,
)
__all__ = [
"COMPARISON_KEYS",
"MODELICA_COMPARISON_COLUMNS",
"PRIMARY_KEYS",
"TestModelArtifacts",
"export_testmodel_artifacts",
"format_testmodel_run_report",
"load_modelica_series",
"write_testmodel_run_report",
"write_modelica_comparison",
]
@@ -0,0 +1,393 @@
from __future__ import annotations
from bisect import bisect_left
import csv
from dataclasses import dataclass
from pathlib import Path
from typing import Any
PRIMARY_KEYS = (
"mytank.p",
"mytank.T",
"mycylinder.p",
"mycylinder.T",
)
MODELICA_COMPARISON_COLUMNS = {
"mytank.p": "mytank.p",
"mytank.T": "mytank.T",
"mycylinder.p": "mycylinder.p",
"mycylinder.T": "mycylinder.T",
"branch.upper_branch.p": "mypipe.p",
"branch.upper_branch.in": "myorifice.port_a.m_flow",
"branch.upper_branch.out": "mytee1.port_out2.m_flow",
"branch.lower_branch.p": "mypipe1.p",
"branch.lower_branch.in": "myorifice1.port_a.m_flow",
"branch.lower_branch.out": "mytee1.port_out1.m_flow",
}
COMPARISON_KEYS = tuple(MODELICA_COMPARISON_COLUMNS.keys())
def _branch_series_values(
series: dict[str, list[float]],
branch_name: str,
legacy_key: str,
) -> list[float]:
generic_key = f"branch.{branch_name}.{legacy_key.split('.')[-1]}"
if generic_key in series:
return series[generic_key]
return series[legacy_key]
@dataclass(frozen=True)
class TestModelArtifacts:
primary_csv_path: Path
temperature_csv_path: Path
temperature_svg_path: Path
run_report_path: Path
comparison_csv_path: Path | None = None
comparison_summary_path: Path | None = None
def format_testmodel_run_report(
*,
network_summary: str,
initialization: Any,
raw_initial_state: tuple[float, ...],
consistent_initial_state: tuple[float, ...],
solution: Any,
series: dict[str, list[float]],
solve_diagnostics: Any,
artifacts: TestModelArtifacts,
comparison_summary: dict[str, tuple[float, float]] | None,
) -> str:
lines = [
network_summary,
"",
f"Initialization converged: {initialization.converged}",
f"Initialization iterations: {initialization.iterations}",
f"Initialization max state delta: {initialization.max_state_delta:.6e}",
f"Initialization max flow delta: {initialization.max_flow_delta:.6e}",
f"Initialization max enthalpy delta: {initialization.max_enthalpy_delta:.6e}",
(
"Initialization downstream pressure spread: "
f"{initialization.downstream_pressure_spread:.6e}"
),
"",
"Raw initial state vector:",
str(list(raw_initial_state)),
"",
"Constraint-consistent initial state vector:",
str(list(consistent_initial_state)),
"",
f"Solver success: {solution.success}",
f"Solver message: {solution.message}",
f"Final time: {solution.t[-1]:.2f} s",
f"Final tank pressure: {series['mytank.p'][-1]:.3f} Pa",
f"Final tank temperature: {series['mytank.T'][-1]:.3f} K",
f"Final cylinder pressure: {series['mycylinder.p'][-1]:.3f} Pa",
(
"Final branch inflow: "
f"{_branch_series_values(series, 'upper_branch', 'branch_upper.in')[-1] + _branch_series_values(series, 'lower_branch', 'branch_lower.in')[-1]:.6f} kg/s"
),
]
if solve_diagnostics is not None:
lines.extend(
[
"",
"Final closure solve diagnostics:",
(
"Upper branch inlet solve: "
f"converged={solve_diagnostics.upper_branch_inlet.converged}, "
f"iterations={solve_diagnostics.upper_branch_inlet.iterations}, "
f"residual={solve_diagnostics.upper_branch_inlet.residual:.6e}"
),
(
"Lower branch inlet solve: "
f"converged={solve_diagnostics.lower_branch_inlet.converged}, "
f"iterations={solve_diagnostics.lower_branch_inlet.iterations}, "
f"residual={solve_diagnostics.lower_branch_inlet.residual:.6e}"
),
]
)
if solve_diagnostics.downstream_pressure_projection is not None:
lines.append(
"Downstream pressure projection: "
f"converged={solve_diagnostics.downstream_pressure_projection.converged}, "
f"iterations={solve_diagnostics.downstream_pressure_projection.iterations}, "
f"residual={solve_diagnostics.downstream_pressure_projection.residual:.6e}"
)
lines.extend(
[
f"Primary series CSV: {artifacts.primary_csv_path}",
f"Temperature CSV: {artifacts.temperature_csv_path}",
f"Temperature plot: {artifacts.temperature_svg_path}",
f"Run report TXT: {artifacts.run_report_path}",
]
)
if (
artifacts.comparison_csv_path is not None
and artifacts.comparison_summary_path is not None
):
lines.extend(
[
f"Modelica comparison CSV: {artifacts.comparison_csv_path}",
f"Modelica comparison summary: {artifacts.comparison_summary_path}",
]
)
if comparison_summary is not None:
for key, (max_abs_error, max_rel_error) in comparison_summary.items():
lines.append(
f"{key} max abs error: {max_abs_error:.6f}, "
f"max rel error: {max_rel_error:.6%}"
)
return "\n".join(lines) + "\n"
def write_testmodel_run_report(output_dir: Path, report_text: str) -> Path:
report_path = output_dir / "testmodel_run_report.txt"
report_path.write_text(report_text, encoding="utf-8")
return report_path
def _write_primary_series_csv(output_dir: Path, series: dict[str, list[float]]) -> Path:
csv_path = output_dir / "testmodel_primary_series.csv"
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["time_s", *PRIMARY_KEYS])
for index, time_value in enumerate(series["time"]):
writer.writerow([time_value, *(series[key][index] for key in PRIMARY_KEYS)])
return csv_path
def _write_temperature_csv(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path:
csv_path = output_dir / "testmodel_tank_temperature.csv"
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["time_s", "mytank_T_K"])
writer.writerows(zip(time_values, temperatures))
return csv_path
def _write_temperature_svg(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path:
svg_path = output_dir / "testmodel_tank_temperature.svg"
width = 900
height = 520
left = 90
right = 40
top = 60
bottom = 70
plot_width = width - left - right
plot_height = height - top - bottom
min_time = min(time_values)
max_time = max(time_values)
min_temp = min(temperatures)
max_temp = max(temperatures)
temp_padding = max(1.0, (max_temp - min_temp) * 0.08)
min_temp -= temp_padding
max_temp += temp_padding
def scale_x(value: float) -> float:
return left + (value - min_time) / max(max_time - min_time, 1e-12) * plot_width
def scale_y(value: float) -> float:
return top + (max_temp - value) / max(max_temp - min_temp, 1e-12) * plot_height
points = " ".join(
f"{scale_x(time_value):.2f},{scale_y(temperature):.2f}"
for time_value, temperature in zip(time_values, temperatures)
)
x_ticks = 5
y_ticks = 5
x_tick_markup = []
y_tick_markup = []
for index in range(x_ticks + 1):
fraction = index / x_ticks
time_value = min_time + fraction * (max_time - min_time)
x = left + fraction * plot_width
x_tick_markup.append(
f'<line x1="{x:.2f}" y1="{top}" x2="{x:.2f}" y2="{top + plot_height}" '
'stroke="#d9e2ec" stroke-width="1" />'
)
x_tick_markup.append(
f'<text x="{x:.2f}" y="{height - 30}" text-anchor="middle" '
'font-size="14" fill="#102a43">'
f"{time_value:.1f}</text>"
)
for index in range(y_ticks + 1):
fraction = index / y_ticks
temp_value = min_temp + fraction * (max_temp - min_temp)
y = top + plot_height - fraction * plot_height
y_tick_markup.append(
f'<line x1="{left}" y1="{y:.2f}" x2="{left + plot_width}" y2="{y:.2f}" '
'stroke="#d9e2ec" stroke-width="1" />'
)
y_tick_markup.append(
f'<text x="{left - 12}" y="{y + 5:.2f}" text-anchor="end" '
'font-size="14" fill="#102a43">'
f"{temp_value:.1f}</text>"
)
svg_content = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
<rect width="{width}" height="{height}" fill="#f7fafc" rx="18" ry="18" />
<text x="{width / 2:.0f}" y="32" text-anchor="middle" font-size="24" fill="#102a43">Python Testmodel Tank Temperature</text>
<text x="{width / 2:.0f}" y="{height - 8}" text-anchor="middle" font-size="16" fill="#486581">Time (s)</text>
<text x="26" y="{height / 2:.0f}" text-anchor="middle" font-size="16" fill="#486581" transform="rotate(-90 26 {height / 2:.0f})">Temperature (K)</text>
<rect x="{left}" y="{top}" width="{plot_width}" height="{plot_height}" fill="#ffffff" stroke="#bcccdc" stroke-width="1.5" />
{''.join(x_tick_markup)}
{''.join(y_tick_markup)}
<polyline fill="none" stroke="#d64545" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" points="{points}" />
</svg>
"""
svg_path.write_text(svg_content, encoding="utf-8")
return svg_path
def load_modelica_series(csv_path: Path, variable_names: tuple[str, ...]) -> dict[str, list[float]]:
series = {"time": []}
for variable_name in variable_names:
series[variable_name] = []
with csv_path.open("r", newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle)
available_variable_names = tuple(
variable_name
for variable_name in variable_names
if MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name) in (reader.fieldnames or ())
)
for row in reader:
series["time"].append(float(row["time"]))
for variable_name in available_variable_names:
modelica_column = MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name)
series[variable_name].append(float(row[modelica_column]))
return series
def _interpolate_series_value(time_values: list[float], values: list[float], target_time: float) -> float:
if target_time <= time_values[0]:
return values[0]
if target_time >= time_values[-1]:
return values[-1]
right_index = bisect_left(time_values, target_time)
if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1e-12:
return values[right_index]
left_index = right_index - 1
left_time = time_values[left_index]
right_time = time_values[right_index]
fraction = (target_time - left_time) / (right_time - left_time)
return values[left_index] + fraction * (values[right_index] - values[left_index])
def write_modelica_comparison(
output_dir: Path,
python_series: dict[str, list[float]],
modelica_series: dict[str, list[float]],
) -> tuple[Path, Path, dict[str, tuple[float, float]]]:
comparison_csv_path = output_dir / "testmodel_modelica_comparison.csv"
summary_path = output_dir / "testmodel_modelica_comparison_summary.txt"
summary: dict[str, tuple[float, float]] = {}
with comparison_csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
header = ["time_s"]
comparison_keys = tuple(
key
for key in COMPARISON_KEYS
if key in python_series and key in modelica_series and modelica_series[key]
)
for key in comparison_keys:
header.extend(
[
f"python.{key}",
f"modelica.{key}",
f"abs_error.{key}",
f"rel_error.{key}",
]
)
writer.writerow(header)
max_abs_errors = {key: 0.0 for key in comparison_keys}
max_rel_errors = {key: 0.0 for key in comparison_keys}
for index, time_value in enumerate(python_series["time"]):
row = [time_value]
for key in comparison_keys:
python_value = python_series[key][index]
modelica_value = _interpolate_series_value(
modelica_series["time"],
modelica_series[key],
time_value,
)
abs_error = abs(python_value - modelica_value)
rel_error = abs_error / max(abs(modelica_value), 1e-9)
max_abs_errors[key] = max(max_abs_errors[key], abs_error)
max_rel_errors[key] = max(max_rel_errors[key], rel_error)
row.extend([python_value, modelica_value, abs_error, rel_error])
writer.writerow(row)
summary_lines = []
for key in comparison_keys:
summary[key] = (max_abs_errors[key], max_rel_errors[key])
summary_lines.append(
f"{key}: max_abs_error={max_abs_errors[key]:.6f}, "
f"max_rel_error={max_rel_errors[key]:.6%}"
)
summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8")
return comparison_csv_path, summary_path, summary
def export_testmodel_artifacts(
*,
output_dir: Path,
series: dict[str, list[float]],
modelica_series: dict[str, list[float]] | None = None,
) -> tuple[TestModelArtifacts, dict[str, tuple[float, float]] | None]:
output_dir.mkdir(parents=True, exist_ok=True)
primary_csv_path = _write_primary_series_csv(output_dir, series)
temperature_csv_path = _write_temperature_csv(
output_dir,
series["time"],
series["mytank.T"],
)
temperature_svg_path = _write_temperature_svg(
output_dir,
series["time"],
series["mytank.T"],
)
comparison_csv_path = None
comparison_summary_path = None
comparison_summary = None
if modelica_series is not None:
(
comparison_csv_path,
comparison_summary_path,
comparison_summary,
) = write_modelica_comparison(output_dir, series, modelica_series)
return (
TestModelArtifacts(
primary_csv_path=primary_csv_path,
temperature_csv_path=temperature_csv_path,
temperature_svg_path=temperature_svg_path,
run_report_path=output_dir / "testmodel_run_report.txt",
comparison_csv_path=comparison_csv_path,
comparison_summary_path=comparison_summary_path,
),
comparison_summary,
)
+1
View File
@@ -0,0 +1 @@
"""Numerical solvers used by simulation systems."""
+258
View File
@@ -0,0 +1,258 @@
from __future__ import annotations
from dataclasses import dataclass
from math import sqrt
from app.simulation.core.ports import PortState, VariableRole
from app.simulation.systems.network import SimulationNetwork
class AlgebraicSolveError(RuntimeError):
def __init__(self, message: str, diagnostics: "AlgebraicSolveDiagnostics") -> None:
super().__init__(message)
self.diagnostics = diagnostics
@dataclass(frozen=True)
class AlgebraicUnknown:
component: str
port: str
variable: str
role: VariableRole
state: PortState
@property
def id(self) -> str:
return f"{self.component}.{self.port}.{self.variable}"
def read(self) -> float:
return float(getattr(self.state, self.variable))
def write(self, value: float) -> None:
setattr(self.state, self.variable, float(value))
@dataclass(frozen=True)
class AlgebraicSolveDiagnostics:
success: bool
message: str
evaluations: int
pressure_scale: float
flow_scale: float
max_scaled_residual: float
max_raw_residual: float
def as_dict(self) -> dict[str, object]:
return {
"success": self.success,
"message": self.message,
"evaluations": self.evaluations,
"pressureScale": self.pressure_scale,
"flowScale": self.flow_scale,
"maxScaledResidual": self.max_scaled_residual,
"maxRawResidual": self.max_raw_residual,
}
class PressureFlowSolver:
"""Solve the acausal pressure-flow subsystem for a compiled network."""
def __init__(
self,
network: SimulationNetwork,
*,
residual_tolerance: float = 1e-7,
max_evaluations: int = 500,
) -> None:
self.network = network
self.residual_tolerance = residual_tolerance
self.max_evaluations = max_evaluations
self.unknowns = self._build_unknowns()
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
unknowns: list[AlgebraicUnknown] = []
for component in self.network.components.values():
for definition in component.port_definitions:
if definition.kind != "physical":
continue
state = component.get_port(definition.name)
for variable in definition.variables:
if variable.role not in {"effort", "flow"}:
continue
unknowns.append(
AlgebraicUnknown(
component=component.name,
port=definition.name,
variable=variable.name,
role=variable.role,
state=state,
)
)
return tuple(unknowns)
def _seed_equal_pressures(self) -> None:
for _ in range(max(2, len(self.network.connections))):
changed = False
for connection in self.network.connections:
if connection.kind != "physical":
continue
first = self.network.components[
connection.endpoint_a.component
].get_port(connection.endpoint_a.port)
second = self.network.components[
connection.endpoint_b.component
].get_port(connection.endpoint_b.port)
if first.p > 0.0 and second.p <= 0.0:
second.p = first.p
changed = True
elif second.p > 0.0 and first.p <= 0.0:
first.p = second.p
changed = True
for component in self.network.components.values():
equal_pressure_equations = [
equation
for equation in component.pressure_flow_equation_residuals()
if equation.relation == "equal" and equation.role == "effort"
]
for equation in equal_pressure_equations:
states = []
for variable in equation.variables:
_, port_name, variable_name = variable.rsplit(".", 2)
if variable_name == "p":
states.append(component.get_port(port_name))
if len(states) != 2:
continue
first, second = states
if first.p > 0.0 and second.p <= 0.0:
second.p = first.p
changed = True
elif second.p > 0.0 and first.p <= 0.0:
first.p = second.p
changed = True
if not changed:
break
def _scales(self) -> tuple[float, float]:
pressure_scale = max(
[
abs(unknown.read())
for unknown in self.unknowns
if unknown.role == "effort" and unknown.read() > 0.0
]
+ [1e5]
)
estimated_flows = [
abs(float(getattr(component, "K_eff"))) * sqrt(pressure_scale)
for component in self.network.components.values()
if hasattr(component, "K_eff")
]
flow_scale = max(
estimated_flows
+ [
abs(unknown.read())
for unknown in self.unknowns
if unknown.role == "flow"
]
+ [1e-3]
)
return pressure_scale, flow_scale
def solve(self) -> AlgebraicSolveDiagnostics:
try:
import numpy as np
from scipy.optimize import least_squares
except ImportError as exc:
raise RuntimeError(
"Topology-driven simulation requires SciPy; install requirements.txt."
) from exc
self._seed_equal_pressures()
pressure_scale, flow_scale = self._scales()
positive_pressures = [
unknown.read()
for unknown in self.unknowns
if unknown.role == "effort" and unknown.read() > 0.0
]
fallback_pressure = (
sum(positive_pressures) / len(positive_pressures)
if positive_pressures
else pressure_scale
)
def variable_scale(unknown: AlgebraicUnknown) -> float:
return pressure_scale if unknown.role == "effort" else flow_scale
x0 = np.asarray(
[
(
unknown.read()
if unknown.role != "effort" or unknown.read() > 0.0
else fallback_pressure
)
/ variable_scale(unknown)
for unknown in self.unknowns
],
dtype=float,
)
lower = np.asarray(
[
1.0 / pressure_scale if unknown.role == "effort" else -np.inf
for unknown in self.unknowns
]
)
upper = np.full(len(self.unknowns), np.inf)
def assign(values) -> None:
for unknown, value in zip(self.unknowns, values):
unknown.write(float(value) * variable_scale(unknown))
def scaled_residuals(values):
assign(values)
equations = self.network.pressure_flow_equation_residuals()
return np.asarray(
[
equation.value
/ (pressure_scale if equation.role == "effort" else flow_scale)
for equation in equations
],
dtype=float,
)
result = least_squares(
scaled_residuals,
x0,
bounds=(lower, upper),
x_scale="jac",
ftol=1e-10,
xtol=1e-10,
gtol=1e-10,
max_nfev=self.max_evaluations,
)
assign(result.x)
equations = self.network.pressure_flow_equation_residuals()
scaled = [
abs(
equation.value
/ (pressure_scale if equation.role == "effort" else flow_scale)
)
for equation in equations
]
success = bool(result.success) and max(scaled, default=0.0) <= self.residual_tolerance
diagnostics = AlgebraicSolveDiagnostics(
success=success,
message=str(result.message),
evaluations=int(result.nfev),
pressure_scale=pressure_scale,
flow_scale=flow_scale,
max_scaled_residual=max(scaled, default=0.0),
max_raw_residual=max((abs(item.value) for item in equations), default=0.0),
)
self.last_diagnostics = diagnostics
if not success:
raise AlgebraicSolveError(
"Pressure-flow equations did not converge to the requested tolerance.",
diagnostics,
)
return diagnostics
+308
View File
@@ -0,0 +1,308 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Literal
CancellationCheck = Callable[[], bool]
AcceptedStepCallback = Callable[[float], None]
IntegrationStatus = Literal["completed", "cancelled", "failed"]
class _IntegrationCancelled(Exception):
pass
@dataclass(frozen=True)
class SolveIVPConfig:
t_start: float = 0.0
t_stop: float = 20.0
method: str = "BDF"
rtol: float = 1e-6
atol: float = 1e-8
max_step: float = 1e-3
@dataclass(frozen=True)
class ODESolution:
t: list[float]
y: list[list[float]]
success: bool
message: str
status: IntegrationStatus = "completed"
error: Exception | None = None
def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]:
return [x + scale * y for x, y in zip(a, b)]
def _append_solution_sample(
times: list[float],
states: list[list[float]],
time: float,
state: list[float],
) -> None:
if times and time <= times[-1] + 1e-12:
return
times.append(float(time))
for index, value in enumerate(state):
states[index].append(float(value))
def _runge_kutta_4(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None,
cancel_check: CancellationCheck | None = None,
accepted_step_callback: AcceptedStepCallback | None = None,
) -> ODESolution:
if t_eval is None:
point_count = max(
2,
int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1,
)
step = (config.t_stop - config.t_start) / (point_count - 1)
t_eval = [config.t_start + index * step for index in range(point_count)]
state = list(initial_state)
states = [[value] for value in state]
times = [float(t_eval[0])]
current_time = float(t_eval[0])
status: IntegrationStatus = "completed"
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
error: Exception | None = None
try:
for target_time in t_eval[1:]:
while current_time < target_time - 1e-15:
if cancel_check is not None and cancel_check():
raise _IntegrationCancelled
dt = min(config.max_step, target_time - current_time)
k1 = rhs(current_time, state)
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt))
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
state = [
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
]
current_time += dt
if accepted_step_callback is not None:
accepted_step_callback(current_time)
_append_solution_sample(times, states, target_time, state)
except _IntegrationCancelled:
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
_append_solution_sample(times, states, current_time, state)
except Exception as exc:
status = "failed"
message = str(exc)
error = exc
_append_solution_sample(times, states, current_time, state)
return ODESolution(
t=times,
y=states,
success=status == "completed",
message=message,
status=status,
error=error,
)
def _integrate_scipy_stepwise(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None,
cancel_check: CancellationCheck,
accepted_step_callback: AcceptedStepCallback | None,
) -> ODESolution:
import numpy as np
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
solver_types = {
"BDF": BDF,
"DOP853": DOP853,
"LSODA": LSODA,
"RK23": RK23,
"RK45": RK45,
"Radau": Radau,
}
solver_type = solver_types.get(config.method)
if solver_type is None:
raise ValueError(f"Unsupported integration method: {config.method}")
times = [float(config.t_start)]
states = [[float(value)] for value in initial_state]
last_accepted_time = float(config.t_start)
last_accepted_state = [float(value) for value in initial_state]
sample_times = list(t_eval or [])
sample_index = 0
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= config.t_start + 1e-12
):
sample_index += 1
def cancellable_rhs(time, state):
if cancel_check():
raise _IntegrationCancelled
return rhs(float(time), [float(value) for value in state])
if cancel_check():
return ODESolution(
t=times,
y=states,
success=False,
message="Simulation was stopped before integration started.",
status="cancelled",
)
try:
solver = solver_type(
cancellable_rhs,
config.t_start,
np.asarray(initial_state, dtype=float),
config.t_stop,
rtol=config.rtol,
atol=config.atol,
max_step=config.max_step,
)
except _IntegrationCancelled:
return ODESolution(
t=times,
y=states,
success=False,
message="Simulation was stopped before integration started.",
status="cancelled",
)
except Exception as exc:
return ODESolution(
t=times,
y=states,
success=False,
message=str(exc),
status="failed",
error=exc,
)
status: IntegrationStatus = "completed"
message = "The solver successfully reached the end of the integration interval."
error: Exception | None = None
while solver.status == "running":
if cancel_check():
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
break
try:
step_message = solver.step()
except _IntegrationCancelled:
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
break
except Exception as exc:
status = "failed"
message = str(exc)
error = exc
break
if solver.status == "failed":
status = "failed"
message = str(step_message or "Integration step failed.")
break
last_accepted_time = float(solver.t)
last_accepted_state = [float(value) for value in solver.y]
if sample_times:
dense_output = solver.dense_output()
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= last_accepted_time + 1e-12
):
sample_time = float(sample_times[sample_index])
sample_state = [float(value) for value in dense_output(sample_time)]
_append_solution_sample(times, states, sample_time, sample_state)
sample_index += 1
else:
_append_solution_sample(
times,
states,
last_accepted_time,
last_accepted_state,
)
if accepted_step_callback is not None:
accepted_step_callback(last_accepted_time)
if status != "completed":
_append_solution_sample(
times,
states,
last_accepted_time,
last_accepted_state,
)
return ODESolution(
t=times,
y=states,
success=status == "completed",
message=message,
status=status,
error=error,
)
def integrate_ode(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None = None,
cancel_check: CancellationCheck | None = None,
accepted_step_callback: AcceptedStepCallback | None = None,
):
"""Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback."""
if abs(config.t_stop - config.t_start) <= 1e-15:
return ODESolution(
t=[float(config.t_start)],
y=[[value] for value in initial_state],
success=True,
message="Skipped integration because t_start equals t_stop.",
)
try:
from scipy.integrate import solve_ivp
except ImportError:
return _runge_kutta_4(
rhs,
initial_state,
config,
t_eval,
cancel_check,
accepted_step_callback,
)
if cancel_check is not None:
return _integrate_scipy_stepwise(
rhs,
initial_state,
config,
t_eval,
cancel_check,
accepted_step_callback,
)
return solve_ivp(
fun=rhs,
t_span=(config.t_start, config.t_stop),
y0=initial_state,
method=config.method,
rtol=config.rtol,
atol=config.atol,
max_step=config.max_step,
t_eval=t_eval,
)
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from dataclasses import dataclass
from app.simulation.core.base import DynamicComponent
from app.simulation.systems.network import Endpoint, SimulationNetwork
class StreamSolveError(RuntimeError):
def __init__(self, message: str, diagnostics: "StreamSolveDiagnostics") -> None:
super().__init__(message)
self.diagnostics = diagnostics
@dataclass(frozen=True)
class StreamSolveDiagnostics:
converged: bool
iterations: int
max_delta: float
def as_dict(self) -> dict[str, object]:
return {
"converged": self.converged,
"iterations": self.iterations,
"maxDelta": self.max_delta,
}
class StreamResolver:
"""Resolve outflow enthalpy propagation after pressure and flow are known."""
def __init__(
self,
network: SimulationNetwork,
*,
relative_tolerance: float = 1e-9,
max_iterations: int = 100,
) -> None:
self.network = network
self.relative_tolerance = relative_tolerance
self.max_iterations = max_iterations
self._connected_endpoint = self._build_connection_map()
self.last_diagnostics: StreamSolveDiagnostics | None = None
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
result: dict[Endpoint, Endpoint] = {}
for connection in self.network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
result[first] = second
result[second] = first
return result
def connected_enthalpies(self) -> dict[str, dict[str, float]]:
values: dict[str, dict[str, float]] = {
component.name: {} for component in self.network.components.values()
}
for endpoint, connected in self._connected_endpoint.items():
connected_port = self.network.components[connected.component].get_port(
connected.port
)
values[endpoint.component][endpoint.port] = connected_port.h_outflow
return values
def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
dynamic_components = [
component
for component in self.network.components.values()
if isinstance(component, DynamicComponent)
]
for component in dynamic_components:
component.refresh_thermodynamic_ports()
max_delta = 0.0
for iteration in range(1, self.max_iterations + 1):
previous = {
(component.name, port_name): port.h_outflow
for component in self.network.components.values()
for port_name, port in component.ports.items()
}
connected = self.connected_enthalpies()
for component in self.network.components.values():
if isinstance(component, DynamicComponent):
component.refresh_thermodynamic_ports()
else:
component.update_stream_outflows(connected[component.name])
deltas = [
abs(port.h_outflow - previous[(component.name, port_name)])
for component in self.network.components.values()
for port_name, port in component.ports.items()
]
magnitudes = [
abs(port.h_outflow)
for component in self.network.components.values()
for port in component.ports.values()
]
max_delta = max(deltas, default=0.0)
scale = max(magnitudes + [1.0])
if max_delta <= self.relative_tolerance * scale:
diagnostics = StreamSolveDiagnostics(
converged=True,
iterations=iteration,
max_delta=max_delta,
)
self.last_diagnostics = diagnostics
return diagnostics, self.connected_enthalpies()
diagnostics = StreamSolveDiagnostics(
converged=False,
iterations=self.max_iterations,
max_delta=max_delta,
)
self.last_diagnostics = diagnostics
raise StreamSolveError(
"Stream enthalpy propagation did not converge.",
diagnostics,
)
+2
View File
@@ -0,0 +1,2 @@
"""System assembly modules."""
+463
View File
@@ -0,0 +1,463 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from math import floor, isfinite
from typing import Literal
from app.simulation.core.base import DynamicComponent
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
from app.simulation.solvers.stream import StreamResolver
from app.simulation.systems.network import Endpoint, SimulationNetwork
SimulationProgressCallback = Callable[[float, str], None]
SimulationCancellationCheck = Callable[[], bool]
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
@dataclass(frozen=True)
class SimulationPreparationIssue:
code: str
message: str
def as_dict(self) -> dict[str, str]:
return {"code": self.code, "message": self.message}
class SimulationPreparationError(ValueError):
def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None:
super().__init__("The compiled model is not ready for simulation.")
self.issues = issues
@dataclass(frozen=True)
class GenericSimulationResult:
success: bool
status: SimulationRunStatus
message: str
simulated_until: float
requested_stop_time: float
variables: tuple[ResultVariableMetadata, ...]
series: dict[str, list[float]]
final: dict[str, float]
diagnostics: dict[str, object]
def as_dict(self) -> dict[str, object]:
return {
"success": self.success,
"status": self.status,
"partial": self.status != "completed",
"message": self.message,
"simulatedUntil": self.simulated_until,
"requestedStopTime": self.requested_stop_time,
"variables": [variable.as_dict() for variable in self.variables],
"series": self.series,
"final": self.final,
"diagnostics": self.diagnostics,
}
class _UnionFind:
def __init__(self, items: set[Endpoint]) -> None:
self.parent = {item: item for item in items}
def find(self, item: Endpoint) -> Endpoint:
parent = self.parent[item]
if parent != item:
self.parent[item] = self.find(parent)
return self.parent[item]
def union(self, first: Endpoint, second: Endpoint) -> None:
first_root = self.find(first)
second_root = self.find(second)
if first_root != second_root:
self.parent[second_root] = first_root
def _equation_port(component_name: str, variable: str) -> Endpoint | None:
parts = variable.rsplit(".", 2)
if len(parts) != 3:
return None
prefix, port_name, variable_name = parts
if prefix != component_name or variable_name != "p":
return None
return Endpoint(component_name, port_name)
def simulation_preparation_issues(
network: SimulationNetwork,
) -> tuple[SimulationPreparationIssue, ...]:
issues: list[SimulationPreparationIssue] = []
physical_endpoints = {
Endpoint(component.name, definition.name)
for component in network.components.values()
for definition in component.port_definitions
if definition.kind == "physical"
}
connected_endpoints = {
endpoint
for connection in network.connections
if connection.kind == "physical"
for endpoint in connection.endpoints
}
for endpoint in sorted(physical_endpoints - connected_endpoints, key=str):
issues.append(
SimulationPreparationIssue(
"PORT_UNCONNECTED",
f"Physical port {endpoint} must be connected before simulation.",
)
)
if any(
definition.kind == "signal"
for component in network.components.values()
for definition in component.port_definitions
):
issues.append(
SimulationPreparationIssue(
"SIGNAL_PORT_UNSUPPORTED",
"Signal-port simulation is not implemented in the current MVP solver.",
)
)
structure = network.pressure_flow_structure_dict()
if not structure["isSquare"]:
issues.append(
SimulationPreparationIssue(
"PRESSURE_FLOW_SYSTEM_NOT_SQUARE",
"Pressure-flow equation count does not match the unknown count: "
f"{structure['equationCount']} equations for {structure['unknownCount']} unknowns.",
)
)
dynamic_names = {
component.name
for component in network.components.values()
if isinstance(component, DynamicComponent)
}
if not dynamic_names:
issues.append(
SimulationPreparationIssue(
"DYNAMIC_STATE_MISSING",
"Each simulated network requires at least one storage component.",
)
)
adjacency = {name: set() for name in network.components}
for connection in network.connections:
first, second = connection.endpoints
adjacency[first.component].add(second.component)
adjacency[second.component].add(first.component)
remaining = set(adjacency)
while remaining:
start = remaining.pop()
group = {start}
stack = [start]
while stack:
current = stack.pop()
for neighbour in adjacency[current] - group:
group.add(neighbour)
remaining.discard(neighbour)
stack.append(neighbour)
if not (group & dynamic_names):
issues.append(
SimulationPreparationIssue(
"ALGEBRAIC_ISLAND_HAS_NO_STORAGE",
"A connected physical network has no pressure/enthalpy storage anchor: "
+ ", ".join(sorted(group))
+ ".",
)
)
if physical_endpoints:
effort_groups = _UnionFind(physical_endpoints)
for connection in network.connections:
if connection.kind == "physical":
effort_groups.union(*connection.endpoints)
storage_ports: dict[Endpoint, str] = {}
for component in network.components.values():
for equation in component.pressure_flow_equation_residuals():
pressure_ports = [
endpoint
for variable in equation.variables
if (endpoint := _equation_port(component.name, variable)) is not None
]
if equation.relation == "equal" and len(pressure_ports) == 2:
effort_groups.union(pressure_ports[0], pressure_ports[1])
if equation.relation == "state":
for endpoint in pressure_ports:
storage_ports[endpoint] = component.name
storages_by_group: dict[Endpoint, set[str]] = {}
for endpoint, component_name in storage_ports.items():
storages_by_group.setdefault(effort_groups.find(endpoint), set()).add(
component_name
)
for storage_names in storages_by_group.values():
if len(storage_names) > 1:
issues.append(
SimulationPreparationIssue(
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
"Storage components are connected without a resistance: "
+ ", ".join(sorted(storage_names))
+ ". Insert an orifice or pipe between them.",
)
)
return tuple(issues)
def simulation_sample_times(
config: SolveIVPConfig,
step: float,
*,
max_points: int = 10001,
) -> list[float]:
if step <= 0.0 or not isfinite(step):
raise ValueError("Simulation sample step must be finite and greater than zero.")
duration = config.t_stop - config.t_start
if duration <= 0.0:
raise ValueError("Simulation stop time must be greater than start time.")
interval_count = int(floor(duration / step + 1e-12))
times = [config.t_start + index * step for index in range(interval_count + 1)]
if times[-1] < config.t_stop - 1e-12:
times.append(config.t_stop)
else:
times[-1] = config.t_stop
if len(times) > max_points:
raise ValueError(
f"Simulation requests {len(times)} samples; the limit is {max_points}."
)
return times
class GenericFluidSystem:
"""Topology-driven, semi-explicit fluid simulation for registered components."""
def __init__(self, network: SimulationNetwork) -> None:
issues = simulation_preparation_issues(network)
if issues:
raise SimulationPreparationError(issues)
self.network = network
self.dynamic_components = network.dynamic_components()
self.pressure_flow_solver = PressureFlowSolver(network)
self.stream_resolver = StreamResolver(network)
self.algebraic_solve_count = 0
self.max_algebraic_residual = 0.0
self.max_algebraic_evaluations = 0
self.max_stream_iterations = 0
def initial_state_vector(self) -> list[float]:
return self.network.initial_state_vector()
def apply_state_vector(self, values: list[float]) -> None:
self.network.apply_state_vector(values)
def _close_current_state(self) -> dict[str, dict[str, float]]:
for component in self.dynamic_components:
component.refresh_thermodynamic_ports()
algebraic = self.pressure_flow_solver.solve()
stream, connected_h = self.stream_resolver.solve()
self.algebraic_solve_count += 1
self.max_algebraic_residual = max(
self.max_algebraic_residual,
algebraic.max_scaled_residual,
)
self.max_algebraic_evaluations = max(
self.max_algebraic_evaluations,
algebraic.evaluations,
)
self.max_stream_iterations = max(
self.max_stream_iterations,
stream.iterations,
)
return connected_h
def consistent_initial_state_vector(self) -> list[float]:
state = self.initial_state_vector()
self.apply_state_vector(state)
self._close_current_state()
return state
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
self.apply_state_vector(state_vector)
connected_h = self._close_current_state()
derivatives: list[float] = []
for component in self.dynamic_components:
derivatives.extend(
component.state_derivative_from_ports(connected_h[component.name])
)
return derivatives
def _append_current_state(self, series: dict[str, list[float]]) -> None:
for component in self.network.components.values():
for relative_key, value in component.result_values().items():
series.setdefault(
f"{component.name}.{relative_key}", []
).append(value)
def simulate(
self,
config: SolveIVPConfig,
*,
sample_step: float,
progress_callback: SimulationProgressCallback | None = None,
cancel_check: SimulationCancellationCheck | None = None,
) -> GenericSimulationResult:
last_reported_progress = -1.0
last_reported_phase = ""
def report_progress(
progress: float,
phase: str,
*,
force: bool = False,
) -> None:
nonlocal last_reported_phase, last_reported_progress
if progress_callback is None:
return
bounded_progress = min(1.0, max(0.0, progress))
if (
force
or phase != last_reported_phase
or bounded_progress - last_reported_progress >= 0.0025
):
last_reported_phase = phase
last_reported_progress = max(
last_reported_progress,
bounded_progress,
)
progress_callback(last_reported_progress, phase)
report_progress(0.0, "initializing", force=True)
t_eval = simulation_sample_times(config, sample_step)
initial_state = self.consistent_initial_state_vector()
report_progress(0.0, "integrating", force=True)
duration = config.t_stop - config.t_start
furthest_solver_time = config.t_start
def report_solver_time(time: float) -> None:
nonlocal furthest_solver_time
furthest_solver_time = max(furthest_solver_time, float(time))
time_fraction = (
(furthest_solver_time - config.t_start) / duration
if duration > 0.0
else 1.0
)
report_progress(time_fraction, "integrating")
def monitored_rhs(time: float, state_vector: list[float]) -> list[float]:
if cancel_check is None:
report_solver_time(time)
return self.rhs(time, state_vector)
solution = integrate_ode(
rhs=monitored_rhs,
initial_state=initial_state,
config=config,
t_eval=t_eval,
cancel_check=cancel_check,
accepted_step_callback=(
report_solver_time if cancel_check is not None else None
),
)
if isinstance(solution, ODESolution):
run_status: SimulationRunStatus = solution.status
integration_error = solution.error
else:
run_status = "completed" if bool(solution.success) else "failed"
integration_error = None
result_message = str(solution.message)
postprocess_progress = (
1.0
if run_status == "completed"
else max(0.0, last_reported_progress)
)
report_progress(postprocess_progress, "postprocessing", force=True)
times = [float(value) for value in solution.t]
series: dict[str, list[float]] = {"time": []}
postprocessing_error: Exception | None = None
for time_index in range(len(times)):
if (
run_status == "completed"
and cancel_check is not None
and cancel_check()
):
run_status = "cancelled"
result_message = "Simulation was stopped while preparing partial results."
break
state = [
float(solution.y[state_index][time_index])
for state_index in range(len(solution.y))
]
try:
self.apply_state_vector(state)
self._close_current_state()
self._append_current_state(series)
series["time"].append(times[time_index])
except Exception as exc:
run_status = "failed"
result_message = str(exc)
postprocessing_error = exc
break
if len(series["time"]) < 2:
if postprocessing_error is not None:
raise postprocessing_error
if integration_error is not None:
raise integration_error
final = {
key: values[-1]
for key, values in series.items()
if key != "time" and values
}
diagnostics = {
"pressureFlow": {
"solveCount": self.algebraic_solve_count,
"maxScaledResidual": self.max_algebraic_residual,
"maxEvaluationsPerSolve": self.max_algebraic_evaluations,
"last": (
self.pressure_flow_solver.last_diagnostics.as_dict()
if self.pressure_flow_solver.last_diagnostics is not None
else None
),
},
"stream": {
"maxIterationsPerSolve": self.max_stream_iterations,
"last": (
self.stream_resolver.last_diagnostics.as_dict()
if self.stream_resolver.last_diagnostics is not None
else None
),
},
"stateCount": len(initial_state),
"sampleCount": len(series["time"]),
}
variables = tuple(
variable
for variable in self.network.result_variable_metadata()
if variable.key in series
)
report_progress(
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
"complete" if run_status == "completed" else run_status,
force=True,
)
return GenericSimulationResult(
success=run_status == "completed" and bool(solution.success),
status=run_status,
message=result_message,
simulated_until=(
float(series["time"][-1])
if series["time"]
else float(config.t_start)
),
requested_stop_time=float(config.t_stop),
variables=variables,
series=series,
final=final,
diagnostics=diagnostics,
)
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
from dataclasses import dataclass
from app.simulation.core.base import Component, DynamicComponent
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.core.ports import PortState
@dataclass(frozen=True)
class Endpoint:
component: str
port: str
@property
def key(self) -> tuple[str, str]:
return self.component, self.port
def __str__(self) -> str:
return f"{self.component}.{self.port}"
@dataclass(frozen=True)
class Connection:
id: str
kind: str
domain: str
endpoint_a: Endpoint
endpoint_b: Endpoint
@property
def endpoints(self) -> tuple[Endpoint, Endpoint]:
return self.endpoint_a, self.endpoint_b
@property
def undirected_key(self) -> tuple[tuple[str, str], tuple[str, str]]:
first, second = sorted((self.endpoint_a.key, self.endpoint_b.key))
return first, second
# Compatibility accessors for existing reports. They do not imply physical flow.
@property
def source_component(self) -> str:
return self.endpoint_a.component
@property
def source_port(self) -> str:
return self.endpoint_a.port
@property
def target_component(self) -> str:
return self.endpoint_b.component
@property
def target_port(self) -> str:
return self.endpoint_b.port
def as_interface_dict(self) -> dict[str, object]:
return {
"id": self.id,
"kind": self.kind,
"domain": self.domain,
"endpoints": [
{"component": endpoint.component, "port": endpoint.port}
for endpoint in self.endpoints
],
}
class SimulationNetwork:
"""Container for components, topology, and state-vector bookkeeping."""
def __init__(self, name: str) -> None:
self.name = name
self.components: dict[str, Component] = {}
self.connections: list[Connection] = []
def add_component(self, component: Component) -> None:
if component.name in self.components:
raise ValueError(f"Duplicate component name: {component.name}")
self.components[component.name] = component
def connect(
self,
endpoint_a_component: str,
endpoint_a_port: str,
endpoint_b_component: str,
endpoint_b_port: str,
*,
connection_id: str | None = None,
) -> Connection:
endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port)
endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port)
if endpoint_a == endpoint_b:
raise ValueError(f"Cannot connect endpoint {endpoint_a} to itself.")
first_port = self._port_for(endpoint_a)
second_port = self._port_for(endpoint_b)
first_definition = first_port.definition
second_definition = second_port.definition
if first_definition is None or second_definition is None:
raise ValueError("Connected ports must expose interface definitions.")
if first_definition.kind != second_definition.kind:
raise ValueError(f"Connection mixes physical and signal ports: {endpoint_a}, {endpoint_b}.")
if first_definition.domain != second_definition.domain:
raise ValueError(f"Connection domains do not match: {endpoint_a}, {endpoint_b}.")
if first_definition.variables != second_definition.variables:
raise ValueError(
f"Connection variable contracts do not match: {endpoint_a}, {endpoint_b}."
)
if first_definition.kind == "signal" and {
first_definition.nominal_role,
second_definition.nominal_role,
} != {"input", "output"}:
raise ValueError("A signal connection must contain one output and one input.")
occupied_endpoints = {
endpoint
for item in self.connections
for endpoint in item.endpoints
}
if first_definition.kind == "physical":
occupied = [
str(endpoint)
for endpoint in (endpoint_a, endpoint_b)
if endpoint in occupied_endpoints
]
if occupied:
raise ValueError(
"Physical ports accept one connection; already connected: "
+ ", ".join(occupied)
+ ". Use a junction component for branching."
)
if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key:
endpoint_a, endpoint_b = endpoint_b, endpoint_a
connection = Connection(
id=connection_id or f"connection_{len(self.connections) + 1}",
kind=first_definition.kind,
domain=first_definition.domain,
endpoint_a=endpoint_a,
endpoint_b=endpoint_b,
)
if any(item.undirected_key == connection.undirected_key for item in self.connections):
raise ValueError(f"Duplicate connection between {endpoint_a} and {endpoint_b}.")
if any(item.id == connection.id for item in self.connections):
raise ValueError(f"Duplicate connection id: {connection.id}.")
self.connections.append(connection)
return connection
def _port_for(self, endpoint: Endpoint) -> PortState:
try:
component = self.components[endpoint.component]
except KeyError as exc:
raise ValueError(f"Unknown component: {endpoint.component}.") from exc
return component.get_port(endpoint.port)
def connection_equation_residuals(self) -> tuple[EquationResidual, ...]:
"""Evaluate connector equations that have a direct scalar residual.
Stream variables are resolved by the stream-mixing layer and therefore do
not incorrectly appear here as an equality between outflow properties.
"""
residuals: list[EquationResidual] = []
for connection in self.connections:
if connection.kind != "physical":
continue
first_port = self._port_for(connection.endpoint_a)
second_port = self._port_for(connection.endpoint_b)
definition = first_port.definition
if definition is None:
raise ValueError(
f"Connected port {connection.endpoint_a} has no interface definition."
)
for variable in definition.variables:
if variable.connection_rule == "equal":
value = float(getattr(first_port, variable.name)) - float(
getattr(second_port, variable.name)
)
elif variable.connection_rule == "sumToZero":
value = float(getattr(first_port, variable.name)) + float(
getattr(second_port, variable.name)
)
else:
continue
residuals.append(
EquationResidual(
id=f"{connection.id}:{variable.name}",
owner="connection",
owner_id=connection.id,
relation=variable.connection_rule,
variables=(
f"{connection.endpoint_a}.{variable.name}",
f"{connection.endpoint_b}.{variable.name}",
),
role=variable.role,
value=value,
)
)
return tuple(residuals)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
"""Evaluate the complete algebraic pressure-flow equation subsystem."""
component_residuals = tuple(
residual
for component in self.components.values()
for residual in component.pressure_flow_equation_residuals()
)
return component_residuals + self.connection_equation_residuals()
def pressure_flow_unknowns(self) -> tuple[str, ...]:
return tuple(
f"{component.name}.{definition.name}.{variable.name}"
for component in self.components.values()
for definition in component.port_definitions
if definition.kind == "physical"
for variable in definition.variables
if variable.role in {"effort", "flow"}
)
def pressure_flow_structure_dict(self) -> dict[str, object]:
unknowns = self.pressure_flow_unknowns()
equations = self.pressure_flow_equation_residuals()
return {
"unknownCount": len(unknowns),
"equationCount": len(equations),
"isSquare": len(unknowns) == len(equations),
"unknowns": list(unknowns),
"equations": [
equation.as_definition_dict() for equation in equations
],
}
def dynamic_components(self) -> list[DynamicComponent]:
return [
component
for component in self.components.values()
if isinstance(component, DynamicComponent)
]
def initial_state_vector(self) -> list[float]:
values: list[float] = []
for component in self.dynamic_components():
values.extend(component.get_state_vector())
return values
def apply_state_vector(self, values: list[float]) -> None:
cursor = 0
for component in self.dynamic_components():
next_cursor = cursor + component.state_size
component.set_state_vector(values[cursor:next_cursor])
cursor = next_cursor
if cursor != len(values):
raise ValueError("State vector length does not match dynamic components.")
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
return tuple(
variable
for component in self.components.values()
for variable in component.result_variable_metadata()
)
def summary(self) -> str:
lines = [f"Network: {self.name}", "Components:"]
for name, component in self.components.items():
lines.append(f" - {name}: {component.__class__.__name__}")
lines.append("Connections:")
for conn in self.connections:
connector = "<->" if conn.kind == "physical" else "->"
lines.append(
f" - {conn.endpoint_a} {connector} {conn.endpoint_b}"
)
return "\n".join(lines)
def as_interface_dict(self) -> dict[str, object]:
connected_endpoints = {
endpoint.key
for connection in self.connections
for endpoint in connection.endpoints
}
return {
"name": self.name,
"components": [
{
"id": component.name,
"type": component.model_type,
"parameters": component.parameter_interface_dicts(),
"ports": [
definition.as_interface_dict()
for definition in component.port_definitions
],
"resultVariables": [
variable.as_dict()
for variable in component.result_variable_metadata()
],
}
for component in self.components.values()
],
"connections": [
connection.as_interface_dict() for connection in self.connections
],
"pressureFlowSystem": self.pressure_flow_structure_dict(),
"unconnectedPorts": [
{"component": component.name, "port": definition.name}
for component in self.components.values()
for definition in component.port_definitions
if (component.name, definition.name) not in connected_endpoints
],
}
+844
View File
@@ -0,0 +1,844 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from functools import lru_cache
from math import isfinite
from pathlib import Path
from typing import Literal
from lxml import etree
from app.simulation.core.ports import PortDefinition
from app.simulation.registry import (
COMPONENT_MODEL_REGISTRY,
ParameterSpec,
)
ValidationLayer = Literal["xml", "schema", "semantic"]
ValidationSeverity = Literal["error", "warning"]
SYSTEM_XML_MAX_BYTES = 5 * 1024 * 1024
SYSTEM_XML_V2_SCHEMA_PATH = (
Path(__file__).resolve().parent.parent / "schemas" / "system-simulation-v2.xsd"
)
SUPPORTED_SOLVER_METHODS = {"RK45", "RK23", "DOP853", "Radau", "BDF", "LSODA"}
@dataclass(frozen=True)
class ValidationIssue:
layer: ValidationLayer
code: str
message: str
severity: ValidationSeverity = "error"
path: str | None = None
line: int | None = None
def as_dict(self) -> dict[str, object]:
result: dict[str, object] = {
"severity": self.severity,
"layer": self.layer,
"code": self.code,
"message": self.message,
}
if self.path is not None:
result["path"] = self.path
if self.line is not None:
result["line"] = self.line
return result
@dataclass(frozen=True)
class SystemXmlSimulation:
t_start: float
t_stop: float
step: float
max_step: float
method: str
line: int | None = None
@dataclass(frozen=True)
class SystemXmlPort:
name: str
kind: str
domain: str
nominal_role: str
positive_flow_direction: str | None
side: str
line: int | None = None
def as_project_data(self) -> dict[str, object]:
data: dict[str, object] = {
"name": self.name,
"kind": self.kind,
"domain": self.domain,
"nominalRole": self.nominal_role,
"side": self.side,
}
if self.positive_flow_direction is not None:
data["positiveFlowDirection"] = self.positive_flow_direction
return data
@dataclass(frozen=True)
class SystemXmlParameter:
name: str
value: float
line: int | None = None
@dataclass(frozen=True)
class SystemXmlComponent:
id: str
name: str
model_type: str
component_type: str
x: float
y: float
rotation: int
mirrored: bool
ports: tuple[SystemXmlPort, ...]
parameters: tuple[SystemXmlParameter, ...]
line: int | None = None
@property
def port_by_name(self) -> dict[str, SystemXmlPort]:
return {port.name: port for port in self.ports}
@dataclass(frozen=True)
class SystemXmlEndpoint:
component: str
port: str
role: str | None
line: int | None = None
@property
def key(self) -> tuple[str, str]:
return self.component, self.port
@dataclass(frozen=True)
class SystemXmlConnection:
id: str
kind: str
domain: str
endpoints: tuple[SystemXmlEndpoint, SystemXmlEndpoint]
line: int | None = None
@property
def undirected_key(self) -> tuple[tuple[str, str], tuple[str, str]]:
first, second = sorted(endpoint.key for endpoint in self.endpoints)
return first, second
@dataclass(frozen=True)
class SystemXmlDocument:
name: str
schema_version: str
unit_system: str
simulation: SystemXmlSimulation
components: tuple[SystemXmlComponent, ...]
connections: tuple[SystemXmlConnection, ...]
def summary(self) -> dict[str, object]:
return {
"name": self.name,
"schemaVersion": self.schema_version,
"unitSystem": self.unit_system,
"componentCount": len(self.components),
"connectionCount": len(self.connections),
}
def as_project_data(self) -> dict[str, object]:
edges = []
for connection in self.connections:
first, second = connection.endpoints
if connection.kind == "signal":
by_role = {endpoint.role: endpoint for endpoint in connection.endpoints}
first = by_role.get("source", first)
second = by_role.get("target", second)
edges.append(
{
"id": connection.id,
"source": first.component,
"sourceHandle": first.port,
"target": second.component,
"targetHandle": second.port,
}
)
return {
"name": self.name,
"nodes": [
{
"id": component.id,
"type": "simulationComponent",
"position": {"x": component.x, "y": component.y},
"data": {
"label": component.name,
"componentType": component.component_type,
"modelType": component.model_type,
"ports": [port.as_project_data() for port in component.ports],
"parameters": {
parameter.name: parameter.value
for parameter in component.parameters
},
"rotation": component.rotation,
"mirrored": component.mirrored,
},
}
for component in self.components
],
"edges": edges,
"simulation": {
"t_start": self.simulation.t_start,
"t_stop": self.simulation.t_stop,
"step": self.simulation.step,
"max_step": self.simulation.max_step,
"method": self.simulation.method,
},
}
@dataclass(frozen=True)
class SystemXmlValidationReport:
document: SystemXmlDocument | None
issues: tuple[ValidationIssue, ...]
@property
def valid(self) -> bool:
return self.document is not None and not any(
issue.severity == "error" for issue in self.issues
)
def as_dict(self) -> dict[str, object]:
errors = sum(issue.severity == "error" for issue in self.issues)
warnings = sum(issue.severity == "warning" for issue in self.issues)
result: dict[str, object] = {
"valid": self.valid,
"errorCount": errors,
"warningCount": warnings,
"issues": [issue.as_dict() for issue in self.issues],
}
if self.document is not None:
result["system"] = self.document.summary()
return result
def validate_system_xml_document(
source: bytes | str,
) -> SystemXmlValidationReport:
xml_bytes = source.encode("utf-8") if isinstance(source, str) else source
if not xml_bytes.strip():
return _failed_report("xml", "XML_EMPTY", "The XML document is empty.")
if len(xml_bytes) > SYSTEM_XML_MAX_BYTES:
return _failed_report(
"xml",
"XML_TOO_LARGE",
f"The XML document exceeds {SYSTEM_XML_MAX_BYTES} bytes.",
)
parser = etree.XMLParser(
resolve_entities=False,
no_network=True,
load_dtd=False,
recover=False,
huge_tree=False,
)
try:
root = etree.fromstring(xml_bytes, parser=parser)
except etree.XMLSyntaxError as exc:
line, _ = exc.position
return _failed_report(
"xml",
"XML_SYNTAX_ERROR",
str(exc).split(", line", maxsplit=1)[0],
line=line,
)
if root.getroottree().docinfo.doctype:
return _failed_report(
"xml",
"XML_DTD_NOT_ALLOWED",
"DTD and entity declarations are not allowed.",
line=root.sourceline,
)
schema = _system_xml_v2_schema()
if not schema.validate(root):
issues = tuple(
ValidationIssue(
layer="schema",
code="XSD_VALIDATION_ERROR",
message=entry.message.strip(),
path=entry.path or None,
line=entry.line or None,
)
for entry in schema.error_log
)
return SystemXmlValidationReport(document=None, issues=issues)
document = _parse_validated_root(root)
issues = tuple(_semantic_issues(document))
return SystemXmlValidationReport(document=document, issues=issues)
@lru_cache(maxsize=1)
def _system_xml_v2_schema() -> etree.XMLSchema:
schema_document = etree.parse(str(SYSTEM_XML_V2_SCHEMA_PATH))
return etree.XMLSchema(schema_document)
def _failed_report(
layer: ValidationLayer,
code: str,
message: str,
*,
line: int | None = None,
) -> SystemXmlValidationReport:
return SystemXmlValidationReport(
document=None,
issues=(ValidationIssue(layer=layer, code=code, message=message, line=line),),
)
def _parse_validated_root(root: etree._Element) -> SystemXmlDocument:
simulation_element = root.find("Simulation")
components_element = root.find("Components")
connections_element = root.find("Connections")
assert simulation_element is not None
assert components_element is not None
assert connections_element is not None
simulation = SystemXmlSimulation(
t_start=float(simulation_element.get("tStart")),
t_stop=float(simulation_element.get("tStop")),
step=float(simulation_element.get("step")),
max_step=float(simulation_element.get("maxStep")),
method=str(simulation_element.get("method")),
line=simulation_element.sourceline,
)
components = tuple(
_parse_component(component) for component in components_element.findall("Component")
)
connections = tuple(
_parse_connection(connection)
for connection in connections_element.findall("Connection")
)
return SystemXmlDocument(
name=str(root.get("name")),
schema_version=str(root.get("schemaVersion")),
unit_system=str(root.get("unitSystem")),
simulation=simulation,
components=components,
connections=connections,
)
def _parse_component(element: etree._Element) -> SystemXmlComponent:
ports = tuple(
SystemXmlPort(
name=str(port.get("name")),
kind=str(port.get("kind")),
domain=str(port.get("domain")),
nominal_role=str(port.get("nominalRole")),
positive_flow_direction=port.get("positiveFlowDirection"),
side=str(port.get("side")),
line=port.sourceline,
)
for port in element.findall("Port")
)
parameters = tuple(
SystemXmlParameter(
name=str(parameter.get("name")),
value=float(parameter.get("value")),
line=parameter.sourceline,
)
for parameter in element.findall("Parameter")
)
return SystemXmlComponent(
id=str(element.get("id")),
name=str(element.get("name")),
model_type=str(element.get("type")),
component_type=str(element.get("componentType")),
x=float(element.get("x")),
y=float(element.get("y")),
rotation=int(element.get("rotation", "0")),
mirrored=element.get("mirrored", "false") in {"true", "1"},
ports=ports,
parameters=parameters,
line=element.sourceline,
)
def _parse_connection(element: etree._Element) -> SystemXmlConnection:
endpoints = tuple(
SystemXmlEndpoint(
component=str(endpoint.get("component")),
port=str(endpoint.get("port")),
role=endpoint.get("role"),
line=endpoint.sourceline,
)
for endpoint in element.findall("Endpoint")
)
assert len(endpoints) == 2
return SystemXmlConnection(
id=str(element.get("id")),
kind=str(element.get("kind")),
domain=str(element.get("domain")),
endpoints=(endpoints[0], endpoints[1]),
line=element.sourceline,
)
def _semantic_issues(document: SystemXmlDocument) -> list[ValidationIssue]:
issues: list[ValidationIssue] = []
_validate_system_and_simulation(document, issues)
component_by_id = _validate_components(document, issues)
_validate_connections(document, component_by_id, issues)
return issues
def _validate_system_and_simulation(
document: SystemXmlDocument,
issues: list[ValidationIssue],
) -> None:
if not document.name.strip():
issues.append(_semantic_issue("SYSTEM_NAME_EMPTY", "System name cannot be blank.", "/System"))
if not document.components:
issues.append(
_semantic_issue(
"SYSTEM_HAS_NO_COMPONENTS",
"The system must contain at least one component.",
"/System/Components",
)
)
simulation = document.simulation
values = {
"tStart": simulation.t_start,
"tStop": simulation.t_stop,
"step": simulation.step,
"maxStep": simulation.max_step,
}
for name, value in values.items():
if not isfinite(value):
issues.append(
_semantic_issue(
"SIMULATION_VALUE_NOT_FINITE",
f"Simulation value {name} must be finite.",
f"/System/Simulation/@{name}",
simulation.line,
)
)
if isfinite(simulation.t_start) and isfinite(simulation.t_stop):
if simulation.t_stop <= simulation.t_start:
issues.append(
_semantic_issue(
"SIMULATION_TIME_RANGE_INVALID",
"Simulation tStop must be greater than tStart.",
"/System/Simulation",
simulation.line,
)
)
for name, value in {
"step": simulation.step,
"maxStep": simulation.max_step,
}.items():
if isfinite(value) and value <= 0.0:
issues.append(
_semantic_issue(
"SIMULATION_STEP_INVALID",
f"Simulation value {name} must be greater than zero.",
f"/System/Simulation/@{name}",
simulation.line,
)
)
if simulation.method not in SUPPORTED_SOLVER_METHODS:
issues.append(
_semantic_issue(
"SIMULATION_METHOD_UNSUPPORTED",
f"Unsupported solver method: {simulation.method}.",
"/System/Simulation/@method",
simulation.line,
)
)
def _validate_components(
document: SystemXmlDocument,
issues: list[ValidationIssue],
) -> dict[str, SystemXmlComponent]:
component_by_id: dict[str, SystemXmlComponent] = {}
names: dict[str, str] = {}
for index, component in enumerate(document.components, start=1):
path = f"/System/Components/Component[{index}]"
if component.id in component_by_id:
issues.append(
_semantic_issue(
"COMPONENT_ID_DUPLICATE",
f"Duplicate component id: {component.id}.",
path,
component.line,
)
)
else:
component_by_id[component.id] = component
if component.name in names:
issues.append(
_semantic_issue(
"COMPONENT_NAME_DUPLICATE",
f"Duplicate component name: {component.name}.",
path,
component.line,
)
)
else:
names[component.name] = component.id
spec = COMPONENT_MODEL_REGISTRY.get(component.model_type)
if spec is None:
issues.append(
_semantic_issue(
"COMPONENT_TYPE_UNSUPPORTED",
f"Unsupported component model type: {component.model_type}.",
f"{path}/@type",
component.line,
)
)
continue
if component.component_type != component.model_type:
issues.append(
_semantic_issue(
"COMPONENT_TYPE_MISMATCH",
f"componentType '{component.component_type}' does not match model type '{component.model_type}'.",
f"{path}/@componentType",
component.line,
)
)
if not isfinite(component.x) or not isfinite(component.y):
issues.append(
_semantic_issue(
"COMPONENT_POSITION_NOT_FINITE",
f"Component {component.id} position must be finite.",
path,
component.line,
)
)
_validate_component_ports(component, spec.ports, path, issues)
_validate_component_parameters(component, spec.parameter_by_name, path, issues)
return component_by_id
def _validate_component_ports(
component: SystemXmlComponent,
expected_ports: tuple[PortDefinition, ...],
component_path: str,
issues: list[ValidationIssue],
) -> None:
actual_by_name: dict[str, SystemXmlPort] = {}
for port_index, port in enumerate(component.ports, start=1):
path = f"{component_path}/Port[{port_index}]"
if port.name in actual_by_name:
issues.append(
_semantic_issue(
"PORT_NAME_DUPLICATE",
f"Component {component.id} contains duplicate port {port.name}.",
path,
port.line,
)
)
else:
actual_by_name[port.name] = port
expected_by_name = {port.name: port for port in expected_ports}
for name in sorted(set(expected_by_name) - set(actual_by_name)):
issues.append(
_semantic_issue(
"PORT_REQUIRED_MISSING",
f"Component {component.id} is missing registered port {name}.",
component_path,
component.line,
)
)
for name in sorted(set(actual_by_name) - set(expected_by_name)):
port = actual_by_name[name]
issues.append(
_semantic_issue(
"PORT_UNSUPPORTED",
f"Component {component.id} contains unsupported port {name}.",
component_path,
port.line,
)
)
for name in sorted(set(actual_by_name) & set(expected_by_name)):
actual = actual_by_name[name]
expected = expected_by_name[name]
path = f"{component_path}/Port[@name='{name}']"
if actual.kind != expected.kind or actual.domain != expected.domain:
issues.append(
_semantic_issue(
"PORT_INTERFACE_MISMATCH",
f"Port {component.id}.{name} has an incompatible kind or domain.",
path,
actual.line,
)
)
if actual.nominal_role != expected.nominal_role:
issues.append(
_semantic_issue(
"PORT_NOMINAL_ROLE_MISMATCH",
f"Port {component.id}.{name} has nominalRole '{actual.nominal_role}', expected '{expected.nominal_role}'.",
path,
actual.line,
)
)
if actual.kind == "physical" and (
actual.positive_flow_direction != expected.positive_flow_direction
):
issues.append(
_semantic_issue(
"PORT_FLOW_SIGN_MISMATCH",
f"Port {component.id}.{name} must use positiveFlowDirection='intoComponent'.",
path,
actual.line,
)
)
def _validate_component_parameters(
component: SystemXmlComponent,
expected_parameters: Mapping[str, ParameterSpec],
component_path: str,
issues: list[ValidationIssue],
) -> None:
actual_by_name: dict[str, SystemXmlParameter] = {}
for parameter_index, parameter in enumerate(component.parameters, start=1):
path = f"{component_path}/Parameter[{parameter_index}]"
if parameter.name in actual_by_name:
issues.append(
_semantic_issue(
"PARAMETER_NAME_DUPLICATE",
f"Component {component.id} contains duplicate parameter {parameter.name}.",
path,
parameter.line,
)
)
else:
actual_by_name[parameter.name] = parameter
for name in sorted(set(expected_parameters) - set(actual_by_name)):
issues.append(
_semantic_issue(
"PARAMETER_REQUIRED_MISSING",
f"Component {component.id} is missing required parameter {name}.",
component_path,
component.line,
)
)
for name in sorted(set(actual_by_name) - set(expected_parameters)):
parameter = actual_by_name[name]
issues.append(
_semantic_issue(
"PARAMETER_UNSUPPORTED",
f"Component {component.id} contains unsupported parameter {name}.",
component_path,
parameter.line,
)
)
for name in sorted(set(actual_by_name) & set(expected_parameters)):
parameter = actual_by_name[name]
message = expected_parameters[name].validation_message(parameter.value)
if message is not None:
issues.append(
_semantic_issue(
"PARAMETER_VALUE_INVALID",
f"Parameter {component.id}.{name} {message}.",
f"{component_path}/Parameter[@name='{name}']",
parameter.line,
)
)
def _validate_connections(
document: SystemXmlDocument,
component_by_id: dict[str, SystemXmlComponent],
issues: list[ValidationIssue],
) -> None:
connection_ids: set[str] = set()
connection_keys: set[tuple[tuple[str, str], tuple[str, str]]] = set()
occupied_physical_ports: dict[tuple[str, str], str] = {}
referenced_ports: set[tuple[str, str]] = set()
for index, connection in enumerate(document.connections, start=1):
path = f"/System/Connections/Connection[{index}]"
if connection.id in connection_ids:
issues.append(
_semantic_issue(
"CONNECTION_ID_DUPLICATE",
f"Duplicate connection id: {connection.id}.",
path,
connection.line,
)
)
connection_ids.add(connection.id)
if connection.undirected_key in connection_keys:
issues.append(
_semantic_issue(
"CONNECTION_DUPLICATE",
f"Connection {connection.id} duplicates an existing endpoint pair.",
path,
connection.line,
)
)
connection_keys.add(connection.undirected_key)
if connection.endpoints[0].key == connection.endpoints[1].key:
issues.append(
_semantic_issue(
"CONNECTION_SELF_REFERENCE",
f"Connection {connection.id} connects an endpoint to itself.",
path,
connection.line,
)
)
resolved_endpoints: list[tuple[SystemXmlEndpoint, SystemXmlPort]] = []
for endpoint_index, endpoint in enumerate(connection.endpoints, start=1):
endpoint_path = f"{path}/Endpoint[{endpoint_index}]"
component = component_by_id.get(endpoint.component)
if component is None:
issues.append(
_semantic_issue(
"ENDPOINT_COMPONENT_UNKNOWN",
f"Connection {connection.id} references unknown component {endpoint.component}.",
endpoint_path,
endpoint.line,
)
)
continue
port = component.port_by_name.get(endpoint.port)
if port is None:
issues.append(
_semantic_issue(
"ENDPOINT_PORT_UNKNOWN",
f"Connection {connection.id} references unknown port {endpoint.component}.{endpoint.port}.",
endpoint_path,
endpoint.line,
)
)
continue
resolved_endpoints.append((endpoint, port))
referenced_ports.add(endpoint.key)
if port.kind != connection.kind or port.domain != connection.domain:
issues.append(
_semantic_issue(
"CONNECTION_INTERFACE_MISMATCH",
f"Connection {connection.id} kind/domain does not match {endpoint.component}.{endpoint.port}.",
endpoint_path,
endpoint.line,
)
)
if connection.kind == "physical":
if endpoint.role is not None:
issues.append(
_semantic_issue(
"PHYSICAL_ENDPOINT_HAS_ROLE",
f"Physical endpoint {endpoint.component}.{endpoint.port} must not declare a source/target role.",
endpoint_path,
endpoint.line,
)
)
previous = occupied_physical_ports.get(endpoint.key)
if previous is not None:
issues.append(
_semantic_issue(
"PHYSICAL_PORT_ALREADY_CONNECTED",
f"Physical port {endpoint.component}.{endpoint.port} is already used by connection {previous}; use a Tee for branching.",
endpoint_path,
endpoint.line,
)
)
else:
occupied_physical_ports[endpoint.key] = connection.id
if len(resolved_endpoints) == 2:
first_port = resolved_endpoints[0][1]
second_port = resolved_endpoints[1][1]
if first_port.kind != second_port.kind:
issues.append(
_semantic_issue(
"CONNECTION_MIXES_PORT_KINDS",
f"Connection {connection.id} mixes physical and signal ports.",
path,
connection.line,
)
)
if first_port.domain != second_port.domain:
issues.append(
_semantic_issue(
"CONNECTION_DOMAIN_MISMATCH",
f"Connection {connection.id} connects different physical domains.",
path,
connection.line,
)
)
if connection.kind == "signal":
roles = {endpoint.role for endpoint in connection.endpoints}
if roles != {"source", "target"}:
issues.append(
_semantic_issue(
"SIGNAL_ENDPOINT_ROLES_INVALID",
f"Signal connection {connection.id} must contain source and target roles.",
path,
connection.line,
)
)
for endpoint, port in resolved_endpoints:
expected_role = "source" if port.nominal_role == "output" else "target"
if endpoint.role != expected_role:
issues.append(
_semantic_issue(
"SIGNAL_DIRECTION_MISMATCH",
f"Signal endpoint {endpoint.component}.{endpoint.port} has role '{endpoint.role}', expected '{expected_role}'.",
path,
endpoint.line,
)
)
for component in document.components:
for port in component.ports:
if (component.id, port.name) not in referenced_ports:
issues.append(
_semantic_issue(
"PORT_UNCONNECTED",
f"Port {component.id}.{port.name} is not connected.",
f"/System/Components/Component[@id='{component.id}']/Port[@name='{port.name}']",
port.line,
severity="warning",
)
)
def _semantic_issue(
code: str,
message: str,
path: str,
line: int | None = None,
*,
severity: ValidationSeverity = "error",
) -> ValidationIssue:
return ValidationIssue(
layer="semantic",
code=code,
message=message,
severity=severity,
path=path,
line=line,
)