44 Commits
Author SHA1 Message Date
huojiarong 408b4ecb22 修复机械事件力曲线并对齐气动孔口端口显示 2026-08-20 05:59:04 +00:00
lujingze e18399c022 整合求解器活动监控与步长回归证据
同步远端 PNL0003 诊断和大采样网格能力,语义合并活动感知的 60 秒真停滞判定与旧后端 15 分钟兼容兜底。

纳管热路径优化、15 单元运行证据、浏览器与 API 报告,并补充北京时间更新日志和遗留问题。
2026-08-19 16:24:31 +00:00
huojiarong c19cf77aee docs: record August 19 updates 2026-08-19 11:35:59 +00:00
huojiarong eb6ea70e19 feat: extend stall timeout and remove sample cap 2026-08-19 11:34:31 +00:00
huojiarong 27f9f4add8 fix: align PNL0003 Reynolds diagnostic with AMESim 2026-08-19 10:40:57 +00:00
lujingze 60b743dd81 补记当前快照存档与推送结果
按北京时间记录 a8c7338 的远端存档、验证结果及仍待完成的 production 复跑和托管 workflow 检查。
2026-08-18 15:22:27 +00:00
lujingze a8c733883c 存档求解器回归基线与当前改动
纳管 AMESim 对齐基线、发布锁、回归测试及当前物理门禁调整。

更新日志仅记录已完成成果,并注明当前 HEAD 尚待真实 production 复跑与远端 workflow 验证。
2026-08-18 15:20:42 +00:00
huojiarong 53f8601fec 修正动态管与阀门诊断输出 2026-08-18 10:24:29 +00:00
huojiarong f725f038b6 完善 PNL00R 摩擦模型与八路回归 2026-08-18 09:30:29 +00:00
lujingze 684d28752a 更新八路仿真最新版本文件test-mql-8 2026-08-18 06:44:24 +00:00
lujingze b435daecf2 完善通用求解器回归与前端交互
- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
2026-08-18 06:42:07 +00:00
huojiarong 143e8dd309 修复测试资源路径 2026-08-18 00:57:32 +00:00
huojiarong 0fa166c8e5 Fix pneumatic node zero-flow reversal 2026-08-17 09:24:35 +00:00
lujingze 16a7eb2d6c 完成求解器雅可比矩阵首轮优化,增加更新目录,整理了文档文件夹,增加了服务启动脚本 2026-08-17 07:33:31 +00:00
Codex 6bb0591d32 fix: align PNL0001 symbol causality 2026-08-16 11:37:02 +00:00
Codex cca9d1e883 fix: seed PNVO pipe series pressure 2026-08-16 11:34:35 +00:00
Codex 22b35b2945 perf: retain exact flow caches and compiled targets 2026-08-16 11:21:44 +00:00
Codex f09dfcf542 fix: bound reported pipe friction diagnostics 2026-08-16 11:03:21 +00:00
ljz 5332a788f3 优化仿真求解性能并修复流量闭合问题(初版) 2026-08-16 17:46:05 +08:00
ljz 57b459bc72 增加可选性能埋点并完成物性效率评估 2026-08-16 17:46:04 +08:00
huojiarong 4e0b9fd8cc fix: align mechanical dynamic port validation 2026-08-15 12:57:03 +00:00
huojiarong 6a064892e2 优化压力流量求解并达到四路性能门槛 2026-08-15 11:45:22 +00:00
ljz 6572defaa4 完善建模交互、组件图标与系统协议 2026-08-15 17:40:18 +08:00
huojiarong 456c29b3b6 验收四路模型并优化拓扑求解性能 2026-08-12 11:57:42 +00:00
huojiarong caca32a513 校准第二支路热流体能量与管路摩擦 2026-08-11 12:13:09 +00:00
huojiarong 0f73d5b568 对齐PNL0002上游温度并稳定热流体闭合 2026-08-11 07:12:52 +00:00
huojiarong 6abcc220de 对齐AMESim管阻孔口与储气耦合 2026-08-10 13:07:03 +00:00
ljz 7671418582 统一组件图标布局并完善选择吸附交互 2026-08-06 23:35:50 +08:00
huojiarong 40c72422ff 对齐PNVO近等压层流平滑 2026-08-05 12:59:05 +00:00
huojiarong e9fc855a1c 修复参数帮助浮层滚动触发 2026-08-05 12:58:08 +00:00
ljz 09778972a6 完善端口标号与MECMAS21动态图标 2026-08-04 12:55:50 +08:00
ljz df1d676131 完善AMESim可配置参数交互
补充气动孔口、动态管路、UD00 与弹性接触组件的离散选项、条件显示和折叠分组。

增加目录协议、非法选项和前端参数表交互回归测试,不修改现有求解公式。
2026-08-04 01:27:17 +08:00
ljz be54070855 完善MECMAS21参数配置与分组交互 2026-08-03 23:51:27 +08:00
ljz 32f21f08ff 优化参数表样式与科学计数法支持 2026-08-03 23:51:27 +08:00
huojiarong 046aa49814 对齐Amesim氦气PR物性与PNVO流量 2026-08-03 15:34:33 +00:00
huojiarong 18d9802f03 优化求解器重试并校正AMESim机械端口 2026-08-03 09:54:46 +00:00
ljz 971e8f2336 初版:实现 AMESim 机械因果化与事件求解
初步支持 MECMAS21 刚性质量状态归并、端止事件、恢复系数,以及 LSTP 接触和压力流量显式因果化。

已知问题:显式传播仍会重复扫描全网方程,长时刚性仿真性能待优化;自适应积分器遇到越出物理域的试探状态时,尚未实现恢复并缩步重试。
2026-08-03 15:45:48 +08:00
huojiarong de265cdde6 调整Playwright默认浏览器策略 2026-08-03 07:03:13 +00:00
ljz cf249d6c0d 优化组件选择交互与视图适配时机 2026-08-03 11:59:24 +08:00
ljz 11093613c5 完善 PNVO001 与 PNRP17 前端图标及端口交互
按 AMESim 参考样式重绘孔板与气动活塞图标,调整端口锚点和旋转后的连接方向,并补充组件图标及接触连接回归测试。
2026-08-03 09:49:55 +08:00
huojiarong 1f4027573f 公开PNRP17并接通实时气动机械耦合 2026-08-02 14:51:31 +00:00
ljz 503394f3ed 完善建模交互与结果曲线功能
支持元件接口接触吸附、旋转后兼容连接及无连线逻辑边。

完善 AMESim 图标显示、参数双栏、结果曲线待定窗口及相关端到端测试。
2026-08-02 17:31:28 +08:00
ljz 410ef535e8 完善AMESim组件界面与仿真求解稳定性 2026-08-02 00:57:48 +08:00
ljz e7177ab03e feat: integrate AMESim media models and editor UI 2026-07-31 23:36:58 +08:00
376 changed files with 459284 additions and 28716 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
*.bat text eol=crlf
*.cmd text eol=crlf
*.sh text eol=lf
+180
View File
@@ -0,0 +1,180 @@
name: Solver regression
on:
push:
paths:
- "app/simulation/**"
- "tests/**"
- "requirements.txt"
- "constraints/**"
- ".python-version"
- "README.md"
- ".github/workflows/solver-regression.yml"
pull_request:
paths:
- "app/simulation/**"
- "tests/**"
- "requirements.txt"
- "constraints/**"
- ".python-version"
- "README.md"
- ".github/workflows/solver-regression.yml"
schedule:
- cron: "17 3 * * 1-6"
- cron: "17 3 * * 0"
workflow_dispatch:
inputs:
suite:
description: Regression tier
required: true
default: quick
type: choice
options:
- quick
- historical
- main-long
case:
description: Longest main-model horizon (predecessors run first)
required: true
default: 0.2s
type: choice
options:
- 0.2s
- 1s
- 5s
- 10s
lane:
description: Output sampling lane
required: true
default: production
type: choice
options:
- solver-only
- production
concurrency:
group: solver-regression-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
quick:
if: >-
github.event_name == 'push' ||
github.event_name == 'pull_request' ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
constraints/python312-linux-x86_64.lock
- name: Install hashed Linux release lock
run: |
python -m pip install \
--force-reinstall \
-r constraints/python312-linux-x86_64.lock
python -m pip check
- name: Run solver foundation tests
env:
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
run: |
python -W error::ResourceWarning -m unittest \
tests.test_dependency_constraints \
tests.test_benchmark_regression \
tests.test_physical_state_v21 \
tests.test_test_mql_ame_contract \
tests.test_test_mql_8_regression \
tests.test_mql_full_branches_regression \
tests.test_pressure_flow_causal_execution \
tests.test_stream_pressure_block_solver \
tests.test_core_solver \
tests.test_supported_piston_tangent \
tests.test_three_piston_tangent \
tests.test_sparse_secant_jacobian \
tests.test_generic_jacobian_sparsity \
tests.test_generic_system_xml_simulation
historical-nightly:
if: >-
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 1-6') ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'historical')
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
constraints/python312-linux-x86_64.lock
- name: Install hashed Linux release lock
run: |
python -m pip install \
--force-reinstall \
-r constraints/python312-linux-x86_64.lock
python -m pip check
- name: Run 0.81 and 2.10 second historical regression
run: |
mkdir -p artifacts
python -m app.simulation.benchmark_regression \
--manifest tests/baselines/simulation/test_mql_full_branches/manifest.json \
--lane production \
--output artifacts/test-mql-full-branches.json
- if: always()
uses: actions/upload-artifact@v4
with:
name: historical-solver-regression
path: artifacts/*.json
if-no-files-found: warn
main-periodic:
if: >-
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 0') ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'main-long')
runs-on: ubuntu-24.04
timeout-minutes: 180
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
constraints/python312-linux-x86_64.lock
- name: Install hashed Linux release lock
run: |
python -m pip install \
--force-reinstall \
-r constraints/python312-linux-x86_64.lock
python -m pip check
- name: Run bounded progressive main-model regression
env:
REQUESTED_CASE: ${{ github.event_name == 'workflow_dispatch' && inputs.case || '10s' }}
REQUESTED_LANE: ${{ github.event_name == 'workflow_dispatch' && inputs.lane || 'production' }}
run: |
mkdir -p artifacts
python -m app.simulation.benchmark_regression \
--manifest tests/baselines/simulation/test_mql_8/manifest.json \
--lane "$REQUESTED_LANE" \
--case "$REQUESTED_CASE" \
--output artifacts/test-mql-8-progressive.json
- if: always()
uses: actions/upload-artifact@v4
with:
name: main-model-progressive-regression
path: artifacts/*.json
if-no-files-found: warn
+4
View File
@@ -12,6 +12,10 @@ htmlcov/
# Local virtual environments
.venv/
.venv-win/
# Local Linux toolchain (downloaded for the startup scripts)
.tools/node-*-linux-x64/
app/data/
frontend/node_modules/
frontend/dist/
+1
View File
@@ -0,0 +1 @@
3.12.3
@@ -1,680 +0,0 @@
# 组件模型建模规范 v1
状态:已在 `experimental` 临时组件库实施
适用对象:人工开发者、代码生成工具和 AI 编程助手
配套读取规范:[组件库分类、发现与读取规范 v1](component-library-spec-v1.md)
## 1. 文档目标
本文档规定一个 Python 仿真元件应如何创建、修改、测试和注册。完成后的模型必须
同时满足四个使用方:
1. 求解器能够实例化模型并调用方程。
2. System XML 能够根据稳定类型找到模型。
3. React Flow 能够自动显示图标、端口和参数。
4. 结果页面能够根据结构化元数据展示变量。
本文档是模型代码的开发合同。若本文档与当前代码行为不一致,应把它视为缺陷:
先核对实际实现,再在同一次修改中同步代码、测试和文档,禁止让两套规则长期并存。
## 2. 开始前先判断任务类型
### 2.1 新增公开模型
公开模型会出现在前端组件库中,也能被 System XML 创建。必须:
- 放入某个组件库的分类目录。
- 实现完整模型契约。
- 加入该库 `library.py` 的 `models` 清单。
- 添加目录、契约、方程和最小仿真测试。
### 2.2 修改已有公开模型
必须先判断改动是否破坏已有工程:
| 改动 | 版本建议 | 兼容性要求 |
| --- | --- | --- |
| 修复数值实现但不改变契约 | 修订版本 | 旧 XML 和工程继续可用 |
| 新增有默认值的参数或结果 | 次版本 | 旧工程缺少该字段时必须有迁移或默认值 |
| 修改界面名称或图标 | 库修订版本 | 不修改机器标识 |
| 修改方程的物理语义 | 根据影响提高次版本或主版本 | 补充基准和变更说明 |
| 删除、改名端口或参数 | 主版本 | 必须设计工程和 XML 迁移 |
| 修改 `MODEL_TYPE` | 视为新模型 | 旧类型必须保留迁移映射 |
### 2.3 新增内部模型
仅供固定算例或研究代码使用、不进入前端目录的模型,不加入 `library.py`。这类模型
应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。
当前示例是
[`app/simulation/examples/testmodel/dynamic_pipe.py`](../app/simulation/examples/testmodel/dynamic_pipe.py)。
### 2.4 新增物理域
仅新增模型类不足以支持新物理域。除了模型,还必须设计:
- `PortDefinition` 和端口变量。
- 变量角色与连接规则。
- 网络兼容性检查。
- 代数方程和 stream/signal 传播。
- XML 端口协议。
- 前端连线兼容规则。
- 最小闭合系统与求解测试。
没有完成这些基础能力时,不得仅通过修改 `domain` 字符串宣称支持新物理域。
## 3. 开发前必须读取的文件
人工或 AI 在修改模型前,应按顺序读取:
1. 本文档。
2. 目标库的 `library.py`。
3. 同分类中物理行为最接近的现有模型。
4. [`core/base.py`](../app/simulation/core/base.py)。
5. [`core/ports.py`](../app/simulation/core/ports.py)。
6. [`core/metadata.py`](../app/simulation/core/metadata.py)。
7. [`core/catalog.py`](../app/simulation/core/catalog.py)。
8. [`registry.py`](../app/simulation/registry.py) 中的启动校验。
9. 与目标模型最接近的测试。
不要只根据文件名、前端图标或旧 XML 猜测模型语义。
## 4. 文件位置和命名
公开模型放在:
```text
app/simulation/components/<library_id>/<category_id>/<model_module>.py
```
例如:
```text
app/simulation/components/experimental/storage/cylinder.py
app/simulation/components/experimental/flow/orifice.py
app/simulation/components/experimental/junctions/tee.py
```
规则:
- 一个公开模型原则上对应一个文件和一个主要模型类。
- 模块名、`MODEL_TYPE`、端口名和参数名使用稳定机器标识。
- `MODEL_TYPE` 使用小写 `snake_case`。
- 参数和结果变量允许保留已有热力学惯例,如 `T0`、`T`、`U`。
- 中文名称只写入 `label`,不能代替机器标识。
- 求解器、介质和网络通用逻辑不得复制到模型文件。
## 5. 公开模型完整契约
每个公开模型类必须在自身类体中显式声明:
```python
MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
DISPLAY = ...
```
同时必须实现:
```python
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Component:
...
```
注册器要求这些字段直接存在于公开模型类中。不要依赖父类隐式提供
`MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、`RESULT_VARIABLES`、
`DISPLAY` 或 `create()`。
## 6. 基类选择
### 6.1 `AlgebraicComponent`
适用于没有积分状态、由当前端口变量和参数直接决定残差的元件,例如:
- 孔板
- 阀门
- 阻性管段
- 理想三通
至少实现:
- 构造函数和端口注册。
- `create()`。
- `pressure_flow_equation_residuals()`。
- 需要传递 stream 变量时实现 `update_stream_outflows()`。
### 6.2 `ThermodynamicVolumeComponent`
适用于包含质量和能量状态的气体容腔,例如:
- 气瓶
- 贮箱
- 有容积的管段
至少实现:
- `get_state_vector()`。
- `set_state_vector()`。
- `refresh_thermodynamic_ports()`。
- `state_derivative_from_ports()`。
- `pressure_flow_equation_residuals()`。
该基类已经提供标准热力学组件结果:
```text
m, U, p, T, rho, u, h
```
除非物理含义不同,不要重新复制这组结果声明。
### 6.3 其他基类
如果现有基类不能表达模型,应先评估是否缺少一种通用组件能力。不要为了一个模型
直接把专用判断塞入 `SimulationNetwork` 或求解器。
## 7. 端口建模规范
当前气动模型使用:
```python
PortDefinition.pneumatic(
"port_a",
nominal_role="bidirectional",
)
```
气动端口包含:
| 变量 | 角色 | 连接规则 | SI 单位 |
| --- | --- | --- | --- |
| `p` | `effort` | `equal` | `Pa` |
| `m_flow` | `flow` | `sumToZero` | `kg/s` |
| `h_outflow` | `stream` | `streamMix` | `J/kg` |
必须遵守:
- `m_flow > 0` 表示质量流入当前组件。
- `nominal_role` 只用于界面和默认布局,不限制实际流向。
- 物理连接是非因果的,连接线端点顺序不代表流向。
- 所有声明端口必须使用 `register_declared_port()` 创建。
- `DISPLAY.ports` 必须与 `PORTS` 名称集合完全一致。
- 分支连接使用三通等连接元件,不能让一个物理端口直接连接多条边。
禁止:
- 在模型内部根据画布左右方向判断流向。
- 为了前端显示另造一套端口名。
- 把 `port_a` 固定解释为真实入口、把 `port_b` 固定解释为真实出口。
- 直接绕过端口状态读写其他组件对象。
## 8. 参数建模规范
所有用户可配置输入必须使用 `ParameterDefinition`:
```python
ParameterDefinition(
name="volume",
label="容积",
quantity="volume",
unit="m3",
default=0.1,
minimum=0.0,
minimum_exclusive=True,
)
```
字段含义:
| 字段 | 规则 |
| --- | --- |
| `name` | 稳定机器名,同时用于 XML、工程文件和 `create()` |
| `label` | 前端显示名称,不能为空 |
| `quantity` | 受控物理量标识 |
| `unit` | 后端 SI 基准单位 |
| `default` | 必须能够创建有效模型 |
| `minimum` / `maximum` | 必须反映方程有效范围 |
| `minimum_exclusive` | 用于直径、容积等严格大于零的量 |
当前受控单位定义在 `SI_UNIT_BY_QUANTITY`:
| quantity | SI 单位 |
| --- | --- |
| `dimensionless` | 空字符串 |
| `density` | `kg/m³` |
| `flow_coefficient` | `kg/(s*Pa^0.5)` |
| `internal_energy` | `J` |
| `length` | `m` |
| `mass` | `kg` |
| `mass_flow` | `kg/s` |
| `pressure` | `Pa` |
| `specific_enthalpy` | `J/kg` |
| `specific_internal_energy` | `J/kg` |
| `temperature` | `K` |
| `volume` | `m3` |
新增物理量时必须先扩展后端受控单位表,再评估前端是否需要单位换算选项。禁止在
单个模型中私自拼写新的同义 `quantity`。
构造函数必须调用:
```python
self.set_parameter_values(
{
"volume": volume,
"p0": p0,
"T0": T0,
}
)
```
保存值、方程计算和结果输出都使用 SI。前端显示单位变化不能改变后端参数语义。
## 9. 结果变量规范
### 9.1 组件级结果
组件自身状态或派生量使用 `ResultVariableDefinition`:
```python
ResultVariableDefinition(
name="pressure_drop",
label="压降",
quantity="pressure",
unit="Pa",
category="derived",
order=10,
)
```
声明后必须在 `component_result_values()` 返回同名值:
```python
def component_result_values(self) -> Mapping[str, float]:
return {
"pressure_drop": self.port_a.p - self.port_b.p,
}
```
声明集合和返回键必须一致。
### 9.2 端口结果
端口结果由 `PORTS` 的端口变量自动产生,不要在 `RESULT_VARIABLES` 中重复声明
`port_a.p`、`port_a.m_flow` 等字段。
### 9.3 禁止暴露的内容
以下内容默认不能作为用户结果:
- 非线性求解器内部未知量索引。
- 缩放残差和迭代缓存。
- 仅用于调试的临时中间值。
- 可以由已有结果稳定推导、但没有明确工程用途的重复字段。
## 10. 显示声明规范
公开模型必须声明 `DISPLAY`:
```python
DISPLAY = ComponentDisplaySpec(
label="示例阻力元件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(
PortDisplaySpec("port_a", "left", order=10),
PortDisplaySpec("port_b", "right", order=20),
),
order=90,
)
```
规则:
- `library_id` 必须等于所属库 ID。
- `category_id` 必须存在于所属库的 `categories`。
- `symbol` 是前端图形键,不是模型类型。
- 未实现专用图标时使用新的稳定键,前端会回退到通用图形。
- 只有确实需要专用工程图标时才修改前端图标渲染器。
- `side` 只允许 `left` 或 `right`。
- 旋转和镜像不能改变端口名或物理语义。
## 11. 标准创建入口
`create()` 是注册器创建模型的唯一入口:
```python
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> ExampleComponent:
return cls(
name=name,
medium=medium,
coefficient=parameters["coefficient"],
)
```
注册器会在调用前:
1. 补齐默认参数。
2. 拒绝未知参数。
3. 检查有限值和边界。
调用后还会检查:
1. 返回对象类型正确。
2. 实例 `model_type` 与 `MODEL_TYPE` 一致。
3. 实际端口与 `PORTS` 完全一致。
4. 实例保存的参数与规范化参数完全一致。
`create()` 不应重复实现参数默认值和边界校验,也不能静默修改传入参数。
## 12. 方程实现要求
模型方程必须满足:
- 残差形式统一为“期望等式左侧减右侧”。
- 每条 `EquationResidual` 使用稳定、可定位的 `id`。
- `variables` 列出该残差实际涉及的端口量或状态。
- `role` 与方程主要约束的物理角色一致。
- 对零压差、零流量和反向流动给出有限结果。
- 必要正则化必须有物理解释,并通过边界测试保护。
- 不得用画布坐标、连接线方向或组件名称决定方程。
动态模型还必须:
- 状态向量长度稳定。
- `get_state_vector()` 和 `set_state_vector()` 互为逆操作。
- 状态导数满足质量和能量守恒约定。
- 初始化默认值能够产生有限介质状态。
## 13. 可复制的代数模型模板
下面是一个符合当前规范的两端口代数阻力模板。复制后必须根据真实物理模型修改
类型、参数、方程、名称和测试,不能只改类名就注册。
```python
from __future__ import annotations
from collections.abc import Mapping
from math import sqrt
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class ExampleRestriction(AlgebraicComponent):
MODEL_TYPE = "example_restriction"
MODEL_VERSION = "1.0.0"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_b", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
name="K",
label="流量系数",
quantity="flow_coefficient",
unit="kg/(s*Pa^0.5)",
default=1e-5,
minimum=0.0,
),
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="示例阻力元件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(
PortDisplaySpec("port_a", "left", order=10),
PortDisplaySpec("port_b", "right", order=20),
),
order=90,
)
def __init__(self, name: str, K: float = 1e-5) -> None:
super().__init__(name)
self.set_parameter_values({"K": K})
self.K = K
self.port_a = self.register_declared_port("port_a")
self.port_b = self.register_declared_port("port_b")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> ExampleRestriction:
return cls(name=name, K=parameters["K"])
def pressure_flow_equation_residuals(
self,
) -> tuple[EquationResidual, ...]:
pressure_difference = self.port_a.p - self.port_b.p
expected_flow = (
self.K
* sqrt(abs(pressure_difference))
* (1.0 if pressure_difference > 0.0 else -1.0)
if pressure_difference != 0.0
else 0.0
)
return (
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_a.m_flow",
f"{self.name}.port_b.m_flow",
),
role="flow",
value=self.port_a.m_flow + self.port_b.m_flow,
),
EquationResidual(
id=f"{self.name}:pressure_flow_relation",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_a.p",
f"{self.name}.port_b.p",
f"{self.name}.port_a.m_flow",
),
role="flow",
value=self.port_a.m_flow - expected_flow,
),
)
def update_stream_outflows(
self,
connected_h: Mapping[str, float],
) -> None:
self.port_a.h_outflow = connected_h["port_b"]
self.port_b.h_outflow = connected_h["port_a"]
```
真实现有模型可参考:
- 储能元件:
[`cylinder.py`](../app/simulation/components/experimental/storage/cylinder.py)
- 阻性元件:
[`orifice.py`](../app/simulation/components/experimental/flow/orifice.py)
- 多端口连接元件:
[`tee.py`](../app/simulation/components/experimental/junctions/tee.py)
## 14. 注册模型
模型文件完成后,只修改所属库的 `library.py`:
```python
models=(
# 已有模型
"app.simulation.components.experimental.flow.example_restriction:ExampleRestriction",
)
```
禁止:
- 直接修改 `COMPONENT_MODEL_REGISTRY`。
- 在前端复制参数和端口定义作为正式来源。
- 递归扫描组件目录自动导入所有 `.py`。
- 同时注册两个相同 `MODEL_TYPE`。
- 把测试类、抽象基类或内部算例模型加入公开清单。
## 15. 测试要求
每个公开模型至少添加:
1. 静态契约测试。
2. 默认参数创建测试。
3. 参数边界测试。
4. 端口与显示布局一致性测试。
5. 关键方程残差测试。
6. 零流量或反向流动测试。
7. 目录输出测试。
8. 最小 XML 编译测试。
9. 能进入通用求解器的模型,再添加短时仿真测试。
推荐先运行:
```powershell
.\.venv-win\Scripts\python.exe -m unittest `
tests.test_component_registry `
tests.test_component_catalog `
tests.test_component_metadata
```
然后运行完整回归:
```powershell
.\.venv-win\Scripts\python.exe -m unittest discover -s tests
```
目录契约影响前端时还要运行:
```powershell
cd frontend
$env:Path = 'F:\Master\SystemSimulationApp\.tools\node-v24.18.0-win-x64;' + $env:Path
npm.cmd run build
```
## 16. 修改已有模型的安全步骤
1. 找到 `MODEL_TYPE` 的所有 XML、工程和测试引用。
2. 记录修改前的端口、参数、结果和默认行为。
3. 判断版本级别和是否需要迁移。
4. 先增加或修改测试,明确预期物理行为。
5. 修改模型类,不在注册器和前端复制规则。
6. 检查默认实例和旧参数是否仍能创建。
7. 检查最小系统是否仍然闭合。
8. 运行针对性测试和完整回归。
9. 同步本文档或模型专属说明中的物理假设。
## 17. 人工或 AI 的任务输入卡
为了减少猜测,新增模型前建议先填写:
```text
模型中文名称:
MODEL_TYPE:
所属 library_id:
所属 category_id:
物理域:
模型用途和边界:
端口列表及含义:
参数列表、SI 单位、默认值和范围:
状态变量:
代数方程或微分方程:
正流量约定:
需要显示的组件结果:
已知参考模型或工程公式:
最小测试系统:
允许的近似:
明确不实现的能力:
```
如果关键物理信息缺失,AI 应先通过现有模型、测试或用户提供的参考补齐;不能仅凭
组件名称自行创造方程。
## 18. AI 修改协议
AI 创建或修改模型时必须遵守:
### 修改前
1. 读取第 3 节列出的文件。
2. 检查工作区已有改动,不能覆盖无关修改。
3. 明确模型是公开模型还是内部模型。
4. 明确端口物理域、状态、参数、方程和结果。
5. 找到最接近的现有模型并沿用代码风格。
### 修改中
1. 将物理契约保存在模型类中。
2. 只在库清单中登记公开模型。
3. 不修改集中注册表来加入单个模型。
4. 不为了让测试通过而放宽全局校验。
5. 不改变现有模型标识,除非任务明确要求迁移。
6. 不把前端拖拽方向当作物理流向。
7. 不把求解器失败简单隐藏为默认结果。
### 修改后
1. 展示涉及的模型、清单和测试文件。
2. 报告版本变化和兼容性影响。
3. 运行针对性测试、完整后端测试和必要的前端构建。
4. 检查 `GET /api/components/catalog` 中的模型、分类、端口和参数。
5. 告知用户需要重启 FastAPI 才能加载新的 Python 模块。
6. 未执行的校验必须明确说明原因。
## 19. 常见失败与处理
| 现象 | 常见原因 | 处理 |
| --- | --- | --- |
| FastAPI 启动时报模型缺少声明 | 字段继承自父类或漏写 | 在公开模型类中显式声明 |
| 模型未出现在前端 | 未加入 `library.py` 或后端未重启 | 检查清单并重启 FastAPI |
| 前端显示“内置兜底” | `/api/components/catalog` 不可用 | 检查 8000 端口和接口响应 |
| 显示端口校验失败 | `DISPLAY.ports` 与 `PORTS` 不一致 | 使用相同端口名和完整集合 |
| 单位校验失败 | `quantity` 与 SI 单位不匹配 | 使用受控单位表或先扩展规范 |
| 默认模型无法注册 | 默认参数越界或构造函数未保存参数 | 修复默认值和 `set_parameter_values()` |
| XML 报不支持模型 | XML `type` 与 `MODEL_TYPE` 不一致 | 修正类型或提供迁移 |
| 模型可显示但无法仿真 | 只完成目录元数据,方程或物理域求解未实现 | 补齐方程、网络和求解测试 |
## 20. 完成定义
一个模型只有同时满足以下条件才算完成:
- 模型契约完整且启动校验通过。
- 默认参数和边界有效。
- 端口、参数和结果具有稳定物理含义。
- 方程覆盖零流量、正常流动和必要的反向流动。
- 模型已加入正确库清单。
- 目录接口能自动输出模型。
- 前端无需复制参数和端口定义即可使用。
- XML 能映射到正确模型。
- 最小系统能够编译;声称可仿真的模型必须产生有限结果。
- 针对性测试、完整回归和必要的前端构建通过。
- 文档记录了模型假设、适用范围和已知限制。
+16 -16
View File
@@ -1,6 +1,6 @@
# test_mql
本目录记录 AMESim 模型 `test_mql.ame` 向 `PythonModels` 迁移时使用的源模型信息、结果对齐约定和当前进度。
本目录记录 AMESim 模型 `test_mql.ame` 向 `app.simulation` 迁移时使用的源模型信息、结果对齐约定和当前进度。
## 目标
@@ -13,9 +13,9 @@ Python 输出只有通过 AMESim baseline 对比后才能作为数值一致性
## 模型与源数据
- AMESim 源模型:`AmesimModels/test_mql.ame`
- Python 系统类:`PythonModels.systems.test_mql.TestMqlSystem`
- 结构运行入口:`PythonModels/scripts/run_test_mql.py`
- 132 状态比较入口:`PythonModels/scripts/run_test_mql_full_state_comparison.py`
- Python 系统类:`app.simulation.examples.test_mql.system.TestMqlSystem`
- 结构运行入口:`app.simulation.examples.test_mql.run`
- 132 状态比较入口:`app.simulation.examples.test_mql.run_full_state_comparison`
- AMESim 组件数:117
- LINE 连接数:84
- 连续状态数:132
@@ -47,16 +47,16 @@ Python 输出只有通过 AMESim baseline 对比后才能作为数值一致性
主要实现位置:
- `PythonModels/systems/test_mql.py`
- `PythonModels/systems/test_mql_closure.py`
- `PythonModels/systems/test_mql_pneumatic.py`
- `PythonModels/systems/test_mql_mechanical.py`
- `PythonModels/systems/test_mql_lines.py`
- `PythonModels/components/amesim_pneumatic.py`
- `PythonModels/components/amesim_mechanical.py`
- `PythonModels/reporting/amesim_results.py`
- `PythonModels/reporting/test_mql_comparison.py`
- `PythonModels/scripts/run_test_mql_full_state_comparison.py`
- `app/simulation/examples/test_mql/system.py`
- `app/simulation/examples/test_mql/closure.py`
- `app/simulation/examples/test_mql/pneumatic.py`
- `app/simulation/examples/test_mql/mechanical.py`
- `app/simulation/examples/test_mql/lines.py`
- `app/simulation/examples/test_mql/primitives/`
- `app/simulation/examples/test_mql/structural_network.py`
- `app/simulation/reporting/amesim_results.py`
- `app/simulation/reporting/test_mql_comparison.py`
- `app/simulation/examples/test_mql/run_full_state_comparison.py`
## 当前对比结果
@@ -109,13 +109,13 @@ Python 输出只有通过 AMESim baseline 对比后才能作为数值一致性
运行默认短时域 comparison:
```bash
python3 -m PythonModels.scripts.run_test_mql_full_state_comparison
python3 -m app.simulation.examples.test_mql.run_full_state_comparison
```
运行 PNVO 事件边界诊断:
```bash
python3 -m PythonModels.scripts.run_test_mql_full_state_comparison --pnvo-event-boundary
python3 -m app.simulation.examples.test_mql.run_full_state_comparison --pnvo-event-boundary
```
运行相关测试:
-2
View File
@@ -1,2 +0,0 @@
__pycache__/
*.pyc
-371
View File
@@ -1,371 +0,0 @@
# PythonModels
`PythonModels` 用于承接 Modelica 和 AMESim 模型的 Python 平台移植。
目标不是逐行翻译源模型,而是建立可运行、可测试、可导出,并能与 OpenModelica 或 AMESim baseline 对比的 Python 仿真框架。
当前包含两条模型线:`Testmodel` 已有可运行的 ODE 近似和 OpenModelica 对比能力;`test_mql` 已形成 132 状态气动机械总闭包,正在按 AMESim baseline 做数值校准。
## 当前目录
- `core/`: 通用基础设施
包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。
- `components/`: 元件级 Python 实现
包含 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee`,以及 AMESim 气动和机械组件原语。
- `systems/`: 系统级装配与闭合
包含旧 `TestModelSystem`,以及当前主线 `TestMqlSystem` 的配置、拓扑、closure、snapshot、端口写回和 RHS。
- `reporting/`: 结果导出与对比
承接 OpenModelica 对比,以及 AMESim 结果读取、`Data_Path` schema validation、comparison 和诊断报告。
- `scripts/`: 运行脚本
包含 `run_testmodel.py`、`run_test_mql.py` 和 `run_test_mql_full_state_comparison.py`。
- `baselines/`: 提交进仓库的稳定基线
当前承接 Python 主变量基线和 Python 对 Modelica 的误差摘要基线。
- `runs/`: 每次实际运行的默认输出目录
当前脚本默认会在这里创建带时间戳的子目录,用来放这次运行生成的产物。
当前关键文件:
- `core/medium.py`: 温度相关的理想气体近似介质 `IdealGasMedium`
- `core/peng_robinson.py`: `test_mql` 使用的氦气 Peng-Robinson 物性
- `core/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
- `core/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
- `components/pipe.py`: 单阻容管道近似,入口压降 + 出口直连内容腔
- `components/tee.py`: 三通的最小 stream 混合 helper
- `systems/testmodel.py`: `Testmodel` 的系统装配壳与外部运行入口
- `systems/testmodel_closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
- `systems/test_mql.py`: `test_mql` 系统装配、132 状态总闭包和关键输出映射
- `systems/test_mql_closure.py`: `test_mql` 气动网络 closure、snapshot、流量计算和端口写回
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
- `scripts/run_test_mql_full_state_comparison.py`: `test_mql` 短时域 AMESim comparison 和诊断入口
- `scripts/run_testmodel.py`: 基线运行与程序化执行入口
- `tests/test_pythonmodels_regression.py`: 当前 Python 基线回归测试
## 当前阶段进度
这一阶段原先有 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` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `systems/testmodel.py` 拆到新的 `systems/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` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
当前没有实现:
- 通用 DAE 初始化器
- `Modelica.Media.Air.SimpleAir` 的严格复刻
- 面向任意拓扑的通用 connector/stream 求解器
## 当前怎么运行
最小运行方式:
```bash
python3 -m PythonModels.scripts.run_testmodel
```
如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如:
```python
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.scripts.run_testmodel import (
TestModelRunConfig,
TestModelSamplingConfig,
run_testmodel,
)
from PythonModels.systems.testmodel import (
BranchConfig,
CylinderConfig,
OrificeConfig,
PipeConfig,
TankConfig,
TestModelConfig,
)
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 PythonModels.scripts.run_testmodel 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. 将结果写入 `PythonModels/runs/` 下本次运行专属的时间戳目录
5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果
当前脚本默认不会再把运行结果直接写到提交基线目录,而是会在 `PythonModels/runs/` 下创建一个带时间戳的子目录,例如:
- `PythonModels/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](baselines/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 结果。
## AMESim test_mql 当前进度
`test_mql` 是从 `AmesimModels/test_mql.ame` 新增迁移的 AMESim 模型,当前只在独立路径下推进,不修改旧 `testmodel`。新增命名保持 AMESim 原始别名和 `Data_Path`,方便后续逐变量对齐。
当前已经完成:
- 解析 117 个组件、84 条 LINE 连接、直接组件接触、全局参数、仿真设置以及 AMESim 变量目录。
- 直接读取 `.ame` 包内 `test_mql_.var` 和 `test_mql_.results`;baseline 包含 1002 个时间点和 1116 个保存变量。
- 使用氦气 Peng-Robinson 物性,内部统一使用绝对压力,对外按 AMESim 表压和原始单位输出。
- 实现 `PNCH023 / PNCH012 / PNOR001 / PNVO001`,以及 `PNL0001 / PNL0002 / PNL0003 / PNL00R` 管路和 `PN3NODE2 / P4NODE2` 节点语义。
- 完成气动真实拓扑装配、canonical flow、端口写回、snapshot 和 112 状态气动 RHS。
- 实现 `PNRP17 / MECMAS21 / LSTP00A / LMECHN1 / UD00 / FORC` 当前工况可确认的机械行为,并形成 20 状态机械闭包。
- 将气动和机械部分组合成 132 状态总闭包,接入活塞体积反馈、气动力、外力、端止动和质量约束,可通过现有 solver 短时积分。
- 建立关键 `Data_Path` 序列导出、output schema、validation、AMESim 插值比较、误差排序、端点诊断和 PNCH012 RHS 项拆解。
当前确认的关键细节:
- `PNRP17` 活塞腔体积使用环形有效面积 `piston_area - rod_area`。
- `LSTP00A` 的 `gap` 观测单位是 mm,计算接触力前必须转换为 m。
- `PNCH023` 固定气室初始压力来自 `P0=153 bar` 的绝对压力;AMESim `press` 输出为相对 `101300 Pa` 的表压。
- `PNCH012` 变容腔初始压力对齐 AMESim 的 `1 bar` 绝对压力,`vol` 输出单位为 cm3,且末端体积等于基础死容积加对应活塞 `vol1`。
- `MECMAS21` 的 `x1dup / v1dup / acc1dup` 是第二机械端口观测,相对 `x1 / v1 / acc1` 为反号,不是重复同值。
- 本算例中 `MECMAS21` 的 `Fmin / Fmax / Fvisc / Ffric` 在 AMESim 结果里为零;当前只把这一工况能验证的部分写入测试,没有硬猜未激活碰撞/摩擦状态机。
当前默认 `0 -> 1e-5 s` comparison 已定位最大偏差为 `press@pn_c1_8`:初值对齐,但末值绝对误差约 `9.22849 Pa`。RHS 拆解显示边界体积功约 `0.026 W`,端口焓流约 `32722 W`,因此当前首要工作是比较 Python 的 `p4_port3_remote_chamber_to_line_flow` 与 AMESim 的 `dm1@pneumatic_69`,检查单位、符号、PNL0001 阻力和 `pnnode4_16` 节点平衡。
当前还不能宣称 `test_mql` 的 Python 时域仿真已经和 AMESim 全局一致。完整说明、运行命令和下一步校准路径见 `AmesimModels/test_mql/README.md`。
## Testmodel 当前架构判断
如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于:
- 组件级:已完成首版
- 系统闭合:已完成首版
- 积分入口:已完成首版
- 基线结果对齐:已具备初步能力
- 去近似:仍在进行中
所以当前最准确的说法不是“已完成移植”,而是:
`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。
## Testmodel 已知限制
当前最主要的限制可以直接理解成下面几条:
- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。
- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。
- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
- 当前 `Tee` 的 stream 语义只覆盖了当前 `Testmodel` 需要的最小集合,还不是通用的 `inStream/actualStream` 框架。
- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。
- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。
- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。
- 当前内核已经足够支撑下一阶段“结构化参数 -> 仿真执行 -> 产物输出”的链路开发,但还没有现成的 Word 参数解析入口和正式报告生成链路。
所以,当前版本适合:
- 架构验证
- 组件接口验证
- 基线工况对比
- 结果导出与误差定位
但当前版本还不适合:
- 直接宣称与 OpenModelica 严格等价
- 作为最终工程结论的唯一依据
- 直接扩展到更复杂拓扑而不补通用连接器语义
## Testmodel 文件级现状
按代码现状逐项看:
- `core/base.py`: 正常
只提供最小抽象层,没有明显冗余。
- `core/ports.py`: 正常
`PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。
- `core/state.py`: 正常
`VolumeState` 只负责 `[m, U]` 状态打包。
- `core/network.py`: 正常
负责状态向量拼装和连接摘要,不参与物理求解。
- `core/solver.py`: 正常
已支持 SciPy、RK4 回退和零时长仿真。
- `components/*.py`: 正常
都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。
- `systems/testmodel.py`: 是当前最重要的技术债集中区
这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。
- `scripts/run_testmodel.py`: 正常
已不是“最小打印脚本”,而是当前结果导出和对比入口。
- `baselines/`: 是当前稳定基线,不应该随着日常运行频繁改动。
- `runs/`: 是当前默认运行产物目录,不是手写源代码,也不应该当作提交基线使用。
## Testmodel 当前主技术债
目前最主要的技术债,可以直接理解成下面 4 件事:
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
2. `systems/testmodel.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
-2
View File
@@ -1,2 +0,0 @@
"""Python port scaffold for the Modelica-based pressurization system."""
-2
View File
@@ -1,2 +0,0 @@
"""Component implementations for the Python system model."""
-55
View File
@@ -1,55 +0,0 @@
from __future__ import annotations
from PythonModels.core.base import DynamicComponent
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
class Cylinder(DynamicComponent):
"""Python port of ModelicaModels.Mycylinder."""
def __init__(
self,
name: str,
medium: IdealGasMedium,
V: float = 0.01,
p0: float = 35e6,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
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 = PortState()
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 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)
-28
View File
@@ -1,28 +0,0 @@
from __future__ import annotations
from math import sqrt
from PythonModels.core.base import AlgebraicComponent
from PythonModels.core.ports import PortState
class Orifice(AlgebraicComponent):
"""Python port of ModelicaModels.Myorifice."""
def __init__(self, name: str, opening: float = 1.0, K: float = 1e-7) -> None:
super().__init__(name=name)
self.opening = opening
self.K = K
self.port_a = PortState()
self.port_b = PortState()
@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)
-133
View File
@@ -1,133 +0,0 @@
from __future__ import annotations
from PythonModels.core.base import DynamicComponent
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
class Pipe(DynamicComponent):
"""Python port of ModelicaModels.Mypipe."""
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.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 = PortState()
self.port_b = PortState()
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 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 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)
-55
View File
@@ -1,55 +0,0 @@
from __future__ import annotations
from PythonModels.core.base import DynamicComponent
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
class Tank(DynamicComponent):
"""Python port of ModelicaModels.Mytank."""
def __init__(
self,
name: str,
medium: IdealGasMedium,
V: float = 0.1,
p0: float = 1e5,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
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 = PortState()
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 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)
-172
View File
@@ -1,172 +0,0 @@
from __future__ import annotations
from PythonModels.core.base import AlgebraicComponent
from PythonModels.core.ports import PortState
class Tee(AlgebraicComponent):
"""Python port of ModelicaModels.Mytee."""
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.port_in = PortState()
self.port_out1 = PortState()
self.port_out2 = PortState()
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)
-2
View File
@@ -1,2 +0,0 @@
"""Core abstractions for the Python system model."""
-48
View File
@@ -1,48 +0,0 @@
from __future__ import annotations
from abc import ABC, abstractmethod
class Component(ABC):
def __init__(self, name: str) -> None:
self.name = name
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
class AlgebraicComponent(Component):
"""Stateless element described by algebraic constraints only."""
-96
View File
@@ -1,96 +0,0 @@
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)
-237
View File
@@ -1,237 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from math import acos, cos, isfinite, log, pi, sqrt
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
@dataclass(frozen=True)
class PengRobinsonFluid:
"""Pure-fluid Peng-Robinson equation-of-state helper.
The class covers the equation-of-state layer plus the enthalpy departure
needed to compare AMESim pneumatic ``pn2hpti`` reference enthalpy flows.
"""
name: str
molar_mass: float
critical_temperature: float
critical_pressure: float
acentric_factor: float
@property
def specific_gas_constant(self) -> float:
return UNIVERSAL_GAS_CONSTANT / self.molar_mass
@property
def a_parameter(self) -> float:
return (
0.45724
* UNIVERSAL_GAS_CONSTANT
* UNIVERSAL_GAS_CONSTANT
* self.critical_temperature
* self.critical_temperature
/ self.critical_pressure
)
@property
def b_parameter(self) -> float:
return 0.07780 * UNIVERSAL_GAS_CONSTANT * self.critical_temperature / self.critical_pressure
@property
def kappa(self) -> float:
omega = self.acentric_factor
return 0.37464 + 1.54226 * omega - 0.26992 * omega * omega
def alpha(self, temperature: float) -> float:
self._validate_temperature(temperature)
reduced_temperature = temperature / self.critical_temperature
return (1.0 + self.kappa * (1.0 - sqrt(reduced_temperature))) ** 2.0
def alpha_temperature_derivative(self, temperature: float) -> float:
self._validate_temperature(temperature)
reduced_temperature = temperature / self.critical_temperature
sqrt_reduced_temperature = sqrt(reduced_temperature)
alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature)
return -(
alpha_base
* self.kappa
/ (self.critical_temperature * sqrt_reduced_temperature)
)
def attractive_parameter(self, temperature: float) -> float:
return self.a_parameter * self.alpha(temperature)
def attractive_parameter_temperature_derivative(self, temperature: float) -> float:
return self.a_parameter * self.alpha_temperature_derivative(temperature)
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
self._validate_temperature(temperature)
if molar_volume <= self.b_parameter:
raise ValueError("Molar volume must be larger than Peng-Robinson b parameter.")
a_alpha = self.attractive_parameter(temperature)
b = self.b_parameter
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
return repulsive - attractive
def pressure_from_density(self, temperature: float, density: float) -> float:
if density <= 0.0:
raise ValueError("Density must be positive.")
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
def reduced_parameters(self, pressure: float, temperature: float) -> tuple[float, float]:
self._validate_pressure_temperature(pressure, temperature)
a_alpha = self.attractive_parameter(temperature)
b = self.b_parameter
A = a_alpha * pressure / (UNIVERSAL_GAS_CONSTANT * UNIVERSAL_GAS_CONSTANT * temperature * temperature)
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
return A, B
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
A, B = self.reduced_parameters(pressure, temperature)
coefficients = (
-(1.0 - B),
A - 3.0 * B * B - 2.0 * B,
-(A * B - B * B - B * B * B),
)
roots = _real_cubic_roots(*coefficients)
physical_roots = tuple(sorted(root for root in roots if root > B and isfinite(root)))
if not physical_roots:
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
return physical_roots
def compressibility_factor(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
roots = self.compressibility_roots(pressure, temperature)
if phase == "vapor":
return roots[-1]
if phase == "liquid":
return roots[0]
if phase == "stable-single-root":
return roots[-1]
raise ValueError(f"Unsupported phase selector: {phase!r}")
def molar_volume(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
z = self.compressibility_factor(pressure, temperature, phase=phase)
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
def density(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
def residual_specific_enthalpy(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
"""Return Peng-Robinson enthalpy departure from ideal gas, J/kg."""
self._validate_pressure_temperature(pressure, temperature)
z = self.compressibility_factor(pressure, temperature, phase=phase)
_, B = self.reduced_parameters(pressure, temperature)
b = self.b_parameter
attractive = self.attractive_parameter(temperature)
d_attractive_d_temperature = (
self.attractive_parameter_temperature_derivative(temperature)
)
log_argument = (z + (1.0 + sqrt(2.0)) * B) / (
z + (1.0 - sqrt(2.0)) * B
)
residual_molar_enthalpy = (
UNIVERSAL_GAS_CONSTANT * temperature * (z - 1.0)
+ (
temperature * d_attractive_d_temperature
- attractive
)
* log(log_argument)
/ (2.0 * sqrt(2.0) * b)
)
return residual_molar_enthalpy / self.molar_mass
@staticmethod
def _validate_temperature(temperature: float) -> None:
if temperature <= 0.0:
raise ValueError("Temperature must be positive.")
@classmethod
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
if pressure <= 0.0:
raise ValueError("Pressure must be positive.")
cls._validate_temperature(temperature)
HELIUM_PR = PengRobinsonFluid(
name="helium",
molar_mass=0.004002602,
critical_temperature=5.1953,
critical_pressure=227_460.0,
acentric_factor=-0.385,
)
NITROGEN_PR = PengRobinsonFluid(
name="nitrogen",
molar_mass=0.0280134,
critical_temperature=126.192,
critical_pressure=3.3958e6,
acentric_factor=0.0372,
)
AIR_PR = PengRobinsonFluid(
name="air",
molar_mass=0.02896513,
critical_temperature=132.5306,
critical_pressure=3.786e6,
acentric_factor=0.0335,
)
def _real_cubic_roots(a: float, b: float, c: float) -> tuple[float, ...]:
"""Return real roots for x**3 + a*x**2 + b*x + c = 0."""
depressed_p = b - a * a / 3.0
depressed_q = 2.0 * a * a * a / 27.0 - a * b / 3.0 + c
discriminant = (depressed_q / 2.0) ** 2.0 + (depressed_p / 3.0) ** 3.0
offset = -a / 3.0
tolerance = 1e-14
if discriminant > tolerance:
sqrt_discriminant = sqrt(discriminant)
u = _real_cube_root(-depressed_q / 2.0 + sqrt_discriminant)
v = _real_cube_root(-depressed_q / 2.0 - sqrt_discriminant)
return (u + v + offset,)
if abs(discriminant) <= tolerance:
u = _real_cube_root(-depressed_q / 2.0)
return tuple(sorted({2.0 * u + offset, -u + offset}))
if depressed_p >= 0.0:
raise ValueError("Unexpected cubic state with three real roots and non-negative p.")
radius = 2.0 * sqrt(-depressed_p / 3.0)
argument = (3.0 * depressed_q / (2.0 * depressed_p)) * sqrt(-3.0 / depressed_p)
argument = max(-1.0, min(1.0, argument))
theta = acos(argument) / 3.0
roots = [
radius * cos(theta - 2.0 * pi * index / 3.0) + offset
for index in range(3)
]
return tuple(sorted(roots))
def _real_cube_root(value: float) -> float:
if value == 0.0:
return 0.0
return (1.0 if value > 0.0 else -1.0) * abs(value) ** (1.0 / 3.0)
-13
View File
@@ -1,13 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@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
-318
View File
@@ -1,318 +0,0 @@
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-10
max_step: float = 1e-3
first_step: float | None = None
@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",
)
solver_options = {
"rtol": config.rtol,
"atol": config.atol,
"max_step": config.max_step,
}
if config.first_step is not None:
solver_options["first_step"] = config.first_step
try:
solver = solver_type(
cancellable_rhs,
config.t_start,
np.asarray(initial_state, dtype=float),
config.t_stop,
**solver_options,
)
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,
)
solve_options = {
"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,
}
if config.first_step is not None:
solve_options["first_step"] = config.first_step
return solve_ivp(**solve_options)
-21
View File
@@ -1,21 +0,0 @@
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])
-23
View File
@@ -1,23 +0,0 @@
from PythonModels.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",
]
-263
View File
@@ -1,263 +0,0 @@
from __future__ import annotations
import re
import struct
import tarfile
from dataclasses import dataclass
from pathlib import Path
class AmesimResultsError(ValueError):
"""Raised when AMESim result files cannot be parsed consistently."""
@dataclass(frozen=True)
class AmesimVariable:
index: int
label: str
data_path: str | None
param_id: int | None
hidden: bool
@dataclass(frozen=True)
class AmesimResults:
times: tuple[float, ...]
variables: tuple[AmesimVariable, ...]
saved_variable_indices: tuple[int, ...]
series_by_data_path: dict[str, tuple[float, ...]]
final_values_by_data_path: dict[str, float]
@property
def point_count(self) -> int:
return len(self.times)
@property
def saved_variable_count(self) -> int:
return len(self.saved_variable_indices)
def series(self, data_path: str) -> tuple[float, ...]:
return self.series_by_data_path[data_path]
def final_value(self, data_path: str) -> float:
return self.final_values_by_data_path[data_path]
_DATA_PATH_RE = re.compile(r"Data_Path=(\S+)")
_PARAM_ID_RE = re.compile(r"Param_Id=(\d+)")
def load_test_mql_amesim_results(
archive_path: str | Path,
*,
time_stop_s: float | None = None,
) -> AmesimResults:
return load_amesim_results_from_archive(
archive_path=archive_path,
var_member=None,
results_member=None,
time_stop_s=time_stop_s,
)
def load_amesim_results_from_archive(
*,
archive_path: str | Path,
var_member: str | None,
results_member: str | None,
time_stop_s: float | None = None,
) -> AmesimResults:
with tarfile.open(archive_path) as archive:
var_member, results_member = _resolve_result_members(
archive,
var_member=var_member,
results_member=results_member,
)
var_file = archive.extractfile(var_member)
results_file = archive.extractfile(results_member)
if var_file is None:
raise AmesimResultsError(f"Missing AMESim variable member: {var_member}")
if results_file is None:
raise AmesimResultsError(f"Missing AMESim results member: {results_member}")
var_lines = var_file.read().decode("latin1").splitlines()
variables = tuple(
_parse_variable_line(index, line) for index, line in enumerate(var_lines)
)
if time_stop_s is not None:
return _parse_amesim_results_window(
results_file,
variables,
time_stop_s=time_stop_s,
)
results_data = results_file.read()
return parse_amesim_results_bytes(results_data, variables)
def _resolve_result_members(
archive: tarfile.TarFile,
*,
var_member: str | None,
results_member: str | None,
) -> tuple[str, str]:
member_names = set(archive.getnames())
if var_member is not None or results_member is not None:
if var_member is None or results_member is None:
raise AmesimResultsError(
"var_member and results_member must either both be set or both be omitted."
)
return var_member, results_member
preferred = ("test_mql_.var", "test_mql_.results")
if preferred[0] in member_names and preferred[1] in member_names:
return preferred
pairs = sorted(
(name, f"{name[:-4]}.results")
for name in member_names
if name.endswith(".var") and f"{name[:-4]}.results" in member_names
)
if len(pairs) != 1:
raise AmesimResultsError(
"Unable to identify a unique AMESim .var/.results member pair."
)
return pairs[0]
def _parse_amesim_results_window(
results_file,
variables: tuple[AmesimVariable, ...],
*,
time_stop_s: float,
) -> AmesimResults:
header = results_file.read(8)
if len(header) < 8:
raise AmesimResultsError("AMESim results data is too small.")
point_count, encoded_saved_variable_count = struct.unpack("<2i", header)
saved_variable_count = abs(encoded_saved_variable_count)
if point_count <= 0 or saved_variable_count <= 0:
raise AmesimResultsError("Invalid AMESim results header.")
mapping_data = results_file.read(saved_variable_count * 4)
if len(mapping_data) != saved_variable_count * 4:
raise AmesimResultsError("AMESim results variable mapping is truncated.")
saved_variable_indices = struct.unpack(
f"<{saved_variable_count}i",
mapping_data,
)
if any(index < 0 or index >= len(variables) for index in saved_variable_indices):
raise AmesimResultsError(
"AMESim results variable mapping references unknown .var rows."
)
row_length = 1 + saved_variable_count
row_byte_count = row_length * 8
times: list[float] = []
series_lists: dict[str, list[float]] = {}
saved_paths: list[tuple[int, str]] = []
for column, variable_index in enumerate(saved_variable_indices, start=1):
data_path = variables[variable_index].data_path
if data_path is None:
continue
series_lists[data_path] = []
saved_paths.append((column, data_path))
for _row_index in range(point_count):
row = results_file.read(row_byte_count)
if len(row) != row_byte_count:
raise AmesimResultsError("AMESim results matrix is truncated.")
time_s = struct.unpack_from("<d", row, 0)[0]
times.append(time_s)
for column, data_path in saved_paths:
series_lists[data_path].append(
struct.unpack_from("<d", row, column * 8)[0]
)
# Keep one real sample after the requested stop so endpoint finite
# differences do not silently fall back to a backward-only slope.
if time_s > time_stop_s + 1.0e-12:
break
return AmesimResults(
times=tuple(times),
variables=variables,
saved_variable_indices=tuple(saved_variable_indices),
series_by_data_path={
data_path: tuple(values) for data_path, values in series_lists.items()
},
final_values_by_data_path={},
)
def parse_amesim_results_bytes(
results_data: bytes,
variables: tuple[AmesimVariable, ...],
) -> AmesimResults:
if len(results_data) < 8:
raise AmesimResultsError("AMESim results data is too small.")
point_count, encoded_saved_variable_count = struct.unpack_from("<2i", results_data, 0)
saved_variable_count = abs(encoded_saved_variable_count)
if point_count <= 0 or saved_variable_count <= 0:
raise AmesimResultsError("Invalid AMESim results header.")
mapping_offset = 8
mapping_size = saved_variable_count * 4
data_offset = mapping_offset + mapping_size
saved_variable_indices = struct.unpack_from(
f"<{saved_variable_count}i",
results_data,
mapping_offset,
)
if any(index < 0 or index >= len(variables) for index in saved_variable_indices):
raise AmesimResultsError(
"AMESim results variable mapping references unknown .var rows."
)
row_length = 1 + saved_variable_count
main_value_count = point_count * row_length
main_byte_count = main_value_count * 8
main_end = data_offset + main_byte_count
if main_end > len(results_data):
raise AmesimResultsError("AMESim results matrix is truncated.")
main_values = struct.unpack_from(f"<{main_value_count}d", results_data, data_offset)
times = tuple(main_values[row * row_length] for row in range(point_count))
series_by_data_path: dict[str, tuple[float, ...]] = {}
for column, variable_index in enumerate(saved_variable_indices, start=1):
variable = variables[variable_index]
if variable.data_path is None:
continue
series_by_data_path[variable.data_path] = tuple(
main_values[row * row_length + column]
for row in range(point_count)
)
final_values_by_data_path: dict[str, float] = {}
trailing_bytes = len(results_data) - main_end
expected_final_bytes = (1 + len(variables)) * 8
if trailing_bytes >= expected_final_bytes:
final_values = struct.unpack_from(f"<{1 + len(variables)}d", results_data, main_end)
for variable, value in zip(variables, final_values[1:]):
if variable.data_path is not None:
final_values_by_data_path[variable.data_path] = value
return AmesimResults(
times=times,
variables=variables,
saved_variable_indices=tuple(saved_variable_indices),
series_by_data_path=series_by_data_path,
final_values_by_data_path=final_values_by_data_path,
)
def _parse_variable_line(index: int, line: str) -> AmesimVariable:
data_path_match = _DATA_PATH_RE.search(line)
param_id_match = _PARAM_ID_RE.search(line)
label = line
if data_path_match is not None:
label = line[: data_path_match.start()].strip()
return AmesimVariable(
index=index,
label=label,
data_path=data_path_match.group(1) if data_path_match else None,
param_id=int(param_id_match.group(1)) if param_id_match else None,
hidden="HIDDEN" in line,
)
@@ -1,195 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
@dataclass(frozen=True)
class TestMqlChamberObservation:
time: float
pressure_pa: float
temperature_k: float
gas_mass_g: float
volume_cm3: float | None
@dataclass(frozen=True)
class TestMqlChamberBinding:
alias: str
submodel: str
pressure_path: str
temperature_path: str
gas_mass_path: str
pressure_duplicate_paths: tuple[str, ...]
temperature_duplicate_paths: tuple[str, ...]
volume_path: str | None
@property
def is_variable(self) -> bool:
return self.volume_path is not None
def observation_at(self, results: AmesimResults, index: int) -> TestMqlChamberObservation:
return TestMqlChamberObservation(
time=results.times[index],
pressure_pa=results.series(self.pressure_path)[index],
temperature_k=results.series(self.temperature_path)[index],
gas_mass_g=results.series(self.gas_mass_path)[index],
volume_cm3=(
results.series(self.volume_path)[index]
if self.volume_path is not None
else None
),
)
@dataclass(frozen=True)
class TestMqlChamberObservationCatalog:
bindings: tuple[TestMqlChamberBinding, ...]
@property
def fixed_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNCH023")
@property
def variable_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNCH012")
def by_alias(self, alias: str) -> TestMqlChamberBinding:
for binding in self.bindings:
if binding.alias == alias:
return binding
raise KeyError(alias)
def build_test_mql_chamber_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
assembly: TestMqlPneumaticAssembly | None = None,
) -> TestMqlChamberObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
assembly = assembly or build_test_mql_pneumatic_assembly()
chamber_aliases = {
**{alias: "PNCH023" for alias in assembly.fixed_chambers},
**{alias: "PNCH012" for alias in assembly.variable_chambers},
}
bindings = []
for alias, submodel in chamber_aliases.items():
variables = tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == alias
)
pressure = _primary_observable(variables, "press", expected_units="Pa")
temperature = _primary_observable(variables, "temp", expected_units="K")
gas_mass = _required_path(
variables,
"mgas1" if submodel == "PNCH012" else "mgas",
expected_units="g",
)
volume = _optional_path(variables, "vol", expected_units="cm**3")
bindings.append(
TestMqlChamberBinding(
alias=alias,
submodel=submodel,
pressure_path=pressure.data_path,
temperature_path=temperature.data_path,
gas_mass_path=gas_mass,
pressure_duplicate_paths=_duplicate_paths(variables, "press", expected_units="Pa"),
temperature_duplicate_paths=_duplicate_paths(variables, "temp", expected_units="K"),
volume_path=volume,
)
)
return TestMqlChamberObservationCatalog(
bindings=tuple(sorted(bindings, key=lambda binding: binding.alias))
)
def _primary_observable(
variables: tuple[TestMqlVariableBinding, ...],
signal_prefix: str,
*,
expected_units: str,
) -> TestMqlVariableBinding:
matches = tuple(
variable
for variable in variables
if variable.signal_name == signal_prefix
and "duplicate" not in variable.label
)
variable = _single(matches, f"primary {signal_prefix}")
_assert_units(variable, expected_units)
return variable
def _duplicate_paths(
variables: tuple[TestMqlVariableBinding, ...],
signal_prefix: str,
*,
expected_units: str,
) -> tuple[str, ...]:
matches = tuple(
variable
for variable in variables
if variable.signal_name.startswith(signal_prefix)
and variable.signal_name != signal_prefix
and "duplicate" in variable.label
)
for variable in matches:
_assert_units(variable, expected_units)
return tuple(variable.data_path for variable in matches)
def _required_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str,
) -> str:
variable = _single(
tuple(variable for variable in variables if variable.signal_name == signal_name),
signal_name,
)
_assert_units(variable, expected_units)
return variable.data_path
def _optional_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str,
) -> str | None:
matches = tuple(variable for variable in variables if variable.signal_name == signal_name)
if not matches:
return None
variable = _single(matches, signal_name)
_assert_units(variable, expected_units)
return variable.data_path
def _single(
matches: tuple[TestMqlVariableBinding, ...],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
def _assert_units(variable: TestMqlVariableBinding, expected_units: str) -> None:
if variable.units != expected_units:
raise ValueError(
f"Unexpected units for {variable.data_path}: "
f"{variable.units!r}, expected {expected_units!r}."
)
@@ -1,237 +0,0 @@
from __future__ import annotations
from bisect import bisect_left
import csv
from dataclasses import dataclass
from pathlib import Path
from PythonModels.reporting.amesim_results import AmesimResults
DEFAULT_TEST_MQL_ALIGNMENT_PATHS = (
"temp3@pn_c1_8",
"press3@pn_c1_8",
"vvol1@pn_brp2_8",
"vol1@pn_brp2_8",
)
@dataclass(frozen=True)
class TestMqlComparisonMetric:
data_path: str
sample_count: int
max_abs_error: float
mean_abs_error: float
max_rel_error: float
final_abs_error: float
@dataclass(frozen=True)
class TestMqlComparisonResult:
metrics: tuple[TestMqlComparisonMetric, ...]
def metric(self, data_path: str) -> TestMqlComparisonMetric:
for metric in self.metrics:
if metric.data_path == data_path:
return metric
raise KeyError(data_path)
@property
def max_abs_error(self) -> float:
return max((metric.max_abs_error for metric in self.metrics), default=0.0)
@property
def max_rel_error(self) -> float:
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
class TestMqlComparisonError(ValueError):
"""Raised when Python and AMESim series cannot be aligned."""
def compare_test_mql_series(
*,
python_times: tuple[float, ...] | list[float],
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
relative_floor: float = 1.0e-12,
) -> TestMqlComparisonResult:
_validate_time_axis(python_times)
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
metrics = []
for data_path in selected_paths:
python_values = tuple(float(value) for value in python_series_by_data_path[data_path])
if len(python_values) != len(python_times):
raise TestMqlComparisonError(
f"Python series length mismatch for {data_path!r}: "
f"{len(python_values)} values for {len(python_times)} time samples."
)
amesim_values = amesim_results.series(data_path)
abs_errors = []
rel_errors = []
for time_value, python_value in zip(python_times, python_values):
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
abs_error = abs(python_value - amesim_value)
abs_errors.append(abs_error)
rel_errors.append(abs_error / max(abs(amesim_value), relative_floor))
final_amesim_value = interpolate_series_value(
amesim_results.times,
amesim_values,
float(python_times[-1]),
)
metrics.append(
TestMqlComparisonMetric(
data_path=data_path,
sample_count=len(python_times),
max_abs_error=max(abs_errors, default=0.0),
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
max_rel_error=max(rel_errors, default=0.0),
final_abs_error=abs(python_values[-1] - final_amesim_value),
)
)
return TestMqlComparisonResult(metrics=tuple(metrics))
def write_test_mql_amesim_baseline_csv(
output_dir: Path,
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] = DEFAULT_TEST_MQL_ALIGNMENT_PATHS,
) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
csv_path = output_dir / "test_mql_amesim_baseline.csv"
_validate_amesim_data_paths(amesim_results, data_paths)
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["time_s", *data_paths])
for index, time_value in enumerate(amesim_results.times):
writer.writerow(
[time_value, *(amesim_results.series(data_path)[index] for data_path in data_paths)]
)
return csv_path
def write_test_mql_comparison_csv(
*,
output_dir: Path,
python_times: tuple[float, ...] | list[float],
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
) -> tuple[Path, Path, TestMqlComparisonResult]:
output_dir.mkdir(parents=True, exist_ok=True)
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
comparison = compare_test_mql_series(
python_times=python_times,
python_series_by_data_path=python_series_by_data_path,
amesim_results=amesim_results,
data_paths=selected_paths,
)
csv_path = output_dir / "test_mql_amesim_comparison.csv"
summary_path = output_dir / "test_mql_amesim_comparison_summary.txt"
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
header = ["time_s"]
for data_path in selected_paths:
header.extend(
[
f"python.{data_path}",
f"amesim.{data_path}",
f"abs_error.{data_path}",
f"rel_error.{data_path}",
]
)
writer.writerow(header)
for index, time_value in enumerate(python_times):
row = [time_value]
for data_path in selected_paths:
python_value = float(python_series_by_data_path[data_path][index])
amesim_value = interpolate_series_value(
amesim_results.times,
amesim_results.series(data_path),
float(time_value),
)
abs_error = abs(python_value - amesim_value)
rel_error = abs_error / max(abs(amesim_value), 1.0e-12)
row.extend([python_value, amesim_value, abs_error, rel_error])
writer.writerow(row)
summary_lines = [
(
f"{metric.data_path}: samples={metric.sample_count}, "
f"max_abs_error={metric.max_abs_error:.12g}, "
f"mean_abs_error={metric.mean_abs_error:.12g}, "
f"max_rel_error={metric.max_rel_error:.12%}, "
f"final_abs_error={metric.final_abs_error:.12g}"
)
for metric in comparison.metrics
]
summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8")
return csv_path, summary_path, comparison
def interpolate_series_value(
time_values: tuple[float, ...] | list[float],
values: tuple[float, ...] | list[float],
target_time: float,
) -> float:
if len(time_values) != len(values):
raise TestMqlComparisonError("time and value series lengths differ.")
if not time_values:
raise TestMqlComparisonError("cannot interpolate an empty series.")
if target_time <= time_values[0]:
return float(values[0])
if target_time >= time_values[-1]:
return float(values[-1])
right_index = bisect_left(time_values, target_time)
if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1.0e-12:
return float(values[right_index])
left_index = right_index - 1
left_time = float(time_values[left_index])
right_time = float(time_values[right_index])
fraction = (target_time - left_time) / (right_time - left_time)
return float(values[left_index]) + fraction * (float(values[right_index]) - float(values[left_index]))
def _select_data_paths(
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None,
) -> tuple[str, ...]:
if data_paths is None:
data_paths = tuple(
data_path
for data_path in python_series_by_data_path
if data_path in amesim_results.series_by_data_path
)
selected_paths = tuple(data_paths)
if not selected_paths:
raise TestMqlComparisonError("no common Data_Path values are available for comparison.")
missing_python = [data_path for data_path in selected_paths if data_path not in python_series_by_data_path]
if missing_python:
raise TestMqlComparisonError(f"Python series missing Data_Path values: {missing_python}")
_validate_amesim_data_paths(amesim_results, selected_paths)
return selected_paths
def _validate_amesim_data_paths(
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str],
) -> None:
missing_amesim = [data_path for data_path in data_paths if data_path not in amesim_results.series_by_data_path]
if missing_amesim:
raise TestMqlComparisonError(f"AMESim results missing Data_Path values: {missing_amesim}")
def _validate_time_axis(time_values: tuple[float, ...] | list[float]) -> None:
if not time_values:
raise TestMqlComparisonError("Python time axis is empty.")
previous = float(time_values[0])
for value in time_values[1:]:
value = float(value)
if value < previous:
raise TestMqlComparisonError("Python time axis must be monotonically increasing.")
previous = value
@@ -1,211 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_lines import (
TestMqlLineAssembly,
build_test_mql_line_assembly,
)
G_PER_S_TO_KG_PER_S = 1.0e-3
@dataclass(frozen=True)
class TestMqlLineObservation:
time: float
mass_flows_kg_s: dict[str, float]
enthalpy_flows_w: dict[str, float]
pressures_pa: dict[str, float]
temperatures_k: dict[str, float]
gas_mass_g: float | None
reynolds_number: float
mass_flow_parameter: float
gas_velocity_m_s: float
friction_factor: float
@dataclass(frozen=True)
class TestMqlLineObservationBinding:
alias: str
submodel: str
pattern: str
mass_flow_paths: tuple[str, ...]
enthalpy_flow_paths: tuple[str, ...]
pressure_paths: tuple[str, ...]
temperature_paths: tuple[str, ...]
gas_mass_path: str | None
reynolds_path: str
mass_flow_parameter_path: str
gas_velocity_path: str
friction_factor_path: str
def mass_flow_kg_s_series(
self,
results: AmesimResults,
data_path: str | None = None,
) -> tuple[float, ...]:
path = data_path or self.mass_flow_paths[0]
if path not in self.mass_flow_paths:
raise KeyError(path)
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(path))
def observation_at(self, results: AmesimResults, index: int) -> TestMqlLineObservation:
return TestMqlLineObservation(
time=results.times[index],
mass_flows_kg_s={
path: results.series(path)[index] * G_PER_S_TO_KG_PER_S
for path in self.mass_flow_paths
},
enthalpy_flows_w={
path: results.series(path)[index]
for path in self.enthalpy_flow_paths
},
pressures_pa={
path: results.series(path)[index]
for path in self.pressure_paths
},
temperatures_k={
path: results.series(path)[index]
for path in self.temperature_paths
},
gas_mass_g=(
results.series(self.gas_mass_path)[index]
if self.gas_mass_path is not None
else None
),
reynolds_number=results.series(self.reynolds_path)[index],
mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index],
gas_velocity_m_s=results.series(self.gas_velocity_path)[index],
friction_factor=results.series(self.friction_factor_path)[index],
)
@dataclass(frozen=True)
class TestMqlLineObservationCatalog:
bindings: tuple[TestMqlLineObservationBinding, ...]
@property
def line_count(self) -> int:
return len(self.bindings)
def by_alias(self, alias: str) -> TestMqlLineObservationBinding:
for binding in self.bindings:
if binding.alias == alias:
return binding
raise KeyError(alias)
def by_submodel(self, submodel: str) -> tuple[TestMqlLineObservationBinding, ...]:
return tuple(binding for binding in self.bindings if binding.submodel == submodel)
def build_test_mql_line_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
line_assembly: TestMqlLineAssembly | None = None,
) -> TestMqlLineObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
line_assembly = line_assembly or build_test_mql_line_assembly(results, variable_catalog)
bindings = []
for line in line_assembly.lines:
variables = tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == line.alias
)
bindings.append(
TestMqlLineObservationBinding(
alias=line.alias,
submodel=line.submodel,
pattern=line.pattern,
mass_flow_paths=_paths_with_prefix(variables, "dm", expected_units="g/s"),
enthalpy_flow_paths=_paths_with_prefix(variables, "dh", expected_units="J/s"),
pressure_paths=_paths_with_prefix(variables, "p", expected_units="Pa"),
temperature_paths=_paths_with_prefix(variables, "t", expected_units="K"),
gas_mass_path=_optional_path(variables, "mgas", expected_units="g"),
reynolds_path=_required_path(variables, "re", expected_units=None),
mass_flow_parameter_path=_required_path(
variables,
"cm",
expected_units="(kg*K/J)**(1/2)",
),
gas_velocity_path=_required_path(variables, "v", expected_units="m/s"),
friction_factor_path=_required_path(variables, "ff", expected_units=None),
)
)
return TestMqlLineObservationCatalog(bindings=tuple(bindings))
def _paths_with_prefix(
variables: tuple[TestMqlVariableBinding, ...],
prefix: str,
*,
expected_units: str | None,
) -> tuple[str, ...]:
matches = tuple(
variable
for variable in variables
if variable.signal_name.startswith(prefix)
)
for variable in matches:
_assert_units(variable, expected_units)
return tuple(variable.data_path for variable in matches)
def _required_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str | None,
) -> str:
variable = _single_signal(variables, signal_name)
_assert_units(variable, expected_units)
return variable.data_path
def _optional_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str | None,
) -> str | None:
matches = tuple(variable for variable in variables if variable.signal_name == signal_name)
if not matches:
return None
variable = _single(matches, signal_name)
_assert_units(variable, expected_units)
return variable.data_path
def _single_signal(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
) -> TestMqlVariableBinding:
return _single(
tuple(variable for variable in variables if variable.signal_name == signal_name),
signal_name,
)
def _single(
matches: tuple[TestMqlVariableBinding, ...],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None:
if variable.units != expected_units:
raise ValueError(
f"Unexpected units for {variable.data_path}: "
f"{variable.units!r}, expected {expected_units!r}."
)
@@ -1,395 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_mechanical import (
TestMqlMechanicalAssembly,
build_test_mql_mechanical_assembly,
)
@dataclass(frozen=True)
class TestMqlPistonObservation:
time: float
chamber_volume_cm3: float
chamber_volume_rate_l_min: float
chamber_length_mm: float
force_port_2_n: float
force_port_3_n: float
displacement_port_2_m: float
velocity_port_2_m_s: float
displacement_port_3_m: float
velocity_port_3_m_s: float
@dataclass(frozen=True)
class TestMqlMassEndstopObservation:
time: float
displacement_m: float
velocity_m_s: float
acceleration_m_s2: float
lower_contact_force_n: float
upper_contact_force_n: float
viscous_friction_force_n: float
dry_friction_force_n: float
stick_flag: float
@dataclass(frozen=True)
class TestMqlElasticEndstopObservation:
time: float
force_n: float
duplicate_force_n: float
gap_mm: float
stiffness_n_m: float
@dataclass(frozen=True)
class TestMqlForceSourceObservation:
time: float
force_n: float
@dataclass(frozen=True)
class TestMqlForceConnectorObservation:
time: float
force_n: float
@dataclass(frozen=True)
class TestMqlMechanicalNodeObservation:
time: float
velocities_m_s: dict[int, float]
displacements_m: dict[int, float]
total_force_n: float
@dataclass(frozen=True)
class TestMqlPistonObservationBinding:
alias: str
volume_path: str
volume_rate_path: str
length_path: str
force_port_2_path: str
force_port_3_path: str
displacement_port_2_path: str
velocity_port_2_path: str
displacement_port_3_path: str
velocity_port_3_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlPistonObservation:
return TestMqlPistonObservation(
time=results.times[index],
chamber_volume_cm3=results.series(self.volume_path)[index],
chamber_volume_rate_l_min=results.series(self.volume_rate_path)[index],
chamber_length_mm=results.series(self.length_path)[index],
force_port_2_n=results.series(self.force_port_2_path)[index],
force_port_3_n=results.series(self.force_port_3_path)[index],
displacement_port_2_m=results.series(self.displacement_port_2_path)[index],
velocity_port_2_m_s=results.series(self.velocity_port_2_path)[index],
displacement_port_3_m=results.series(self.displacement_port_3_path)[index],
velocity_port_3_m_s=results.series(self.velocity_port_3_path)[index],
)
@dataclass(frozen=True)
class TestMqlMassEndstopObservationBinding:
alias: str
displacement_path: str
velocity_path: str
acceleration_path: str
displacement_duplicate_path: str
velocity_duplicate_path: str
acceleration_duplicate_path: str
lower_contact_force_path: str
upper_contact_force_path: str
viscous_friction_force_path: str
dry_friction_force_path: str
stick_flag_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlMassEndstopObservation:
return TestMqlMassEndstopObservation(
time=results.times[index],
displacement_m=results.series(self.displacement_path)[index],
velocity_m_s=results.series(self.velocity_path)[index],
acceleration_m_s2=results.series(self.acceleration_path)[index],
lower_contact_force_n=results.series(self.lower_contact_force_path)[index],
upper_contact_force_n=results.series(self.upper_contact_force_path)[index],
viscous_friction_force_n=results.series(self.viscous_friction_force_path)[index],
dry_friction_force_n=results.series(self.dry_friction_force_path)[index],
stick_flag=results.series(self.stick_flag_path)[index],
)
@dataclass(frozen=True)
class TestMqlElasticEndstopObservationBinding:
alias: str
force_path: str
duplicate_force_path: str
gap_path: str
stiffness_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlElasticEndstopObservation:
return TestMqlElasticEndstopObservation(
time=results.times[index],
force_n=results.series(self.force_path)[index],
duplicate_force_n=results.series(self.duplicate_force_path)[index],
gap_mm=results.series(self.gap_path)[index],
stiffness_n_m=results.series(self.stiffness_path)[index],
)
@dataclass(frozen=True)
class TestMqlForceSourceObservationBinding:
alias: str
force_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceSourceObservation:
return TestMqlForceSourceObservation(
time=results.times[index],
force_n=results.series(self.force_path)[index],
)
@dataclass(frozen=True)
class TestMqlForceConnectorObservationBinding:
alias: str
force_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceConnectorObservation:
return TestMqlForceConnectorObservation(
time=results.times[index],
force_n=results.series(self.force_path)[index],
)
@dataclass(frozen=True)
class TestMqlMechanicalNodeObservationBinding:
alias: str
velocity_paths_by_port: dict[int, str]
displacement_paths_by_port: dict[int, str]
total_force_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlMechanicalNodeObservation:
return TestMqlMechanicalNodeObservation(
time=results.times[index],
velocities_m_s={
port: results.series(path)[index]
for port, path in self.velocity_paths_by_port.items()
},
displacements_m={
port: results.series(path)[index]
for port, path in self.displacement_paths_by_port.items()
},
total_force_n=results.series(self.total_force_path)[index],
)
@dataclass(frozen=True)
class TestMqlMechanicalObservationCatalog:
pistons: dict[str, TestMqlPistonObservationBinding]
masses: dict[str, TestMqlMassEndstopObservationBinding]
elastic_endstops: dict[str, TestMqlElasticEndstopObservationBinding]
zero_force_sources: dict[str, TestMqlForceSourceObservationBinding]
force_connectors: dict[str, TestMqlForceConnectorObservationBinding]
mechanical_nodes: dict[str, TestMqlMechanicalNodeObservationBinding]
@property
def binding_count(self) -> int:
return (
len(self.pistons)
+ len(self.masses)
+ len(self.elastic_endstops)
+ len(self.zero_force_sources)
+ len(self.force_connectors)
+ len(self.mechanical_nodes)
)
def build_test_mql_mechanical_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
mechanical_assembly: TestMqlMechanicalAssembly | None = None,
) -> TestMqlMechanicalObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
mechanical_assembly = mechanical_assembly or build_test_mql_mechanical_assembly(
amesim_results=results,
variable_catalog=variable_catalog,
)
return TestMqlMechanicalObservationCatalog(
pistons={
alias: _build_piston_binding(alias, variable_catalog)
for alias in mechanical_assembly.pistons
},
masses={
alias: _build_mass_binding(alias, variable_catalog)
for alias in mechanical_assembly.masses
},
elastic_endstops={
alias: _build_elastic_endstop_binding(alias, variable_catalog)
for alias in mechanical_assembly.elastic_endstops
},
zero_force_sources={
alias: _build_zero_force_source_binding(alias, variable_catalog)
for alias in mechanical_assembly.zero_force_sources
},
force_connectors={
alias: _build_force_connector_binding(alias, variable_catalog)
for alias in mechanical_assembly.force_connectors
},
mechanical_nodes={
alias: _build_mechanical_node_binding(alias, variable_catalog)
for alias in mechanical_assembly.mechanical_nodes
},
)
def _build_piston_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlPistonObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlPistonObservationBinding(
alias=alias,
volume_path=_required_path(variables, "vol1", expected_units="cm**3"),
volume_rate_path=_required_path(variables, "vvol1", expected_units="L/min"),
length_path=_required_path(variables, "length", expected_units="mm"),
force_port_2_path=_required_path(variables, "f2", expected_units="N"),
force_port_3_path=_required_path(variables, "f3", expected_units="N"),
displacement_port_2_path=_required_path(variables, "x5", expected_units="m"),
velocity_port_2_path=_required_path(variables, "v5", expected_units="m/s"),
displacement_port_3_path=_required_path(variables, "x4", expected_units="m"),
velocity_port_3_path=_required_path(variables, "v4", expected_units="m/s"),
)
def _build_mass_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlMassEndstopObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlMassEndstopObservationBinding(
alias=alias,
displacement_path=_required_path(variables, "x1", expected_units="m"),
velocity_path=_required_path(variables, "v1", expected_units="m/s"),
acceleration_path=_required_path(variables, "acc1", expected_units="m/s/s"),
displacement_duplicate_path=_required_path(variables, "x1dup", expected_units="m"),
velocity_duplicate_path=_required_path(variables, "v1dup", expected_units="m/s"),
acceleration_duplicate_path=_required_path(variables, "acc1dup", expected_units="m/s/s"),
lower_contact_force_path=_required_path(variables, "Fmin", expected_units="N"),
upper_contact_force_path=_required_path(variables, "Fmax", expected_units="N"),
viscous_friction_force_path=_required_path(variables, "Fvisc", expected_units="N"),
dry_friction_force_path=_required_path(variables, "Ffric", expected_units="N"),
stick_flag_path=_required_path(variables, "stick", expected_units=None),
)
def _build_elastic_endstop_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlElasticEndstopObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlElasticEndstopObservationBinding(
alias=alias,
force_path=_required_path(variables, "f1", expected_units="N"),
duplicate_force_path=_required_path(variables, "f2", expected_units="N"),
gap_path=_required_path(variables, "gap", expected_units="mm"),
stiffness_path=_required_path(variables, "kval", expected_units="N/m"),
)
def _build_zero_force_source_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlForceSourceObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlForceSourceObservationBinding(
alias=alias,
force_path=_required_path(variables, "fzero", expected_units="N"),
)
def _build_force_connector_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlForceConnectorObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlForceConnectorObservationBinding(
alias=alias,
force_path=_required_path(variables, "force", expected_units="N"),
)
def _build_mechanical_node_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlMechanicalNodeObservationBinding:
variables = _owner_variables(variable_catalog, alias)
velocity_paths_by_port = {}
displacement_paths_by_port = {}
for port in range(1, 9):
velocity_paths_by_port[port] = _required_path(
variables,
f"p{port}__vt",
expected_units="m/s",
)
displacement_paths_by_port[port] = _required_path(
variables,
f"p{port}__xt",
expected_units="m",
)
return TestMqlMechanicalNodeObservationBinding(
alias=alias,
velocity_paths_by_port=velocity_paths_by_port,
displacement_paths_by_port=displacement_paths_by_port,
total_force_path=_required_path(variables, "tforce", expected_units="N"),
)
def _owner_variables(
variable_catalog: TestMqlVariableCatalog,
alias: str,
) -> tuple[TestMqlVariableBinding, ...]:
return tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == alias
)
def _required_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str | None,
) -> str:
variable = _single(
tuple(variable for variable in variables if variable.signal_name == signal_name),
signal_name,
)
_assert_units(variable, expected_units)
return variable.data_path
def _single(
matches: tuple[TestMqlVariableBinding, ...],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None:
if variable.units != expected_units:
raise ValueError(
f"Unexpected units for {variable.data_path}: "
f"{variable.units!r}, expected {expected_units!r}."
)
@@ -1,210 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_chamber_observations import (
TestMqlChamberObservationCatalog,
build_test_mql_chamber_observation_catalog,
)
from PythonModels.reporting.test_mql_line_observations import (
TestMqlLineObservationCatalog,
build_test_mql_line_observation_catalog,
)
from PythonModels.reporting.test_mql_mechanical_observations import (
TestMqlMechanicalObservationCatalog,
build_test_mql_mechanical_observation_catalog,
)
from PythonModels.reporting.test_mql_orifice_observations import (
TestMqlOrificeObservationCatalog,
build_test_mql_orifice_observation_catalog,
)
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
@dataclass(frozen=True)
class TestMqlObservationCatalog:
variable_catalog: TestMqlVariableCatalog
chambers: TestMqlChamberObservationCatalog
orifices: TestMqlOrificeObservationCatalog
lines: TestMqlLineObservationCatalog
mechanical: TestMqlMechanicalObservationCatalog
@property
def binding_count(self) -> int:
return (
len(self.chambers.bindings)
+ len(self.orifices.bindings)
+ self.lines.line_count
+ self.mechanical.binding_count
)
def data_paths_by_domain(self) -> dict[str, tuple[str, ...]]:
return {
"chambers": _sorted_unique(_chamber_data_paths(self.chambers)),
"orifices": _sorted_unique(_orifice_data_paths(self.orifices)),
"lines": _sorted_unique(_line_data_paths(self.lines)),
"mechanical": _sorted_unique(_mechanical_data_paths(self.mechanical)),
}
def data_paths(self) -> tuple[str, ...]:
paths = []
for domain_paths in self.data_paths_by_domain().values():
paths.extend(domain_paths)
return _sorted_unique(paths)
def baseline_series_by_data_path(
self,
results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
) -> dict[str, tuple[float, ...]]:
selected_paths = tuple(data_paths) if data_paths is not None else self.data_paths()
_validate_observed_paths(self, selected_paths)
return {data_path: results.series(data_path) for data_path in selected_paths}
def build_test_mql_observation_catalog(results: AmesimResults) -> TestMqlObservationCatalog:
variable_catalog = build_test_mql_variable_catalog(results)
return TestMqlObservationCatalog(
variable_catalog=variable_catalog,
chambers=build_test_mql_chamber_observation_catalog(
results,
variable_catalog=variable_catalog,
),
orifices=build_test_mql_orifice_observation_catalog(
results,
variable_catalog=variable_catalog,
),
lines=build_test_mql_line_observation_catalog(
results,
variable_catalog=variable_catalog,
),
mechanical=build_test_mql_mechanical_observation_catalog(
results,
variable_catalog=variable_catalog,
),
)
def _chamber_data_paths(catalog: TestMqlChamberObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.bindings:
paths.extend(
[
binding.pressure_path,
binding.temperature_path,
binding.gas_mass_path,
*binding.pressure_duplicate_paths,
*binding.temperature_duplicate_paths,
]
)
if binding.volume_path is not None:
paths.append(binding.volume_path)
return tuple(paths)
def _orifice_data_paths(catalog: TestMqlOrificeObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.bindings:
paths.extend(
[
binding.primary_mass_flow_path,
binding.primary_enthalpy_flow_path,
binding.reversed_mass_flow_path,
binding.reversed_enthalpy_flow_path,
binding.mass_flow_parameter_path,
binding.gas_velocity_path,
]
)
if binding.opening_path is not None:
paths.append(binding.opening_path)
return tuple(paths)
def _line_data_paths(catalog: TestMqlLineObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.bindings:
paths.extend(binding.mass_flow_paths)
paths.extend(binding.enthalpy_flow_paths)
paths.extend(binding.pressure_paths)
paths.extend(binding.temperature_paths)
if binding.gas_mass_path is not None:
paths.append(binding.gas_mass_path)
paths.extend(
[
binding.reynolds_path,
binding.mass_flow_parameter_path,
binding.gas_velocity_path,
binding.friction_factor_path,
]
)
return tuple(paths)
def _mechanical_data_paths(catalog: TestMqlMechanicalObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.pistons.values():
paths.extend(
[
binding.volume_path,
binding.volume_rate_path,
binding.length_path,
binding.force_port_2_path,
binding.force_port_3_path,
binding.displacement_port_2_path,
binding.velocity_port_2_path,
binding.displacement_port_3_path,
binding.velocity_port_3_path,
]
)
for binding in catalog.masses.values():
paths.extend(
[
binding.displacement_path,
binding.velocity_path,
binding.acceleration_path,
binding.displacement_duplicate_path,
binding.velocity_duplicate_path,
binding.acceleration_duplicate_path,
binding.lower_contact_force_path,
binding.upper_contact_force_path,
binding.viscous_friction_force_path,
binding.dry_friction_force_path,
binding.stick_flag_path,
]
)
for binding in catalog.elastic_endstops.values():
paths.extend(
[
binding.force_path,
binding.duplicate_force_path,
binding.gap_path,
binding.stiffness_path,
]
)
for binding in catalog.zero_force_sources.values():
paths.append(binding.force_path)
for binding in catalog.force_connectors.values():
paths.append(binding.force_path)
for binding in catalog.mechanical_nodes.values():
paths.extend(binding.velocity_paths_by_port.values())
paths.extend(binding.displacement_paths_by_port.values())
paths.append(binding.total_force_path)
return tuple(paths)
def _validate_observed_paths(
catalog: TestMqlObservationCatalog,
data_paths: tuple[str, ...],
) -> None:
observed_paths = set(catalog.data_paths())
missing = [data_path for data_path in data_paths if data_path not in observed_paths]
if missing:
raise KeyError(f"Data_Path values are not in the test_mql observation catalog: {missing}")
def _sorted_unique(data_paths: tuple[str, ...] | list[str]) -> tuple[str, ...]:
return tuple(sorted(set(data_paths)))
@@ -1,194 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
G_PER_S_TO_KG_PER_S = 1.0e-3
@dataclass(frozen=True)
class TestMqlOrificeObservation:
time: float
mass_flow_kg_s: float
enthalpy_flow_w: float
mass_flow_parameter: float
gas_velocity_m_s: float
opening: float
effective_area_m2: float
@dataclass(frozen=True)
class TestMqlOrificeBinding:
alias: str
submodel: str
nominal_area_m2: float
flow_coefficient: float
primary_mass_flow_path: str
primary_enthalpy_flow_path: str
reversed_mass_flow_path: str
reversed_enthalpy_flow_path: str
mass_flow_parameter_path: str
gas_velocity_path: str
opening_path: str | None
@property
def is_variable(self) -> bool:
return self.opening_path is not None
def opening_series(self, results: AmesimResults) -> tuple[float, ...]:
if self.opening_path is None:
return tuple(1.0 for _ in results.times)
return tuple(results.series(self.opening_path))
def mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]:
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.primary_mass_flow_path))
def reversed_mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]:
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.reversed_mass_flow_path))
def effective_area_series(self, results: AmesimResults) -> tuple[float, ...]:
return tuple(self.nominal_area_m2 * max(opening, 0.0) for opening in self.opening_series(results))
def observation_at(self, results: AmesimResults, index: int) -> TestMqlOrificeObservation:
opening = self.opening_series(results)[index]
return TestMqlOrificeObservation(
time=results.times[index],
mass_flow_kg_s=results.series(self.primary_mass_flow_path)[index] * G_PER_S_TO_KG_PER_S,
enthalpy_flow_w=results.series(self.primary_enthalpy_flow_path)[index],
mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index],
gas_velocity_m_s=results.series(self.gas_velocity_path)[index],
opening=opening,
effective_area_m2=self.nominal_area_m2 * max(opening, 0.0),
)
@dataclass(frozen=True)
class TestMqlOrificeObservationCatalog:
bindings: tuple[TestMqlOrificeBinding, ...]
@property
def fixed_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNOR001")
@property
def variable_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNVO001")
def by_alias(self, alias: str) -> TestMqlOrificeBinding:
for binding in self.bindings:
if binding.alias == alias:
return binding
raise KeyError(alias)
def build_test_mql_orifice_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
assembly: TestMqlPneumaticAssembly | None = None,
) -> TestMqlOrificeObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
assembly = assembly or build_test_mql_pneumatic_assembly()
bindings = []
for alias, orifice in {
**assembly.fixed_orifices,
**assembly.variable_orifices,
}.items():
owner_variables = tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == alias
)
primary_mass_flow = _find_primary(owner_variables, signal_prefix="dm")
primary_enthalpy_flow = _find_primary(owner_variables, signal_prefix="dh")
reversed_mass_flow = _find_reversed(owner_variables, signal_prefix="dm")
reversed_enthalpy_flow = _find_reversed(owner_variables, signal_prefix="dh")
mass_flow_parameter = _find_by_signal(owner_variables, "cm")
gas_velocity = _find_by_signal(owner_variables, "gasvel")
opening = _find_optional_by_signal(owner_variables, "xv")
bindings.append(
TestMqlOrificeBinding(
alias=alias,
submodel=primary_mass_flow.submodel,
nominal_area_m2=orifice.area,
flow_coefficient=orifice.flow_coefficient,
primary_mass_flow_path=primary_mass_flow.data_path,
primary_enthalpy_flow_path=primary_enthalpy_flow.data_path,
reversed_mass_flow_path=reversed_mass_flow.data_path,
reversed_enthalpy_flow_path=reversed_enthalpy_flow.data_path,
mass_flow_parameter_path=mass_flow_parameter.data_path,
gas_velocity_path=gas_velocity.data_path,
opening_path=opening.data_path if opening is not None else None,
)
)
return TestMqlOrificeObservationCatalog(
bindings=tuple(sorted(bindings, key=lambda binding: binding.alias))
)
def _find_primary(
variables: tuple[TestMqlVariableBinding, ...],
*,
signal_prefix: str,
) -> TestMqlVariableBinding:
matches = [
variable
for variable in variables
if variable.signal_name.startswith(signal_prefix)
and "sign reversed duplicate" not in variable.label
]
return _single(matches, f"primary {signal_prefix}")
def _find_reversed(
variables: tuple[TestMqlVariableBinding, ...],
*,
signal_prefix: str,
) -> TestMqlVariableBinding:
matches = [
variable
for variable in variables
if variable.signal_name.startswith(signal_prefix)
and "sign reversed duplicate" in variable.label
]
return _single(matches, f"reversed {signal_prefix}")
def _find_by_signal(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
) -> TestMqlVariableBinding:
return _single(
[variable for variable in variables if variable.signal_name == signal_name],
signal_name,
)
def _find_optional_by_signal(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
) -> TestMqlVariableBinding | None:
matches = [variable for variable in variables if variable.signal_name == signal_name]
if not matches:
return None
return _single(matches, signal_name)
def _single(
matches: list[TestMqlVariableBinding],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
@@ -1,100 +0,0 @@
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_observations import (
TestMqlObservationCatalog,
build_test_mql_observation_catalog,
)
from PythonModels.reporting.test_mql_variables import TestMqlVariableBinding
@dataclass(frozen=True)
class TestMqlOutputSignal:
data_path: str
domain: str
owner_alias: str
owner_kind: str
submodel: str
signal_name: str
units: str | None
amesim_index: int
saved: bool
@dataclass(frozen=True)
class TestMqlOutputSchema:
signals: tuple[TestMqlOutputSignal, ...]
@property
def signal_count(self) -> int:
return len(self.signals)
def by_data_path(self, data_path: str) -> TestMqlOutputSignal:
for signal in self.signals:
if signal.data_path == data_path:
return signal
raise KeyError(data_path)
def data_paths(self) -> tuple[str, ...]:
return tuple(signal.data_path for signal in self.signals)
def data_paths_by_domain(self, domain: str) -> tuple[str, ...]:
return tuple(signal.data_path for signal in self.signals if signal.domain == domain)
def counts_by_domain(self) -> dict[str, int]:
return dict(Counter(signal.domain for signal in self.signals))
def counts_by_submodel(self) -> dict[str, int]:
return dict(Counter(signal.submodel for signal in self.signals))
def counts_by_owner_kind(self) -> dict[str, int]:
return dict(Counter(signal.owner_kind for signal in self.signals))
def counts_by_units(self) -> dict[str | None, int]:
return dict(Counter(signal.units for signal in self.signals))
def build_test_mql_output_schema(
results: AmesimResults,
*,
observation_catalog: TestMqlObservationCatalog | None = None,
) -> TestMqlOutputSchema:
observation_catalog = observation_catalog or build_test_mql_observation_catalog(results)
domain_by_data_path = _domain_by_data_path(observation_catalog)
signals = []
for data_path in sorted(domain_by_data_path):
variable = observation_catalog.variable_catalog.by_data_path(data_path)
signals.append(_signal_from_variable(variable, domain_by_data_path[data_path]))
return TestMqlOutputSchema(signals=tuple(signals))
def _domain_by_data_path(
observation_catalog: TestMqlObservationCatalog,
) -> dict[str, str]:
domain_by_data_path = {}
for domain, data_paths in observation_catalog.data_paths_by_domain().items():
for data_path in data_paths:
if data_path in domain_by_data_path:
raise ValueError(f"Data_Path {data_path!r} is assigned to multiple domains.")
domain_by_data_path[data_path] = domain
return domain_by_data_path
def _signal_from_variable(
variable: TestMqlVariableBinding,
domain: str,
) -> TestMqlOutputSignal:
return TestMqlOutputSignal(
data_path=variable.data_path,
domain=domain,
owner_alias=variable.owner_alias,
owner_kind=variable.owner_kind,
submodel=variable.submodel,
signal_name=variable.signal_name,
units=variable.units,
amesim_index=variable.index,
saved=variable.saved,
)
@@ -1,161 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_comparison import (
TestMqlComparisonResult,
compare_test_mql_series,
)
from PythonModels.reporting.test_mql_output_schema import TestMqlOutputSchema
class TestMqlOutputValidationError(ValueError):
"""Raised when a Python test_mql output does not satisfy the AMESim output contract."""
@dataclass(frozen=True)
class TestMqlValidatedOutput:
times: tuple[float, ...]
series_by_data_path: dict[str, tuple[float, ...]]
data_paths: tuple[str, ...]
def series(self, data_path: str) -> tuple[float, ...]:
if data_path not in self.series_by_data_path:
raise KeyError(data_path)
return self.series_by_data_path[data_path]
def validate_test_mql_output(
*,
times: tuple[float, ...] | list[float],
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
schema: TestMqlOutputSchema,
data_paths: tuple[str, ...] | list[str] | None = None,
allow_extra_paths: bool = False,
require_all_schema_paths: bool = False,
) -> TestMqlValidatedOutput:
validated_times = _validate_time_axis(times)
selected_paths = _select_paths(
series_by_data_path=series_by_data_path,
schema=schema,
data_paths=data_paths,
allow_extra_paths=allow_extra_paths,
require_all_schema_paths=require_all_schema_paths,
)
validated_series = {
data_path: _validate_series(
data_path=data_path,
values=series_by_data_path[data_path],
expected_count=len(validated_times),
)
for data_path in selected_paths
}
return TestMqlValidatedOutput(
times=validated_times,
series_by_data_path=validated_series,
data_paths=selected_paths,
)
def compare_validated_test_mql_output(
*,
times: tuple[float, ...] | list[float],
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
schema: TestMqlOutputSchema,
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
allow_extra_paths: bool = False,
require_all_schema_paths: bool = False,
relative_floor: float = 1.0e-12,
) -> TestMqlComparisonResult:
validated = validate_test_mql_output(
times=times,
series_by_data_path=series_by_data_path,
schema=schema,
data_paths=data_paths,
allow_extra_paths=allow_extra_paths,
require_all_schema_paths=require_all_schema_paths,
)
return compare_test_mql_series(
python_times=validated.times,
python_series_by_data_path=validated.series_by_data_path,
amesim_results=amesim_results,
data_paths=validated.data_paths,
relative_floor=relative_floor,
)
def _validate_time_axis(times: tuple[float, ...] | list[float]) -> tuple[float, ...]:
if not times:
raise TestMqlOutputValidationError("Python time axis is empty.")
validated = tuple(_finite_float("time", value) for value in times)
previous = validated[0]
for value in validated[1:]:
if value < previous:
raise TestMqlOutputValidationError("Python time axis must be monotonically increasing.")
previous = value
return validated
def _select_paths(
*,
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
schema: TestMqlOutputSchema,
data_paths: tuple[str, ...] | list[str] | None,
allow_extra_paths: bool,
require_all_schema_paths: bool,
) -> tuple[str, ...]:
schema_paths = set(schema.data_paths())
provided_paths = set(series_by_data_path)
if not allow_extra_paths:
extra_paths = sorted(provided_paths - schema_paths)
if extra_paths:
raise TestMqlOutputValidationError(
f"Python output contains Data_Path values outside test_mql schema: {extra_paths}"
)
if require_all_schema_paths:
missing_schema_paths = sorted(schema_paths - provided_paths)
if missing_schema_paths:
raise TestMqlOutputValidationError(
f"Python output is missing required test_mql schema Data_Path values: {missing_schema_paths}"
)
selected_paths = tuple(data_paths) if data_paths is not None else tuple(sorted(provided_paths & schema_paths))
if not selected_paths:
raise TestMqlOutputValidationError("no test_mql schema Data_Path values are available.")
unknown_selected = [data_path for data_path in selected_paths if data_path not in schema_paths]
if unknown_selected:
raise TestMqlOutputValidationError(
f"Requested Data_Path values are outside test_mql schema: {unknown_selected}"
)
missing_selected = [data_path for data_path in selected_paths if data_path not in series_by_data_path]
if missing_selected:
raise TestMqlOutputValidationError(
f"Python output is missing selected Data_Path values: {missing_selected}"
)
return selected_paths
def _validate_series(
*,
data_path: str,
values: tuple[float, ...] | list[float],
expected_count: int,
) -> tuple[float, ...]:
if len(values) != expected_count:
raise TestMqlOutputValidationError(
f"Python series length mismatch for {data_path!r}: "
f"{len(values)} values for {expected_count} time samples."
)
return tuple(_finite_float(data_path, value) for value in values)
def _finite_float(label: str, value: float) -> float:
try:
numeric_value = float(value)
except (TypeError, ValueError) as exc:
raise TestMqlOutputValidationError(f"{label!r} contains a non-numeric value: {value!r}") from exc
if not isfinite(numeric_value):
raise TestMqlOutputValidationError(f"{label!r} contains a non-finite value: {value!r}")
return numeric_value
@@ -1,111 +0,0 @@
from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults, AmesimVariable
from PythonModels.systems.test_mql import COMPONENT_SPECS, CONNECTION_SPECS
_UNIT_RE = re.compile(r"\[([^\]]+)\]\s*$")
@dataclass(frozen=True)
class TestMqlVariableBinding:
index: int
data_path: str
signal_name: str
owner_alias: str
owner_kind: str
submodel: str
label: str
units: str | None
saved: bool
@dataclass(frozen=True)
class TestMqlVariableCatalog:
variables: tuple[TestMqlVariableBinding, ...]
@property
def data_path_count(self) -> int:
return len(self.variables)
@property
def saved_data_path_count(self) -> int:
return sum(1 for variable in self.variables if variable.saved)
def by_data_path(self, data_path: str) -> TestMqlVariableBinding:
for variable in self.variables:
if variable.data_path == data_path:
return variable
raise KeyError(data_path)
def counts_by_submodel(self) -> dict[str, int]:
return dict(Counter(variable.submodel for variable in self.variables))
def counts_by_owner_kind(self) -> dict[str, int]:
return dict(Counter(variable.owner_kind for variable in self.variables))
def data_paths_for_owner(self, owner_alias: str) -> tuple[str, ...]:
return tuple(
variable.data_path
for variable in self.variables
if variable.owner_alias == owner_alias
)
def build_test_mql_variable_catalog(amesim_results: AmesimResults) -> TestMqlVariableCatalog:
owner_map = _build_owner_map()
saved_indices = set(amesim_results.saved_variable_indices)
bindings = []
for variable in amesim_results.variables:
if variable.data_path is None:
continue
signal_name, owner_alias = split_data_path(variable.data_path)
owner_kind, submodel = owner_map[owner_alias]
bindings.append(
TestMqlVariableBinding(
index=variable.index,
data_path=variable.data_path,
signal_name=signal_name,
owner_alias=owner_alias,
owner_kind=owner_kind,
submodel=submodel,
label=variable.label,
units=_extract_units(variable),
saved=variable.index in saved_indices,
)
)
return TestMqlVariableCatalog(variables=tuple(bindings))
def split_data_path(data_path: str) -> tuple[str, str]:
if "@" not in data_path:
raise ValueError(f"AMESim Data_Path does not contain an owner alias: {data_path!r}")
signal_name, owner_alias = data_path.rsplit("@", 1)
if not signal_name or not owner_alias:
raise ValueError(f"Invalid AMESim Data_Path: {data_path!r}")
return signal_name, owner_alias
def _build_owner_map() -> dict[str, tuple[str, str]]:
owner_map = {
str(spec["alias"]): ("component", str(spec["submodel"]))
for spec in COMPONENT_SPECS
}
owner_map.update(
{
str(spec["alias"]): ("connection", str(spec["submodel"]))
for spec in CONNECTION_SPECS
}
)
return owner_map
def _extract_units(variable: AmesimVariable) -> str | None:
match = _UNIT_RE.search(variable.label)
if match is None:
return None
return match.group(1)
-393
View File
@@ -1,393 +0,0 @@
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,31 +0,0 @@
Model: test_mql
Source archive: /home/huojr/projects/SystemSimulationApp/AmesimModels/test_mql.ame
Components: 117
Connections: 84
Continuous states in AMESim modelinfo: 132
Discrete states in AMESim modelinfo: 24
Global parameters:
- D1: 20
- D2: 20
- D3: 14
- P0: 153
- Pdq: 1
- V: 15
- cf: 0.45
Component submodels:
- F000: 16
- FORC: 2
- LMECHN1: 2
- LSTP00A: 8
- MECMAS21: 10
- P4NODE2: 8
- PN3NODE2: 8
- PNCH012: 8
- PNCH023: 4
- PNGD00: 1
- PNOR001: 8
- PNPL01: 16
- PNRP17: 8
- PNVO001: 8
- STEP0: 8
- UD00: 2
@@ -1,31 +0,0 @@
Model: test_mql
Source archive: /home/huojr/projects/SystemSimulationApp/AmesimModels/test_mql.ame
Components: 117
Connections: 84
Continuous states in AMESim modelinfo: 132
Discrete states in AMESim modelinfo: 24
Global parameters:
- D1: 20
- D2: 20
- D3: 14
- P0: 153
- Pdq: 1
- V: 15
- cf: 0.45
Component submodels:
- F000: 16
- FORC: 2
- LMECHN1: 2
- LSTP00A: 8
- MECMAS21: 10
- P4NODE2: 8
- PN3NODE2: 8
- PNCH012: 8
- PNCH023: 4
- PNGD00: 1
- PNOR001: 8
- PNPL01: 16
- PNRP17: 8
- PNVO001: 8
- STEP0: 8
- UD00: 2
@@ -1,31 +0,0 @@
Model: test_mql
Source archive: /home/huojr/projects/SystemSimulationApp/AmesimModels/test_mql.ame
Components: 117
Connections: 84
Continuous states in AMESim modelinfo: 132
Discrete states in AMESim modelinfo: 24
Global parameters:
- D1: 20
- D2: 20
- D3: 14
- P0: 153
- Pdq: 1
- V: 15
- cf: 0.45
Component submodels:
- F000: 16
- FORC: 2
- LMECHN1: 2
- LSTP00A: 8
- MECMAS21: 10
- P4NODE2: 8
- PN3NODE2: 8
- PNCH012: 8
- PNCH023: 4
- PNGD00: 1
- PNOR001: 8
- PNPL01: 16
- PNRP17: 8
- PNVO001: 8
- STEP0: 8
- UD00: 2
@@ -1,31 +0,0 @@
Model: test_mql
Source archive: /home/huojr/projects/SystemSimulationApp/AmesimModels/test_mql.ame
Components: 117
Connections: 84
Continuous states in AMESim modelinfo: 132
Discrete states in AMESim modelinfo: 24
Global parameters:
- D1: 20
- D2: 20
- D3: 14
- P0: 153
- Pdq: 1
- V: 15
- cf: 0.45
Component submodels:
- F000: 16
- FORC: 2
- LMECHN1: 2
- LSTP00A: 8
- MECMAS21: 10
- P4NODE2: 8
- PN3NODE2: 8
- PNCH012: 8
- PNCH023: 4
- PNGD00: 1
- PNOR001: 8
- PNPL01: 16
- PNRP17: 8
- PNVO001: 8
- STEP0: 8
- UD00: 2
@@ -1,31 +0,0 @@
Model: test_mql
Source archive: /home/huojr/projects/SystemSimulationApp/AmesimModels/test_mql.ame
Components: 117
Connections: 84
Continuous states in AMESim modelinfo: 132
Discrete states in AMESim modelinfo: 24
Global parameters:
- D1: 20
- D2: 20
- D3: 14
- P0: 153
- Pdq: 1
- V: 15
- cf: 0.45
Component submodels:
- F000: 16
- FORC: 2
- LMECHN1: 2
- LSTP00A: 8
- MECMAS21: 10
- P4NODE2: 8
- PN3NODE2: 8
- PNCH012: 8
- PNCH023: 4
- PNGD00: 1
- PNOR001: 8
- PNPL01: 16
- PNRP17: 8
- PNVO001: 8
- STEP0: 8
- UD00: 2
@@ -1,8 +0,0 @@
Model: test_mql
Mode: AMESim baseline passthrough
Samples: 1002
Output schema signals: 858
Compared signals: 858
Observation bindings: 114
Max absolute error: 0.0
Max relative error: 0.0
@@ -1,8 +0,0 @@
Model: test_mql
Mode: AMESim baseline passthrough
Samples: 1002
Output schema signals: 858
Compared signals: 858
Observation bindings: 114
Max absolute error: 0.0
Max relative error: 0.0
-74
View File
@@ -1,74 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from PythonModels.systems.test_mql import TestMqlRunConfig, TestMqlSystem
@dataclass(frozen=True)
class TestMqlPathConfig:
output_dir: Path | None = None
@dataclass(frozen=True)
class TestMqlExecutionConfig:
write_summary: bool = True
@dataclass(frozen=True)
class TestMqlScriptConfig:
run: TestMqlRunConfig = field(default_factory=TestMqlRunConfig)
paths: TestMqlPathConfig = field(default_factory=TestMqlPathConfig)
execution: TestMqlExecutionConfig = field(default_factory=TestMqlExecutionConfig)
def _default_output_dir() -> Path:
pythonmodels_root = Path(__file__).resolve().parents[1]
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
return pythonmodels_root / "runs" / timestamp
def format_test_mql_summary(system: TestMqlSystem) -> str:
snapshot = system.snapshot()
lines = [
"Model: test_mql",
f"Source archive: {system.archive_path}",
f"Components: {snapshot.component_count}",
f"Connections: {snapshot.connection_count}",
f"Continuous states in AMESim modelinfo: {snapshot.continuous_state_count}",
f"Discrete states in AMESim modelinfo: {snapshot.discrete_state_count}",
"Global parameters:",
]
for name, value in sorted(snapshot.global_parameters.items()):
lines.append(f" - {name}: {value}")
lines.append("Component submodels:")
for name, count in sorted(snapshot.submodel_counts.items()):
lines.append(f" - {name}: {count}")
return "\n".join(lines) + "\n"
def run_test_mql(config: TestMqlScriptConfig | None = None):
config = config or TestMqlScriptConfig()
system = TestMqlSystem()
result = system.simulate(config.run)
output_dir = config.paths.output_dir or _default_output_dir()
if config.execution.write_summary:
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "test_mql_model_summary.txt").write_text(
format_test_mql_summary(system),
encoding="utf-8",
)
return system, result, output_dir
def main() -> None:
system, result, output_dir = run_test_mql()
print(format_test_mql_summary(system), end="")
print(f"Samples: {len(result.t)}")
print(f"Output directory: {output_dir}")
if __name__ == "__main__":
main()
-219
View File
@@ -1,219 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from PythonModels.reporting import (
COMPARISON_KEYS,
PRIMARY_KEYS,
TestModelArtifacts,
export_testmodel_artifacts,
format_testmodel_run_report,
load_modelica_series,
write_testmodel_run_report,
)
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.systems.testmodel import (
InitializationDiagnostics,
TestModelConfig,
TestModelSystem,
)
from PythonModels.systems.testmodel_closure import TestModelSolveDiagnostics
@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(pythonmodels_root: Path) -> Path:
timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f")
return pythonmodels_root / "runs" / 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()
repo_root = Path(__file__).resolve().parents[2]
pythonmodels_root = Path(__file__).resolve().parents[1]
resolved_output_dir = (
output_dir
or run_config.paths.output_dir
or _default_run_output_dir(pythonmodels_root)
)
resolved_modelica_result_path = (
modelica_result_path
or run_config.paths.modelica_result_path
or repo_root / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
)
t_eval = tuple(run_config.sample_times())
return PreparedTestModelRun(
run_config=run_config,
repo_root=repo_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()
-2
View File
@@ -1,2 +0,0 @@
"""System assembly modules."""
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-151
View File
@@ -1,151 +0,0 @@
from __future__ import annotations
import ast
import operator
from dataclasses import dataclass
from math import isfinite
from typing import Any
from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from PythonModels.systems.test_mql import COMPONENT_SPECS, GLOBAL_PARAMETERS
_BINARY_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
}
_UNARY_OPERATORS = {
ast.UAdd: operator.pos,
ast.USub: operator.neg,
}
class TestMqlExpressionError(ValueError):
"""Raised when an AMESim parameter expression cannot be resolved safely."""
@dataclass(frozen=True)
class TestMqlResolvedParameter:
name: str
title: str
raw_value: str
units: str
value: float | None
@property
def is_numeric(self) -> bool:
return self.value is not None
@dataclass(frozen=True)
class TestMqlResolvedComponent:
alias: str
component_name: str
submodel: str
label: str
parameters: dict[str, TestMqlResolvedParameter]
def parameter_value(self, name: str) -> float:
parameter = self.parameters[name]
if parameter.value is None:
raise KeyError(f"Parameter {name!r} on {self.alias!r} is not numeric")
return parameter.value
@dataclass(frozen=True)
class TestMqlConfig:
raw_global_parameters: dict[str, str]
global_parameters: dict[str, float]
fluid: PengRobinsonFluid
components: tuple[TestMqlResolvedComponent, ...]
@classmethod
def from_amesim_specs(cls) -> "TestMqlConfig":
raw_globals = dict(GLOBAL_PARAMETERS)
numeric_globals = {
name: value
for name, raw in raw_globals.items()
if (value := resolve_numeric_expression(raw, {})) is not None
}
components = tuple(
_resolve_component(spec, numeric_globals)
for spec in COMPONENT_SPECS
)
return cls(
raw_global_parameters=raw_globals,
global_parameters=numeric_globals,
fluid=HELIUM_PR,
components=components,
)
def component(self, alias: str) -> TestMqlResolvedComponent:
for component in self.components:
if component.alias == alias:
return component
raise KeyError(alias)
def components_by_submodel(self, submodel: str) -> tuple[TestMqlResolvedComponent, ...]:
return tuple(component for component in self.components if component.submodel == submodel)
def _resolve_component(
spec: dict[str, Any],
variables: dict[str, float],
) -> TestMqlResolvedComponent:
parameters = {}
for parameter in spec.get("parameters", []):
name = str(parameter["name"])
raw_value = str(parameter["value"])
parameters[name] = TestMqlResolvedParameter(
name=name,
title=str(parameter["title"]),
raw_value=raw_value,
units=str(parameter["units"]),
value=resolve_numeric_expression(raw_value, variables),
)
return TestMqlResolvedComponent(
alias=str(spec["alias"]),
component_name=str(spec["component_name"]),
submodel=str(spec["submodel"]),
label=str(spec["label"]),
parameters=parameters,
)
def resolve_numeric_expression(
expression: str,
variables: dict[str, float],
) -> float | None:
expression = expression.strip()
if not expression:
return None
normalized = expression.replace("^", "**")
try:
parsed = ast.parse(normalized, mode="eval")
value = float(_eval_node(parsed.body, variables))
except (SyntaxError, TestMqlExpressionError, ValueError, TypeError, ZeroDivisionError):
return None
return value if isfinite(value) else None
def _eval_node(node: ast.AST, variables: dict[str, float]) -> float:
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.Name):
if node.id not in variables:
raise TestMqlExpressionError(f"Unknown variable: {node.id}")
return float(variables[node.id])
if isinstance(node, ast.BinOp):
operator_type = type(node.op)
if operator_type not in _BINARY_OPERATORS:
raise TestMqlExpressionError(f"Unsupported binary operator: {operator_type}")
return float(_BINARY_OPERATORS[operator_type](_eval_node(node.left, variables), _eval_node(node.right, variables)))
if isinstance(node, ast.UnaryOp):
operator_type = type(node.op)
if operator_type not in _UNARY_OPERATORS:
raise TestMqlExpressionError(f"Unsupported unary operator: {operator_type}")
return float(_UNARY_OPERATORS[operator_type](_eval_node(node.operand, variables)))
raise TestMqlExpressionError(f"Unsupported expression node: {type(node)}")
@@ -1,451 +0,0 @@
from __future__ import annotations
import re
import tarfile
from dataclasses import dataclass
from pathlib import Path
from PythonModels.systems.test_mql import CONNECTION_SPECS, GLOBAL_PARAMETERS
from PythonModels.systems.test_mql_config import resolve_numeric_expression
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
@dataclass(frozen=True)
class TestMqlPnl0001Spec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
polytropic_constant: float
heat_transfer_coefficient: float
external_temperature_k: float
gas_type_index: int
mode: int
initial_temperature_k: float
initial_gauge_pressure_pa: float
@property
def initial_absolute_pressure_pa(self) -> float:
return self.initial_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA
@dataclass(frozen=True)
class TestMqlPnl0002Spec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
polytropic_constant: float
heat_transfer_coefficient: float
external_temperature_k: float
gas_type_index: int
mode: int
initial_center_temperature_k: float
initial_center_gauge_pressure_pa: float
@property
def initial_center_absolute_pressure_pa(self) -> float:
return self.initial_center_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA
@dataclass(frozen=True)
class TestMqlPnl0003Spec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
polytropic_constant: float
heat_transfer_coefficient: float
external_temperature_k: float
gas_type_index: int
mode: int
initial_temperature_1_k: float
initial_gauge_pressure_1_pa: float
initial_temperature_2_k: float
initial_gauge_pressure_2_pa: float
@property
def initial_absolute_pressure_1_pa(self) -> float:
return self.initial_gauge_pressure_1_pa + AMESIM_REFERENCE_PRESSURE_PA
@property
def initial_absolute_pressure_2_pa(self) -> float:
return self.initial_gauge_pressure_2_pa + AMESIM_REFERENCE_PRESSURE_PA
@dataclass(frozen=True)
class TestMqlPnl00rSpec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
gas_type_index: int
def load_test_mql_pnl0001_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl0001Spec, ...]:
"""Load resolved PNL0001 geometry and initial states from the AMESim source."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL0001"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL0001":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL0001 line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
state_values = _evar_values(block)
specs.append(
TestMqlPnl0001Spec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
polytropic_constant=_required_numeric(
alias, "k", real_parameters, numeric_globals
),
heat_transfer_coefficient=_required_numeric(
alias, "kth", real_parameters, numeric_globals
),
external_temperature_k=_required_numeric(
alias, "extemp", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
mode=int(
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
),
initial_temperature_k=_required_numeric(
alias, "t2", state_values, numeric_globals
),
initial_gauge_pressure_pa=_required_numeric(
alias, "p2", state_values, numeric_globals
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL0001 parameter blocks: {missing}")
return tuple(specs)
def load_test_mql_pnl0002_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl0002Spec, ...]:
"""Load resolved PNL0002 geometry and center compliance initial state."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL0002"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL0002":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL0002 line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
state_values = _ivar_values(block)
specs.append(
TestMqlPnl0002Spec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
polytropic_constant=_required_numeric(
alias, "k", real_parameters, numeric_globals
),
heat_transfer_coefficient=_required_numeric(
alias, "kth", real_parameters, numeric_globals
),
external_temperature_k=_required_numeric(
alias, "extemp", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
mode=int(
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
),
initial_center_temperature_k=_required_numeric(
alias, "tctr", state_values, numeric_globals
),
initial_center_gauge_pressure_pa=_required_numeric(
alias, "pctr", state_values, numeric_globals
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL0002 parameter blocks: {missing}")
return tuple(specs)
def load_test_mql_pnl0003_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl0003Spec, ...]:
"""Load resolved PNL0003 geometry and both compliance initial states."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL0003"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL0003":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL0003 line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
state_values = _evar_values(block)
specs.append(
TestMqlPnl0003Spec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
polytropic_constant=_required_numeric(
alias, "k", real_parameters, numeric_globals
),
heat_transfer_coefficient=_required_numeric(
alias, "kth", real_parameters, numeric_globals
),
external_temperature_k=_required_numeric(
alias, "extemp", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
mode=int(
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
),
initial_temperature_1_k=_required_numeric(
alias, "t1", state_values, numeric_globals
),
initial_gauge_pressure_1_pa=_required_numeric(
alias, "p1", state_values, numeric_globals
),
initial_temperature_2_k=_required_numeric(
alias, "t2", state_values, numeric_globals
),
initial_gauge_pressure_2_pa=_required_numeric(
alias, "p2", state_values, numeric_globals
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL0003 parameter blocks: {missing}")
return tuple(specs)
def load_test_mql_pnl00r_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl00rSpec, ...]:
"""Load resolved PNL00R geometry from the AMESim source."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL00R"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL00R":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL00R line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
specs.append(
TestMqlPnl00rSpec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL00R parameter blocks: {missing}")
return tuple(specs)
def _parameter_expressions(block: str, tag_name: str) -> dict[str, str]:
parameters = {}
for parameter_block in re.findall(
rf"<{tag_name}>.*?</{tag_name}>",
block,
flags=re.DOTALL,
):
parameters[_required_text(parameter_block, "VARNAME")] = _required_text(
parameter_block,
"VALUE",
)
return parameters
def _ivar_values(block: str) -> dict[str, str]:
values = {}
for variable_block in re.findall(r"<IVAR>.*?</IVAR>", block, flags=re.DOTALL):
value = _optional_text(variable_block, "VALUE")
if value:
values[_required_text(variable_block, "VARNAME")] = value
return values
def _evar_values(block: str) -> dict[str, str]:
values = {}
for variable_block in re.findall(r"<EVAR>.*?</EVAR>", block, flags=re.DOTALL):
value = _optional_text(variable_block, "VALUE")
if value:
values[_required_text(variable_block, "VARNAME")] = value
return values
def _required_numeric(
alias: str,
name: str,
expressions: dict[str, str],
variables: dict[str, float],
) -> float:
if name not in expressions:
raise ValueError(f"Missing {name!r} on line {alias!r}")
value = resolve_numeric_expression(expressions[name], variables)
if value is None:
raise ValueError(
f"Cannot resolve {name!r}={expressions[name]!r} on line {alias!r}"
)
return value
def _required_text(block: str, tag_name: str) -> str:
value = _optional_text(block, tag_name)
if value is None:
raise ValueError(f"Missing AMESim circuit element: {tag_name}")
return value
def _optional_text(block: str, tag_name: str) -> str | None:
match = re.search(rf"<{tag_name}>(.*?)</{tag_name}>", block, flags=re.DOTALL)
return match.group(1).strip() if match is not None else None
-544
View File
@@ -1,544 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.components.amesim_mechanical import (
AmesimElasticEndstop,
AmesimMassFrictionEndstops,
AmesimPistonGeometry,
circular_area,
mm_to_m,
)
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
MM_TO_M = 1.0e-3
N_PER_MM_TO_N_PER_M = 1.0e3
N_PER_MM_PER_S_TO_N_PER_M_PER_S = 1.0e3
@dataclass(frozen=True)
class TestMqlPistonSpec:
alias: str
piston_diameter_m: float
rod_diameter_m: float
zero_displacement_m: float
piston_area_m2: float
rod_area_m2: float
annulus_area_m2: float
data_paths: tuple[str, ...]
def geometry(self) -> AmesimPistonGeometry:
return AmesimPistonGeometry(
piston_diameter_m=self.piston_diameter_m,
rod_diameter_m=self.rod_diameter_m,
zero_length_m=self.zero_displacement_m,
)
@dataclass(frozen=True)
class TestMqlMassEndstopSpec:
alias: str
mass_kg: float
xmin_m: float
xmax_m: float
min_stiffness_n_per_m: float
max_stiffness_n_per_m: float
min_damping_n_per_m_per_s: float
max_damping_n_per_m_per_s: float
min_penetration_m: float
max_penetration_m: float
stiction_force_n: float
coulomb_friction_n: float
viscous_friction_n_per_m_per_s: float
windage_n_per_m2_per_s2: float
stick_velocity_threshold_m_s: float
reset_velocity_threshold_m_s: float
rest_coeff: float
stribeck_constant_m_s: float
use_friction: bool
stop_type: int
initial_velocity_m_s: float
initial_displacement_m: float
data_paths: tuple[str, ...]
def endstop(self) -> AmesimMassFrictionEndstops:
return AmesimMassFrictionEndstops(
mass_kg=self.mass_kg,
lower_limit_m=self.xmin_m,
upper_limit_m=self.xmax_m,
lower_stiffness_n_per_m=self.min_stiffness_n_per_m,
upper_stiffness_n_per_m=self.max_stiffness_n_per_m,
lower_damping_n_per_m_per_s=self.min_damping_n_per_m_per_s,
upper_damping_n_per_m_per_s=self.max_damping_n_per_m_per_s,
viscous_friction_n_per_m_per_s=self.viscous_friction_n_per_m_per_s,
coulomb_friction_n=self.coulomb_friction_n,
stiction_force_n=self.stiction_force_n,
windage_n_per_m2_per_s2=self.windage_n_per_m2_per_s2,
)
@dataclass(frozen=True)
class TestMqlElasticEndstopSpec:
alias: str
gap_m: float
contact_stiffness_n_per_m: float
contact_damping_n_per_m_per_s: float
spring_diameter_m: float
wire_diameter_m: float
data_paths: tuple[str, ...]
def endstop(self) -> AmesimElasticEndstop:
return AmesimElasticEndstop(
contact_stiffness_n_per_m=self.contact_stiffness_n_per_m,
contact_damping_n_per_m_per_s=self.contact_damping_n_per_m_per_s,
gap0_m=self.gap_m,
)
@dataclass(frozen=True)
class TestMqlMechanicalNodeSpec:
alias: str
port_count: int
sum_mode: int
data_paths: tuple[str, ...]
@dataclass(frozen=True)
class TestMqlPiecewiseLinearSignalSpec:
alias: str
t_start_s: float
starts: tuple[float, ...]
ends: tuple[float, ...]
durations_s: tuple[float, ...]
stage_count: int
is_cyclic: bool
data_paths: tuple[str, ...]
def output_at(self, time_s: float) -> float:
if self.stage_count <= 0:
return 0.0
elapsed = max(time_s - self.t_start_s, 0.0)
active_durations = self.durations_s[: self.stage_count]
total_duration = sum(active_durations)
if self.is_cyclic and total_duration > 0.0:
elapsed = elapsed % total_duration
stage_start_time = 0.0
for index, duration in enumerate(active_durations):
stage_end_time = stage_start_time + duration
if elapsed < stage_end_time or index == self.stage_count - 1:
if duration <= 0.0:
return self.ends[index]
fraction = (elapsed - stage_start_time) / duration
return self.starts[index] + fraction * (self.ends[index] - self.starts[index])
stage_start_time = stage_end_time
return self.ends[self.stage_count - 1]
@dataclass(frozen=True)
class TestMqlForceConnectorSpec:
alias: str
signal_alias: str
target_mass_alias: str
data_paths: tuple[str, ...]
def force_at(
self,
time_s: float,
signals: dict[str, TestMqlPiecewiseLinearSignalSpec],
) -> float:
return signals[self.signal_alias].output_at(time_s)
@dataclass(frozen=True)
class TestMqlMechanicalAssembly:
pistons: dict[str, TestMqlPistonSpec]
masses: dict[str, TestMqlMassEndstopSpec]
elastic_endstops: dict[str, TestMqlElasticEndstopSpec]
mechanical_nodes: dict[str, TestMqlMechanicalNodeSpec]
piecewise_signals: dict[str, TestMqlPiecewiseLinearSignalSpec]
force_connectors: dict[str, TestMqlForceConnectorSpec]
zero_force_sources: tuple[str, ...]
@property
def component_count(self) -> int:
return (
len(self.pistons)
+ len(self.masses)
+ len(self.elastic_endstops)
+ len(self.mechanical_nodes)
+ len(self.piecewise_signals)
+ len(self.force_connectors)
+ len(self.zero_force_sources)
)
@property
def aliases(self) -> tuple[str, ...]:
return tuple(
[
*self.pistons,
*self.masses,
*self.elastic_endstops,
*self.mechanical_nodes,
*self.piecewise_signals,
*self.force_connectors,
*self.zero_force_sources,
]
)
@dataclass(frozen=True)
class TestMqlMechanicalMassState:
alias: str
velocity_m_s: float
displacement_m: float
def as_vector(self) -> list[float]:
return [self.velocity_m_s, self.displacement_m]
@dataclass(frozen=True)
class TestMqlMechanicalNodeKinematics:
alias: str
velocities_m_s: dict[int, float]
displacements_m: dict[int, float]
@dataclass(frozen=True)
class TestMqlPistonKinematics:
alias: str
port_2_velocity_m_s: float
port_2_displacement_m: float
port_3_velocity_m_s: float
port_3_displacement_m: float
@dataclass(frozen=True)
class TestMqlMechanicalMassSnapshot:
states: tuple[TestMqlMechanicalMassState, ...]
node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics]
piston_kinematics_by_alias: dict[str, TestMqlPistonKinematics]
@property
def state_count(self) -> int:
return 2 * len(self.states)
class TestMqlMechanicalMassClosure:
def __init__(self, assembly: TestMqlMechanicalAssembly) -> None:
self.assembly = assembly
self.mass_aliases = tuple(assembly.masses)
def initial_state_vector(self) -> list[float]:
state: list[float] = []
for alias in self.mass_aliases:
spec = self.assembly.masses[alias]
state.extend([spec.initial_velocity_m_s, spec.initial_displacement_m])
return state
def snapshot(self, state_vector: list[float] | None = None) -> TestMqlMechanicalMassSnapshot:
values = self.initial_state_vector() if state_vector is None else list(state_vector)
if len(values) != 2 * len(self.mass_aliases):
raise ValueError("mechanical mass state vector requires two values per mass")
states = tuple(
TestMqlMechanicalMassState(
alias=alias,
velocity_m_s=values[2 * index],
displacement_m=values[2 * index + 1],
)
for index, alias in enumerate(self.mass_aliases)
)
node_kinematics = self._node_kinematics_by_alias(states)
return TestMqlMechanicalMassSnapshot(
states=states,
node_kinematics_by_alias=node_kinematics,
piston_kinematics_by_alias=self._piston_kinematics_by_alias(
states,
node_kinematics,
),
)
def _node_kinematics_by_alias(
self,
states: tuple[TestMqlMechanicalMassState, ...],
) -> dict[str, TestMqlMechanicalNodeKinematics]:
state_by_alias = {state.alias: state for state in states}
front = state_by_alias["mass_friction_endstops_18"]
rear = state_by_alias["mass_friction_endstops_19"]
return {
"dynamic_mechanical_node_alternative_2": TestMqlMechanicalNodeKinematics(
alias="dynamic_mechanical_node_alternative_2",
velocities_m_s={port: -front.velocity_m_s for port in range(1, 9)},
displacements_m={port: -front.displacement_m for port in range(1, 9)},
),
"dynamic_mechanical_node_alternative_3": TestMqlMechanicalNodeKinematics(
alias="dynamic_mechanical_node_alternative_3",
velocities_m_s={port: rear.velocity_m_s for port in range(1, 9)},
displacements_m={port: rear.displacement_m for port in range(1, 9)},
),
}
def _piston_kinematics_by_alias(
self,
states: tuple[TestMqlMechanicalMassState, ...],
node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics],
) -> dict[str, TestMqlPistonKinematics]:
state_by_alias = {state.alias: state for state in states}
rear_node = node_kinematics_by_alias["dynamic_mechanical_node_alternative_3"]
piston_bindings = (
("pn_brp2_8", "mass_friction_endstops_10", 8),
("pn_brp2_9", "mass_friction_endstops_11", 7),
("pn_brp2_10", "mass_friction_endstops_12", 6),
("pn_brp2_11", "mass_friction_endstops_13", 5),
("pn_brp2_12", "mass_friction_endstops_14", 4),
("pn_brp2_13", "mass_friction_endstops_15", 3),
("pn_brp2_14", "mass_friction_endstops_16", 2),
("pn_brp2_15", "mass_friction_endstops_17", 1),
)
return {
piston_alias: TestMqlPistonKinematics(
alias=piston_alias,
port_2_velocity_m_s=state_by_alias[mass_alias].velocity_m_s,
port_2_displacement_m=state_by_alias[mass_alias].displacement_m,
port_3_velocity_m_s=rear_node.velocities_m_s[rear_node_port],
port_3_displacement_m=rear_node.displacements_m[rear_node_port],
)
for piston_alias, mass_alias, rear_node_port in piston_bindings
}
def rhs(
self,
state_vector: list[float],
*,
force_by_mass_alias: dict[str, float] | None = None,
constrained_mass_aliases: set[str] | None = None,
) -> list[float]:
snapshot = self.snapshot(state_vector)
force_by_mass_alias = force_by_mass_alias or {}
constrained_mass_aliases = constrained_mass_aliases or set()
derivatives: list[float] = []
for state in snapshot.states:
spec = self.assembly.masses[state.alias]
mass = spec.endstop()
applied_force = force_by_mass_alias.get(state.alias, 0.0)
acceleration, velocity = mass.derivatives(
velocity_m_s=state.velocity_m_s,
displacement_m=state.displacement_m,
port_1_force_n=applied_force,
)
if state.alias in constrained_mass_aliases and _limit_constraint_holds(
spec,
state,
applied_force,
):
acceleration = 0.0
velocity = 0.0
derivatives.extend([acceleration, velocity])
return derivatives
def build_test_mql_mechanical_assembly(
config: TestMqlConfig | None = None,
amesim_results: AmesimResults | None = None,
variable_catalog: TestMqlVariableCatalog | None = None,
) -> TestMqlMechanicalAssembly:
config = config or TestMqlConfig.from_amesim_specs()
if variable_catalog is None and amesim_results is not None:
variable_catalog = build_test_mql_variable_catalog(amesim_results)
pistons = {
component.alias: _build_piston(component, variable_catalog)
for component in config.components_by_submodel("PNRP17")
}
masses = {
component.alias: _build_mass(component, variable_catalog, amesim_results)
for component in config.components_by_submodel("MECMAS21")
}
elastic_endstops = {
component.alias: _build_elastic_endstop(component, variable_catalog)
for component in config.components_by_submodel("LSTP00A")
}
mechanical_nodes = {
component.alias: _build_mechanical_node(component, variable_catalog)
for component in config.components_by_submodel("LMECHN1")
}
piecewise_signals = {
component.alias: _build_piecewise_signal(component, variable_catalog)
for component in config.components_by_submodel("UD00")
}
force_connectors = {
component.alias: _build_force_connector(component, variable_catalog)
for component in config.components_by_submodel("FORC")
}
zero_force_sources = tuple(component.alias for component in config.components_by_submodel("F000"))
return TestMqlMechanicalAssembly(
pistons=pistons,
masses=masses,
elastic_endstops=elastic_endstops,
mechanical_nodes=mechanical_nodes,
piecewise_signals=piecewise_signals,
force_connectors=force_connectors,
zero_force_sources=zero_force_sources,
)
def _build_piston(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlPistonSpec:
geometry = AmesimPistonGeometry(
piston_diameter_m=mm_to_m(component.parameter_value("dp")),
rod_diameter_m=mm_to_m(component.parameter_value("dr")),
zero_length_m=mm_to_m(component.parameter_value("x0")),
)
return TestMqlPistonSpec(
alias=component.alias,
piston_diameter_m=geometry.piston_diameter_m,
rod_diameter_m=geometry.rod_diameter_m,
zero_displacement_m=geometry.zero_length_m,
piston_area_m2=geometry.piston_area_m2,
rod_area_m2=geometry.rod_area_m2,
annulus_area_m2=geometry.annulus_area_m2,
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_mass(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
amesim_results: AmesimResults | None,
) -> TestMqlMassEndstopSpec:
return TestMqlMassEndstopSpec(
alias=component.alias,
mass_kg=component.parameter_value("mass"),
xmin_m=component.parameter_value("xmin"),
xmax_m=component.parameter_value("xmax"),
min_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmin")),
max_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmax")),
min_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmin")),
max_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmax")),
min_penetration_m=mm_to_m(component.parameter_value("Pdmin")),
max_penetration_m=mm_to_m(component.parameter_value("Pdmax")),
stiction_force_n=component.parameter_value("fstick"),
coulomb_friction_n=component.parameter_value("fcoul"),
viscous_friction_n_per_m_per_s=component.parameter_value("rvisc"),
windage_n_per_m2_per_s2=component.parameter_value("wind"),
stick_velocity_threshold_m_s=component.parameter_value("dvel"),
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
rest_coeff=component.parameter_value("restcoeff"),
stribeck_constant_m_s=component.parameter_value("astrib"),
use_friction=bool(int(component.parameter_value("useFriction"))),
stop_type=int(component.parameter_value("stoptype")),
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_elastic_endstop(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlElasticEndstopSpec:
return TestMqlElasticEndstopSpec(
alias=component.alias,
gap_m=mm_to_m(component.parameter_value("gap0")),
contact_stiffness_n_per_m=component.parameter_value("kcont"),
contact_damping_n_per_m_per_s=component.parameter_value("rcont"),
spring_diameter_m=mm_to_m(component.parameter_value("sdiam")),
wire_diameter_m=mm_to_m(component.parameter_value("wdiam")),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_mechanical_node(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlMechanicalNodeSpec:
return TestMqlMechanicalNodeSpec(
alias=component.alias,
port_count=int(component.parameter_value("v1")),
sum_mode=int(component.parameter_value("sum")),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _limit_constraint_holds(
spec: TestMqlMassEndstopSpec,
state: TestMqlMechanicalMassState,
applied_force_n: float,
) -> bool:
if abs(state.velocity_m_s) > spec.stick_velocity_threshold_m_s:
return False
at_lower_limit = state.displacement_m <= spec.xmin_m + spec.min_penetration_m
at_upper_limit = state.displacement_m >= spec.xmax_m - spec.max_penetration_m
return (at_lower_limit and applied_force_n <= 0.0) or (
at_upper_limit and applied_force_n >= 0.0
)
def _build_piecewise_signal(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlPiecewiseLinearSignalSpec:
starts = tuple(component.parameter_value(f"start{index}") for index in range(1, 9))
ends = tuple(component.parameter_value(f"end{index}") for index in range(1, 9))
durations = tuple(component.parameter_value(f"t{index}") for index in range(1, 9))
return TestMqlPiecewiseLinearSignalSpec(
alias=component.alias,
t_start_s=component.parameter_value("tstart"),
starts=starts,
ends=ends,
durations_s=durations,
stage_count=int(component.parameter_value("nstages")),
is_cyclic=bool(int(component.parameter_value("iscyclic"))),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_force_connector(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlForceConnectorSpec:
signal_alias_by_force_connector = {
"forcecon_1": "piecewiselinear",
"forcecon_2": "piecewiselinear_1",
}
target_mass_by_force_connector = {
"forcecon_1": "mass_friction_endstops_19",
"forcecon_2": "mass_friction_endstops_18",
}
return TestMqlForceConnectorSpec(
alias=component.alias,
signal_alias=signal_alias_by_force_connector[component.alias],
target_mass_alias=target_mass_by_force_connector[component.alias],
data_paths=_data_paths(variable_catalog, component.alias),
)
def n_per_mm_to_n_per_m(value: float) -> float:
return value * N_PER_MM_TO_N_PER_M
def n_per_mm_per_s_to_n_per_m_per_s(value: float) -> float:
return value * N_PER_MM_PER_S_TO_N_PER_M_PER_S
def _initial_value(amesim_results: AmesimResults | None, data_path: str) -> float:
if amesim_results is None:
return 0.0
return float(amesim_results.series(data_path)[0])
def _data_paths(
variable_catalog: TestMqlVariableCatalog | None,
alias: str,
) -> tuple[str, ...]:
if variable_catalog is None:
return ()
return variable_catalog.data_paths_for_owner(alias)
-215
View File
@@ -1,215 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.systems.test_mql import COMPONENT_SPECS
@dataclass(frozen=True)
class TestMqlPneumaticNode3Balance:
temperature_k: float
pressure_pa: float
port_1_enthalpy_flow_w: float
port_1_mass_flow_g_s: float
port_1_volume_derivative_l_min: float
port_1_volume_cm3: float
port_2_enthalpy_flow_w: float
port_2_mass_flow_g_s: float
port_2_volume_derivative_l_min: float
port_2_volume_cm3: float
port_3_enthalpy_flow_w: float
port_3_mass_flow_g_s: float
port_3_volume_derivative_l_min: float
port_3_volume_cm3: float
@dataclass(frozen=True)
class TestMqlPneumaticNode3:
"""Exact algebraic contract of AMESim ``PN3NODE2``.
Pressure and temperature are fixed by port 2 and duplicated to ports 1 and
3. Flow and volume signals at port 2 are the sums of ports 1 and 3, matching
the ``EXPRESS2`` equations stored in ``test_mql_.cir``.
"""
alias: str
def balance(
self,
*,
port_2_temperature_k: float,
port_2_pressure_pa: float,
port_1_enthalpy_flow_w: float,
port_1_mass_flow_g_s: float,
port_3_enthalpy_flow_w: float,
port_3_mass_flow_g_s: float,
port_1_volume_derivative_l_min: float = 0.0,
port_1_volume_cm3: float = 0.0,
port_3_volume_derivative_l_min: float = 0.0,
port_3_volume_cm3: float = 0.0,
) -> TestMqlPneumaticNode3Balance:
if port_2_temperature_k <= 0.0:
raise ValueError("port_2_temperature_k must be positive")
if port_2_pressure_pa <= 0.0:
raise ValueError("port_2_pressure_pa must be positive")
return TestMqlPneumaticNode3Balance(
temperature_k=port_2_temperature_k,
pressure_pa=port_2_pressure_pa,
port_1_enthalpy_flow_w=port_1_enthalpy_flow_w,
port_1_mass_flow_g_s=port_1_mass_flow_g_s,
port_1_volume_derivative_l_min=port_1_volume_derivative_l_min,
port_1_volume_cm3=port_1_volume_cm3,
port_2_enthalpy_flow_w=(
port_1_enthalpy_flow_w + port_3_enthalpy_flow_w
),
port_2_mass_flow_g_s=port_1_mass_flow_g_s + port_3_mass_flow_g_s,
port_2_volume_derivative_l_min=(
port_1_volume_derivative_l_min + port_3_volume_derivative_l_min
),
port_2_volume_cm3=port_1_volume_cm3 + port_3_volume_cm3,
port_3_enthalpy_flow_w=port_3_enthalpy_flow_w,
port_3_mass_flow_g_s=port_3_mass_flow_g_s,
port_3_volume_derivative_l_min=port_3_volume_derivative_l_min,
port_3_volume_cm3=port_3_volume_cm3,
)
@dataclass(frozen=True)
class TestMqlPneumaticNode4Balance:
temperature_k: float
pressure_pa: float
port_1_enthalpy_flow_w: float
port_1_mass_flow_g_s: float
port_1_volume_derivative_l_min: float
port_1_volume_cm3: float
port_2_enthalpy_flow_w: float
port_2_mass_flow_g_s: float
port_2_volume_derivative_l_min: float
port_2_volume_cm3: float
port_3_enthalpy_flow_w: float
port_3_mass_flow_g_s: float
port_3_volume_derivative_l_min: float
port_3_volume_cm3: float
port_4_enthalpy_flow_w: float
port_4_mass_flow_g_s: float
port_4_volume_derivative_l_min: float
port_4_volume_cm3: float
@dataclass(frozen=True)
class TestMqlPneumaticNode4:
"""Exact algebraic contract of AMESim ``P4NODE2``.
Pressure and temperature are fixed by port 2 and duplicated to ports 1, 3,
and 4. Flow and volume signals at port 2 are the sums of ports 1, 3, and
4, matching the saved AMESim variables for ``pnnode4_*`` instances.
"""
alias: str
def balance(
self,
*,
port_2_temperature_k: float,
port_2_pressure_pa: float,
port_1_enthalpy_flow_w: float,
port_1_mass_flow_g_s: float,
port_3_enthalpy_flow_w: float,
port_3_mass_flow_g_s: float,
port_4_enthalpy_flow_w: float,
port_4_mass_flow_g_s: float,
port_1_volume_derivative_l_min: float = 0.0,
port_1_volume_cm3: float = 0.0,
port_3_volume_derivative_l_min: float = 0.0,
port_3_volume_cm3: float = 0.0,
port_4_volume_derivative_l_min: float = 0.0,
port_4_volume_cm3: float = 0.0,
) -> TestMqlPneumaticNode4Balance:
if port_2_temperature_k <= 0.0:
raise ValueError("port_2_temperature_k must be positive")
if port_2_pressure_pa <= 0.0:
raise ValueError("port_2_pressure_pa must be positive")
return TestMqlPneumaticNode4Balance(
temperature_k=port_2_temperature_k,
pressure_pa=port_2_pressure_pa,
port_1_enthalpy_flow_w=port_1_enthalpy_flow_w,
port_1_mass_flow_g_s=port_1_mass_flow_g_s,
port_1_volume_derivative_l_min=port_1_volume_derivative_l_min,
port_1_volume_cm3=port_1_volume_cm3,
port_2_enthalpy_flow_w=(
port_1_enthalpy_flow_w
+ port_3_enthalpy_flow_w
+ port_4_enthalpy_flow_w
),
port_2_mass_flow_g_s=(
port_1_mass_flow_g_s
+ port_3_mass_flow_g_s
+ port_4_mass_flow_g_s
),
port_2_volume_derivative_l_min=(
port_1_volume_derivative_l_min
+ port_3_volume_derivative_l_min
+ port_4_volume_derivative_l_min
),
port_2_volume_cm3=(
port_1_volume_cm3 + port_3_volume_cm3 + port_4_volume_cm3
),
port_3_enthalpy_flow_w=port_3_enthalpy_flow_w,
port_3_mass_flow_g_s=port_3_mass_flow_g_s,
port_3_volume_derivative_l_min=port_3_volume_derivative_l_min,
port_3_volume_cm3=port_3_volume_cm3,
port_4_enthalpy_flow_w=port_4_enthalpy_flow_w,
port_4_mass_flow_g_s=port_4_mass_flow_g_s,
port_4_volume_derivative_l_min=port_4_volume_derivative_l_min,
port_4_volume_cm3=port_4_volume_cm3,
)
@dataclass(frozen=True)
class TestMqlP4NodePortConnection:
line_alias: str
local_node_alias: str
local_port: str
remote_node_alias: str
remote_port: str
@dataclass(frozen=True)
class TestMqlP4NodePrimaryConnection:
line_alias: str
node_alias: str
node_port: str
chamber_alias: str
chamber_port: str
@dataclass(frozen=True)
class TestMqlP4NodeOrificeConnection:
orifice_alias: str
node_alias: str
node_port: str
direct_line_alias: str
@dataclass(frozen=True)
class TestMqlP4NodeNeighborhood:
node_alias: str
primary: TestMqlP4NodePrimaryConnection
port_1: TestMqlP4NodePortConnection
port_3: TestMqlP4NodePortConnection
port_4: TestMqlP4NodeOrificeConnection
def build_test_mql_node3_assembly() -> dict[str, TestMqlPneumaticNode3]:
return {
str(spec["alias"]): TestMqlPneumaticNode3(alias=str(spec["alias"]))
for spec in COMPONENT_SPECS
if spec["submodel"] == "PN3NODE2"
}
def build_test_mql_node4_assembly() -> dict[str, TestMqlPneumaticNode4]:
return {
str(spec["alias"]): TestMqlPneumaticNode4(alias=str(spec["alias"]))
for spec in COMPONENT_SPECS
if spec["submodel"] == "P4NODE2"
}
-273
View File
@@ -1,273 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.components.amesim_pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
AmesimVariablePneumaticVolume,
)
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
BAR_TO_PA = 1.0e5
DEFAULT_TEST_MQL_TEMPERATURE_K = 293.15
DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR = 1.0
# Matched to PNVO001 event-window mass flow near the 0.04 s opening event.
TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER = 0.99805
@dataclass(frozen=True)
class TestMqlStepSignalSpec:
alias: str
initial_output: float
final_output: float
step_time_s: float
transition_duration_s: float
transition_type: int
def output_at(self, time_s: float) -> float:
if self.transition_type != 1:
raise ValueError(
f"unsupported STEP0 transition type {self.transition_type} on {self.alias}"
)
return self.initial_output if time_s < self.step_time_s else self.final_output
@dataclass(frozen=True)
class TestMqlVariableOrificeControl:
orifice_alias: str
step: TestMqlStepSignalSpec
def opening_at(self, time_s: float) -> float:
return self.step.output_at(time_s)
@dataclass(frozen=True)
class TestMqlPneumaticAssembly:
fixed_chambers: dict[str, AmesimPneumaticVolume]
variable_chambers: dict[str, AmesimVariablePneumaticVolume]
fixed_orifices: dict[str, AmesimPneumaticOrifice]
variable_orifices: dict[str, AmesimPneumaticOrifice]
variable_orifice_controls: dict[str, TestMqlVariableOrificeControl]
fixed_initial_absolute_pressure_pa: float
variable_initial_absolute_pressure_pa: float
@property
def initial_pressure_pa(self) -> float:
return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa)
@property
def fixed_initial_gauge_pressure_pa(self) -> float:
return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa)
@property
def variable_initial_gauge_pressure_pa(self) -> float:
return pressure_to_amesim_gauge_pa(self.variable_initial_absolute_pressure_pa)
@property
def chamber_count(self) -> int:
return len(self.fixed_chambers) + len(self.variable_chambers)
@property
def orifice_count(self) -> int:
return len(self.fixed_orifices) + len(self.variable_orifices)
@property
def component_count(self) -> int:
return self.chamber_count + self.orifice_count
@property
def variable_orifice_control_count(self) -> int:
return len(self.variable_orifice_controls)
def set_variable_orifice_openings(self, time_s: float) -> None:
for alias, control in self.variable_orifice_controls.items():
self.variable_orifices[alias].opening = control.opening_at(time_s)
@property
def aliases(self) -> tuple[str, ...]:
return tuple(
[
*self.fixed_chambers,
*self.variable_chambers,
*self.fixed_orifices,
*self.variable_orifices,
]
)
def build_test_mql_pneumatic_assembly(
config: TestMqlConfig | None = None,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPneumaticAssembly:
config = config or TestMqlConfig.from_amesim_specs()
fixed_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter(
config.global_parameters["P0"]
)
variable_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter(
DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR
)
fixed_chambers = {
component.alias: _build_chamber(
component,
volume_parameter="cvol",
gas=gas,
initial_pressure_pa=fixed_initial_absolute_pressure_pa,
)
for component in config.components_by_submodel("PNCH023")
}
variable_chambers = {
component.alias: _build_chamber(
component,
volume_parameter="cvol0",
gas=gas,
initial_pressure_pa=variable_initial_absolute_pressure_pa,
)
for component in config.components_by_submodel("PNCH012")
}
fixed_orifices = {
component.alias: _build_orifice(
component,
area_parameter="area",
gas=gas,
opening=1.0,
)
for component in config.components_by_submodel("PNOR001")
}
variable_orifice_controls = _build_variable_orifice_controls(config)
variable_orifices = {
component.alias: _build_orifice(
component,
area_parameter="area0",
gas=gas,
opening=variable_orifice_controls[component.alias].opening_at(0.0),
)
for component in config.components_by_submodel("PNVO001")
}
return TestMqlPneumaticAssembly(
fixed_chambers=fixed_chambers,
variable_chambers=variable_chambers,
fixed_orifices=fixed_orifices,
variable_orifices=variable_orifices,
variable_orifice_controls=variable_orifice_controls,
fixed_initial_absolute_pressure_pa=fixed_initial_absolute_pressure_pa,
variable_initial_absolute_pressure_pa=variable_initial_absolute_pressure_pa,
)
def _build_variable_orifice_controls(
config: TestMqlConfig,
) -> dict[str, TestMqlVariableOrificeControl]:
from PythonModels.systems.test_mql import CONNECTION_SPECS
components_by_alias = {component.alias: component for component in config.components}
variable_orifice_aliases = {
component.alias for component in config.components_by_submodel("PNVO001")
}
controls: dict[str, TestMqlVariableOrificeControl] = {}
for connection in CONNECTION_SPECS:
if connection["submodel"] != "DIRECT":
continue
source_alias = str(connection["source_component"])
target_alias = str(connection["target_component"])
if target_alias in variable_orifice_aliases:
orifice_alias = target_alias
step_alias = source_alias
elif source_alias in variable_orifice_aliases:
orifice_alias = source_alias
step_alias = target_alias
else:
continue
step_component = components_by_alias.get(step_alias)
if step_component is None or step_component.submodel != "STEP0":
continue
controls[orifice_alias] = TestMqlVariableOrificeControl(
orifice_alias=orifice_alias,
step=TestMqlStepSignalSpec(
alias=step_alias,
initial_output=step_component.parameter_value("out0"),
final_output=step_component.parameter_value("out1"),
step_time_s=step_component.parameter_value("t0"),
transition_duration_s=step_component.parameter_value("td"),
transition_type=int(step_component.parameter_value("transitionType")),
),
)
missing = variable_orifice_aliases - controls.keys()
if missing:
raise ValueError(
"missing STEP0 controls for PNVO001 components: "
+ ", ".join(sorted(missing))
)
return controls
def absolute_pressure_from_amesim_bar_parameter(pressure_bar: float) -> float:
return pressure_bar * BAR_TO_PA
def pressure_to_amesim_gauge_pa(absolute_pressure_pa: float) -> float:
return absolute_pressure_pa - AMESIM_REFERENCE_PRESSURE_PA
def pressure_from_amesim_bar_parameter(pressure_bar: float) -> float:
return pressure_to_amesim_gauge_pa(absolute_pressure_from_amesim_bar_parameter(pressure_bar))
def _build_chamber(
component: TestMqlResolvedComponent,
*,
volume_parameter: str,
gas: AmesimPneumaticGas,
initial_pressure_pa: float,
) -> AmesimPneumaticVolume:
if volume_parameter == "cvol0":
return AmesimVariablePneumaticVolume.from_liters(
name=component.alias,
dead_volume_liters=component.parameter_value(volume_parameter),
gas=gas,
p0=initial_pressure_pa,
T0=_component_temperature(component),
heat_transfer_coefficient=component.parameter_value("kth"),
heat_transfer_area=component.parameter_value("sth"),
external_temperature_k=_component_temperature(component),
)
return AmesimPneumaticVolume.from_liters(
name=component.alias,
volume_liters=component.parameter_value(volume_parameter),
gas=gas,
p0=initial_pressure_pa,
T0=_component_temperature(component),
heat_transfer_coefficient=component.parameter_value("kth"),
heat_transfer_area=component.parameter_value("sth"),
external_temperature_k=_component_temperature(component),
)
def _build_orifice(
component: TestMqlResolvedComponent,
*,
area_parameter: str,
gas: AmesimPneumaticGas,
opening: float,
) -> AmesimPneumaticOrifice:
flow_coefficient = component.parameter_value("cq")
if component.submodel == "PNVO001":
flow_coefficient *= TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER
return AmesimPneumaticOrifice.from_mm2(
name=component.alias,
area_mm2=component.parameter_value(area_parameter),
flow_coefficient=flow_coefficient,
gas=gas,
opening=opening,
)
def _component_temperature(component: TestMqlResolvedComponent) -> float:
parameter = component.parameters.get("extemp")
if parameter is None or parameter.value is None:
return DEFAULT_TEST_MQL_TEMPERATURE_K
return parameter.value
@@ -1,194 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.components.amesim_pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
)
from PythonModels.components.amesim_pneumatic_line import (
AmesimPnl0001Pipe,
AmesimPnl0002Pipe,
AmesimPnl0003Pipe,
AmesimPnl00rPipe,
)
from PythonModels.systems.test_mql_line_parameters import (
TestMqlPnl0001Spec,
TestMqlPnl0002Spec,
TestMqlPnl0003Spec,
TestMqlPnl00rSpec,
load_test_mql_pnl0001_specs,
load_test_mql_pnl0002_specs,
load_test_mql_pnl0003_specs,
load_test_mql_pnl00r_specs,
)
TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE = 5.5636e-6
@dataclass(frozen=True)
class TestMqlPnl0001Assembly:
specs: tuple[TestMqlPnl0001Spec, ...]
lines: dict[str, AmesimPnl0001Pipe]
def spec(self, alias: str) -> TestMqlPnl0001Spec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
@dataclass(frozen=True)
class TestMqlPnl0002Assembly:
specs: tuple[TestMqlPnl0002Spec, ...]
lines: dict[str, AmesimPnl0002Pipe]
def spec(self, alias: str) -> TestMqlPnl0002Spec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
@dataclass(frozen=True)
class TestMqlPnl0003Assembly:
specs: tuple[TestMqlPnl0003Spec, ...]
lines: dict[str, AmesimPnl0003Pipe]
def spec(self, alias: str) -> TestMqlPnl0003Spec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
@dataclass(frozen=True)
class TestMqlPnl00rAssembly:
specs: tuple[TestMqlPnl00rSpec, ...]
lines: dict[str, AmesimPnl00rPipe]
def spec(self, alias: str) -> TestMqlPnl00rSpec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
def build_test_mql_pnl0001_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl0001Assembly:
specs = load_test_mql_pnl0001_specs(archive_path)
lines = {
spec.alias: AmesimPnl0001Pipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
polytropic_constant=spec.polytropic_constant,
heat_transfer_coefficient=spec.heat_transfer_coefficient,
external_temperature_k=spec.external_temperature_k,
calibrated_linear_conductance=(
_test_mql_pnl0001_calibrated_linear_conductance(spec)
),
gas=gas,
p0=spec.initial_absolute_pressure_pa,
T0=spec.initial_temperature_k,
)
for spec in specs
}
return TestMqlPnl0001Assembly(specs=specs, lines=lines)
def _test_mql_pnl0001_calibrated_linear_conductance(
spec: TestMqlPnl0001Spec,
) -> float | None:
if spec.target_component.startswith("pn_c1_") and _matches_geometry(
spec, diameter_mm=20.0, length_m=1.0
):
return TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE
return None
def _matches_geometry(
spec: TestMqlPnl0001Spec,
*,
diameter_mm: float,
length_m: float,
) -> bool:
return (
abs(spec.diameter_mm - diameter_mm) < 1.0e-12
and abs(spec.length_m - length_m) < 1.0e-12
)
def build_test_mql_pnl0002_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl0002Assembly:
specs = load_test_mql_pnl0002_specs(archive_path)
lines = {
spec.alias: AmesimPnl0002Pipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
polytropic_constant=spec.polytropic_constant,
heat_transfer_coefficient=spec.heat_transfer_coefficient,
external_temperature_k=spec.external_temperature_k,
gas=gas,
pctr_0=spec.initial_center_absolute_pressure_pa,
Tctr_0=spec.initial_center_temperature_k,
)
for spec in specs
}
return TestMqlPnl0002Assembly(specs=specs, lines=lines)
def build_test_mql_pnl0003_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl0003Assembly:
specs = load_test_mql_pnl0003_specs(archive_path)
lines = {
spec.alias: AmesimPnl0003Pipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
polytropic_constant=spec.polytropic_constant,
heat_transfer_coefficient=spec.heat_transfer_coefficient,
external_temperature_k=spec.external_temperature_k,
gas=gas,
p1_0=spec.initial_absolute_pressure_1_pa,
T1_0=spec.initial_temperature_1_k,
p2_0=spec.initial_absolute_pressure_2_pa,
T2_0=spec.initial_temperature_2_k,
)
for spec in specs
}
return TestMqlPnl0003Assembly(specs=specs, lines=lines)
def build_test_mql_pnl00r_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl00rAssembly:
specs = load_test_mql_pnl00r_specs(archive_path)
lines = {
spec.alias: AmesimPnl00rPipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
gas=gas,
)
for spec in specs
}
return TestMqlPnl00rAssembly(specs=specs, lines=lines)
-303
View File
@@ -1,303 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from PythonModels.components.cylinder import Cylinder
from PythonModels.components.orifice import Orifice
from PythonModels.components.pipe import Pipe
from PythonModels.components.tank import Tank
from PythonModels.components.tee import Tee
from PythonModels.core.medium import IdealGasMedium
from PythonModels.core.network import SimulationNetwork
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
from PythonModels.systems.testmodel_closure import (
BranchClosureComponents,
InitializationDiagnostics,
TestModelClosure,
TestModelClosureComponents,
TestModelSnapshot,
)
@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
-668
View File
@@ -1,668 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
from PythonModels.components.cylinder import Cylinder
from PythonModels.components.orifice import Orifice
from PythonModels.components.pipe import Pipe
from PythonModels.components.tank import Tank
from PythonModels.components.tee import Tee
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.state import VolumeState
@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,
]
+94 -18
View File
@@ -2,37 +2,113 @@
ReactFlow 系统建模与 `app.simulation` 仿真后端。
## 开发环境准备
后端统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本
`3.12.3`。`requirements.txt` 保留支持范围,
`constraints/python312-direct.txt` 固定跨平台开发环境的直接依赖参考版本;
`constraints/python312-linux-x86_64.lock` 则完整固定发布与 CI 所用的 Linux x86_64
wheel、全部传递依赖及其 SHA-256。
Windows:
```powershell
py -3.12 -m venv .venv-win
.\.venv-win\Scripts\python.exe -m pip install `
-r requirements.txt `
-c constraints/python312-direct.txt
.\.venv-win\Scripts\python.exe -m pip check
```
Linux:
```bash
python3.12 -m venv .venv
./.venv/bin/python -m pip install \
-r constraints/python312-linux-x86_64.lock
./.venv/bin/python -m pip check
```
Linux 发布锁仅适用于兼容 manylinux_2_28 的 Linux x86_64 和 CPython 3.12。它启用
`--only-binary=:all:` 与 `--require-hashes`,因此不会静默改用源码包或未审计 wheel;
CI 和正式性能复测必须直接以 `-r` 安装该文件。Windows 或其他平台的开发环境继续
使用 `requirements.txt` 加 `constraints/python312-direct.txt`。若要测试
`requirements.txt` 声明的兼容范围,可显式省略约束,但这类结果不应与锁定环境的
性能数据直接比较。
升级参考版本时,应在干净的 Python 3.12 Linux x86_64 虚拟环境中解析范围文件,
仅下载兼容 wheel,逐个记录 wheel 的 SHA-256,再从空环境安装发布锁并运行
`pip check`、依赖契约测试与后端测试。不能只复制 `pip freeze`,因为它既不证明
依赖来源,也不校验安装产物。
前端使用 Vite 8,需要 Node.js `20.19+` 或 `22.12+`。首次启动前安装前端依赖。
Windows(PowerShell,使用仓库内的便携 Node.js):
```powershell
$nodeDir = Get-ChildItem .tools -Directory -Filter "node-*-win-x64" |
Where-Object { (Test-Path "$($_.FullName)\node.exe") -and (Test-Path "$($_.FullName)\npm.cmd") } |
Select-Object -First 1
& "$($nodeDir.FullName)\npm.cmd" --prefix frontend ci
```
Linux:
```bash
cd frontend
npm ci
cd ..
```
Windows 启动脚本会自动使用 `.tools/node-*-win-x64` 下兼容的便携 Node.js;Linux 启动脚本优先使用 `.tools/node-*-linux-x64` 下兼容的运行时(如果存在),否则使用 `PATH` 中的 `node` 和 `npm`。`start-all.sh` 需要 Bash 4.3 或更高版本。
## 启动项目
脚本统一存放在 `bat/` 目录。三个入口分别用于同时启动、只启动后端、只启动前端。
Windows:
```bat
bat\start-all.bat
bat\start-backend.bat
bat\start-reactflow.bat
```
Linux:
```bash
./bat/start-all.sh
./bat/start-backend.sh
./bat/start-reactflow.sh
```
后端地址为 `http://127.0.0.1:8000`,前端地址为 `http://127.0.0.1:5173`。Windows 的 `start-all.bat` 会分别打开两个命令行窗口;Linux 的 `start-all.sh` 会在同一终端管理两个进程,按 `Ctrl+C` 会同时停止它们。
## 后端接口
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
- `POST /api/reactflow/system-xml`:导出 System XML v2。
- `POST /api/reactflow/system-xml`:导出精简的 System XML v3。
- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
- `POST /api/reactflow/simulate-test-mql`:运行现有固定拓扑 AMESim `test_mql` 迁移模型;该接口不把 AMESim 子模型注册为公开拖拽组件。
- `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。
- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。
- `POST /api/reactflow/simulate-test-mql`:返回固定拓扑 AMESim `test_mql` 的结构与采样摘要;132 状态数值对比使用独立 comparison 入口。AMESim 子模型已有 19 个第一版公开模型,但该接口本身不是任意拖拽拓扑求解器。
- `POST /api/system-xml/validate`:接收原始 System XML v3,返回 XML、XSD 和模型语义三层诊断。
- `POST /api/system-xml/parse`:校验 XML,并返回可直接编译、求解的规范化模型数据;它不还原 ReactFlow 画布布局。
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。
- `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。
- `POST /api/system-xml/simulate`:按 XML 中的组件、连接、参数和仿真设置运行当前支持的气动、标量信号及一维机械网络 MVP,并返回组件及端口时间序列。
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。
当前网络层可以从组件和连接生成压力-流量残差,使用 SciPy 完成非线性代数闭合和时间积分,并按实际流向传播 stream 焓。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点。它不是完整 DAE 求解器,也不等价于严格 Modelica.Fluid 实现。
当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。
XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用:
```powershell
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
```
XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。
## 文档
- [开发文档索引](docs/README.md)
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md)
- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md)
- [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-spec-v1.md)
- [组件模型建模规范 v1](docs/standard/component-model-authoring-spec-v1.md)
- [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md)
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
- [System XML v2 协议](docs/system-xml-v2.md)
- [System XML v2 XSD](schemas/system-simulation-v2.xsd)
- [System XML v1 协议(旧版)](docs/system-xml-v1.md)
- [System XML v1 XSD(旧版)](schemas/system-simulation-v1.xsd)
- [System XML v3 协议(当前规范)](docs/standard/system-xml-v3.md)
- [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd)
+561 -205
View File
File diff suppressed because it is too large. Load diff
+59 -38
View File
@@ -16,8 +16,8 @@
- `components/experimental/junctions/`: 三通等连接节点。
- `components/amesim/`: AMESim 气动、信号和机械组件原语。
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑、基线运行入口,以及 test_mql 当前迁移过程中的系统装配和诊断脚本。
- `examples/test_mql/`: AMESim `test_mql` 的前端调用运行入口。
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和基线运行入口。
- `examples/test_mql/`: AMESim `test_mql` 的系统装配、校准原语、诊断和运行入口。
- `reporting/`: CSV、SVG、运行报告、Modelica 对比结果、AMESim 结果读取和诊断报告导出。
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
@@ -29,30 +29,67 @@
FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow
启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。
临时组件库的声明入口是 `components/experimental/library.py`。公开模型必须在
临时组件库的声明入口是 `components/experimental/library.py`,AMESim 第一版
公开临时库入口是 `components/amesim/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)。
[`组件模型建模规范 v1`](../../docs/standard/component-model-authoring-spec-v1.md)和
[`组件库分类、发现与读取规范 v1`](../../docs/standard/component-library-spec-v1.md)。
当前关键文件:
- `core/medium.py`: 温度相关的理想气体近似介质 `IdealGasMedium`
- `core/peng_robinson.py`: `test_mql` 使用的氦气 Peng-Robinson 物性
- `core/medium.py`: 气体介质协议 `GasMedium` 与通用理想气体实现 `IdealGasMedium`
- `components/amesim/media/`: AMESim 零端口介质物性定义元件;具体类型确定介质,`property_model` 下拉参数选择计算方法,当前提供空气理想气体和氦气 Peng-Robinson
- `components/amesim/gases.py`: AMESim `gi` 介质物性实例注册表;`gi=0` 固定为空气(理想气体,内置默认),`gi=1..99` 引用画布中的显式介质定义
- `core/peng_robinson.py`: `test_mql` 与公开氦气介质共用的 Peng-Robinson 状态方程
- `performance.py`: 默认关闭、按单次仿真隔离的阶段与物性性能埋点
- `benchmark_performance.py`: System XML 主求解路径的可重复命令行基准工具
- `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` 当前专用的闭合、初始化投影、分支求解与端口回写
- `examples/testmodel/test_mql.py`: `test_mql` 系统装配、132 状态总闭包和关键输出映射
- `examples/testmodel/test_mql_closure.py`: `test_mql` 气动网络 closure、snapshot、流量计算和端口写回
- `examples/test_mql/system.py`: `test_mql` 系统装配、132 状态总闭包和关键输出映射
- `examples/test_mql/closure.py`: `test_mql` 气动网络 closure、snapshot、流量计算和端口写回
- `examples/test_mql/primitives/`: 固定算例专用的 Peng-Robinson 氦气、管路和机械校准原语
- `examples/test_mql/structural_network.py`: 固定算例专用的结构网络;不替代带端口契约校验的通用网络
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
- `reporting/amesim_results.py`: AMESim 结果读取入口
- `examples/testmodel/run_test_mql_full_state_comparison.py`: `test_mql` 短时域 AMESim comparison 和诊断入口
- `examples/testmodel/run_test_mql.py`: test_mql 基线运行与程序化执行入口
- `examples/test_mql/run_full_state_comparison.py`: `test_mql` 短时域 AMESim comparison 和诊断入口
- `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
## 可选性能诊断
`SIMULATIONAPP_PROFILE` 支持 `off`(默认)、`standard` 和 `audit`。`standard`
只统计低频的大阶段;`audit` 才展开 RHS、代数闭合、stream 和物性调用,开销也
明显更高。最终优化收益必须在 `off` 下复测。
Peng–Robinson 氦气的高开销物性默认使用一次仿真内独立的精确 LRU 缓存;不同
仿真任务不会共享条目,仿真结束后自动释放。可在启动进程前设置
`SIMULATIONAPP_PROPERTY_CACHE=off` 做数值和性能 A/B,正常运行保持默认 `on`。
缓存只复用完全相同的输入,不做四舍五入或容差匹配。
FastAPI worker 默认在 lifespan 启动阶段预热 SciPy 积分、非线性求解、稀疏
Jacobian 和 System XML XSD,完成后才开始接收请求。它不会运行业务模型,也不
写入文件;如需诊断冷启动,可设置 `SIMULATIONAPP_WARMUP=off`。每个 worker 都会
独立暖机一次。
```powershell
.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance `
--mode audit --warmups 1 --runs 3 `
--factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" `
--output app/data/performance-evaluations/helium-step.json
```
缓存关闭对照可在同一命令中增加 `--disable-property-cache`。缓存容量、命中、
未命中和驱逐数会在 audit 响应的
`diagnostics.performance.propertyCache` 中返回。
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
[`仿真性能评估 2026-08-15`](../../docs/other/仿真性能评估-2026-08-15.md)。
## 当前阶段进度
这一阶段原先有 4 件重点工作,现在的状态如下:
@@ -181,7 +218,7 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
`testmodel_tank_temperature.svg`
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
13. 面向 System XML v3 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
当前没有实现:
@@ -274,11 +311,11 @@ print(result.used_modelica_reference)
## 基线结果
当前基线对比摘要来自:
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt)
[`testmodel_modelica_comparison_summary.txt`](../../tests/data/testmodel/testmodel_modelica_comparison_summary.txt)
当前四个主变量的最大误差为:
- `mytank.p`: `max_abs_error = 134.960857 Pa`, `max_rel_error = 0.006798%`
- `mytank.p`: `max_abs_error = 134.960858 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%`
@@ -287,31 +324,15 @@ print(result.used_modelica_reference)
## AMESim test_mql 当前进度
`test_mql` 是从 `AmesimModels/test_mql.ame` 新增迁移的 AMESim 模型,当前只在独立路径下推进,不修改旧 `testmodel`。新增命名保持 AMESim 原始别名和 `Data_Path`,方便后续逐变量对齐。
`test_mql` 从 `AmesimModels/test_mql.ame` 迁移,并与旧 `testmodel` 保持独立。
固定算例实现统一位于 `examples/test_mql/`,结果读取和比较能力位于
`reporting/`;公开拖拽组件由 `components/amesim/library.py` 单独登记。
当前已经完成:
- 解析 117 个组件、84 条 LINE 连接、直接组件接触、全局参数、仿真设置以及 AMESim 变量目录。
- 直接读取 `.ame` 包内 `test_mql_.var` 和 `test_mql_.results`;baseline 包含 1002 个时间点和 1116 个保存变量。
- 使用氦气 Peng-Robinson 物性,内部统一使用绝对压力,对外按 AMESim 表压和原始单位输出。
- 实现 `PNCH023 / PNCH012 / PNOR001 / PNVO001`,以及 `PNL0001 / PNL0002 / PNL0003 / PNL00R` 管路和 `PN3NODE2 / P4NODE2` 节点语义。
- 完成气动真实拓扑装配、canonical flow、端口写回、snapshot 和 112 状态气动 RHS。
- 实现 `PNRP17 / MECMAS21 / LSTP00A / LMECHN1 / UD00 / FORC` 当前工况可确认的机械行为,并形成 20 状态机械闭包。
- 将气动和机械部分组合成 132 状态总闭包,接入活塞体积反馈、气动力、外力、端止动和质量约束,可通过现有 solver 短时积分。
- 建立关键 `Data_Path` 序列导出、output schema、validation、AMESim 插值比较、误差排序、端点诊断和 PNCH012 RHS 项拆解。
当前确认的关键细节:
- `PNRP17` 活塞腔体积使用环形有效面积 `piston_area - rod_area`。
- `LSTP00A` 的 `gap` 观测单位是 mm,计算接触力前必须转换为 m。
- `PNCH023` 固定气室初始压力来自 `P0=153 bar` 的绝对压力;AMESim `press` 输出为相对 `101300 Pa` 的表压。
- `PNCH012` 变容腔初始压力对齐 AMESim 的 `1 bar` 绝对压力,`vol` 输出单位为 cm3,且末端体积等于基础死容积加对应活塞 `vol1`。
- `MECMAS21` 的 `x1dup / v1dup / acc1dup` 是第二机械端口观测,相对 `x1 / v1 / acc1` 为反号,不是重复同值。
- 本算例中 `MECMAS21` 的 `Fmin / Fmax / Fvisc / Ffric` 在 AMESim 结果里为零;当前只把这一工况能验证的部分写入测试,没有硬猜未激活碰撞/摩擦状态机。
当前默认 `0 -> 1e-5 s` comparison 已定位最大偏差为 `press@pn_c1_8`:初值对齐,但末值绝对误差约 `9.22849 Pa`。RHS 拆解显示边界体积功约 `0.026 W`,端口焓流约 `32722 W`,因此当前首要工作是比较 Python 的 `p4_port3_remote_chamber_to_line_flow` 与 AMESim 的 `dm1@pneumatic_69`,检查单位、符号、PNL0001 阻力和 `pnnode4_16` 节点平衡。
当前还不能宣称 `test_mql` 的 Python 时域仿真已经和 AMESim 全局一致。完整说明、运行命令和下一步校准路径见 `AmesimModels/test_mql/README.md`。
当前已形成 112 个气动状态和 20 个机械状态的总闭包、AMESim 原生结果读取、
`Data_Path` 输出校验及短时域 comparison。这里不再复制易过期的数值进度;
最新对比结果、运行命令、限制和下一步校准路径以
[`AmesimModels/test_mql/README.md`](../../AmesimModels/test_mql/README.md)
为唯一说明。当前仍不能宣称 Python 时域仿真与 AMESim 全局一致。
## Testmodel 当前架构判断
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
import argparse
import hashlib
import importlib
import json
import os
import platform
import statistics
import sys
from datetime import UTC, datetime
from math import ceil
from pathlib import Path
from time import perf_counter_ns, process_time_ns
from typing import Any
def _named_value(value: str, *, option: str) -> tuple[str, str]:
name, separator, target = value.partition("=")
if not separator or not name.strip() or not target.strip():
raise ValueError(
f"{option} must use NAME=VALUE syntax, received {value!r}."
)
return name.strip(), target.strip()
def _percentile(values: list[float], percentile: float) -> float:
ordered = sorted(values)
index = max(0, min(len(ordered) - 1, ceil(percentile * len(ordered)) - 1))
return ordered[index]
def _duration_summary(values: list[float]) -> dict[str, object]:
return {
"samplesMs": values,
"minimumMs": min(values),
"medianMs": statistics.median(values),
"p95Ms": _percentile(values, 0.95),
"maximumMs": max(values),
}
def _load_factory_xml(specification: str) -> bytes:
module_name, separator, member_name = specification.partition(":")
if not separator or not module_name or not member_name:
raise ValueError(
"Factory specifications must use module.path:callable syntax."
)
factory = getattr(importlib.import_module(module_name), member_name)
value = factory()
if isinstance(value, bytes):
return value
if isinstance(value, str):
return value.encode("utf-8")
from app.main import build_reactflow_system_xml
return build_reactflow_system_xml(value)
def _serialize_result_event(result: dict[str, object]) -> bytes:
"""Render the final NDJSON payload shape used by the streaming endpoint."""
status = str(result.get("status", "completed"))
event = {
"event": "result",
"progress": 100 if status == "completed" else 0,
"phase": status,
"message": "仿真完成" if status == "completed" else "仿真任务结束",
"simulatedTime": result.get("simulatedUntil"),
"totalTime": result.get("requestedStopTime"),
"result": result,
}
return (
json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
).encode("utf-8")
def _run_case(
name: str,
xml_bytes: bytes,
*,
warmups: int,
runs: int,
cancellable_path: bool,
allow_failures: bool,
) -> dict[str, object]:
from app.main import run_system_xml_simulation
cancel_check = (lambda: False) if cancellable_path else None
for _ in range(warmups):
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
if not bool(result.get("success")) and not allow_failures:
raise RuntimeError(f"Warmup for {name!r} failed: {result.get('message')}")
wall_samples_ms: list[float] = []
cpu_samples_ms: list[float] = []
serialization_samples_ms: list[float] = []
serialized_sizes: list[int] = []
profiles: list[dict[str, object]] = []
final_result: dict[str, object] | None = None
for _ in range(runs):
wall_start = perf_counter_ns()
cpu_start = process_time_ns()
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
cpu_samples_ms.append((process_time_ns() - cpu_start) / 1_000_000.0)
wall_samples_ms.append((perf_counter_ns() - wall_start) / 1_000_000.0)
if not bool(result.get("success")) and not allow_failures:
raise RuntimeError(f"Benchmark for {name!r} failed: {result.get('message')}")
diagnostics = result.get("diagnostics")
if isinstance(diagnostics, dict):
performance = diagnostics.get("performance")
if isinstance(performance, dict):
profiles.append(performance)
serialization_start = perf_counter_ns()
serialized_event = _serialize_result_event(result)
serialization_samples_ms.append(
(perf_counter_ns() - serialization_start) / 1_000_000.0
)
serialized_sizes.append(len(serialized_event))
final_result = result
assert final_result is not None
return {
"name": name,
"success": bool(final_result.get("success")),
"message": final_result.get("message"),
"inputBytes": len(xml_bytes),
"inputSha256": hashlib.sha256(xml_bytes).hexdigest(),
"status": final_result.get("status"),
"simulatedUntil": final_result.get("simulatedUntil"),
"requestedStopTime": final_result.get("requestedStopTime"),
"wall": _duration_summary(wall_samples_ms),
"cpu": _duration_summary(cpu_samples_ms),
"resultSerialization": _duration_summary(serialization_samples_ms),
"resultEventBytes": serialized_sizes,
"performanceRuns": profiles,
}
def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Benchmark the real System XML simulation path with optional profiling."
)
parser.add_argument(
"--mode",
choices=("off", "standard", "audit"),
default="audit",
help="Instrumentation depth selected before importing the simulation modules.",
)
parser.add_argument("--warmups", type=int, default=1)
parser.add_argument("--runs", type=int, default=5)
parser.add_argument(
"--xml",
action="append",
default=[],
metavar="NAME=PATH",
help="Add an XML file benchmark case.",
)
parser.add_argument(
"--factory",
action="append",
default=[],
metavar="NAME=MODULE:CALLABLE",
help="Add a zero-argument factory returning XML or ReactFlowProjectPayload.",
)
parser.add_argument(
"--direct-path",
action="store_true",
help="Do not pass a cancel callback; use the one-shot SciPy path when eligible.",
)
parser.add_argument(
"--disable-property-cache",
action="store_true",
help="Disable the run-local exact property cache for an A/B comparison.",
)
parser.add_argument(
"--allow-failures",
action="store_true",
help="Record failed simulation runs instead of aborting the benchmark.",
)
parser.add_argument("--output", type=Path)
arguments = parser.parse_args(argv)
if arguments.warmups < 0:
parser.error("--warmups must not be negative.")
if arguments.runs <= 0:
parser.error("--runs must be positive.")
if not arguments.xml and not arguments.factory:
parser.error("At least one --xml or --factory case is required.")
return arguments
def main(argv: list[str] | None = None) -> int:
arguments = _parse_arguments(argv)
os.environ["SIMULATIONAPP_PROFILE"] = arguments.mode
os.environ["SIMULATIONAPP_PROPERTY_CACHE"] = (
"off" if arguments.disable_property_cache else "on"
)
cases: list[tuple[str, bytes]] = []
for raw_case in arguments.xml:
name, raw_path = _named_value(raw_case, option="--xml")
cases.append((name, Path(raw_path).read_bytes()))
for raw_case in arguments.factory:
name, specification = _named_value(raw_case, option="--factory")
cases.append((name, _load_factory_xml(specification)))
report: dict[str, Any] = {
"generatedAt": datetime.now(UTC).isoformat(),
"profileMode": arguments.mode,
"cancellableSolverPath": not arguments.direct_path,
"propertyCacheEnabled": not arguments.disable_property_cache,
"allowFailures": bool(arguments.allow_failures),
"warmups": arguments.warmups,
"runs": arguments.runs,
"runtime": {
"python": sys.version,
"platform": platform.platform(),
"processor": platform.processor(),
},
"cases": [
_run_case(
name,
xml_bytes,
warmups=arguments.warmups,
runs=arguments.runs,
cancellable_path=not arguments.direct_path,
allow_failures=arguments.allow_failures,
)
for name, xml_bytes in cases
],
}
text = json.dumps(report, ensure_ascii=False, indent=2)
if arguments.output is not None:
arguments.output.parent.mkdir(parents=True, exist_ok=True)
arguments.output.write_text(text + "\n", encoding="utf-8")
print(f"Performance report written to {arguments.output.resolve()}")
else:
print(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large. Load diff
@@ -19,6 +19,7 @@ class AmesimPnpl01(AlgebraicComponent):
MODEL_TYPE = "amesim_pnpl01"
MODEL_VERSION = "0.1.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
PARAMETERS = ()
RESULT_VARIABLES = ()
@@ -26,7 +27,7 @@ class AmesimPnpl01(AlgebraicComponent):
label="PNPL01 零气动流边界",
library_id="amesim",
category_id="boundary",
symbol="generic",
symbol="amesim_pnpl01",
ports=(PortDisplaySpec("port_1", "left", order=10),),
order=10,
)
@@ -46,6 +47,9 @@ class AmesimPnpl01(AlgebraicComponent):
) -> AmesimPnpl01:
return cls(name=name)
def pressure_flow_equation_values(self) -> tuple[float, ...]:
return (self.port_1.m_flow,)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
+474 -117
View File
@@ -1,34 +1,74 @@
from __future__ import annotations
from functools import lru_cache
from collections.abc import Mapping
from math import isclose, sqrt
from math import isclose, log, sqrt, tanh
from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER,
normalize_amesim_gas_index,
)
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.catalog import (
ComponentDisplaySpec,
ParameterGroupDisplaySpec,
PortDisplaySpec,
)
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterCondition,
ParameterDefinition,
ParameterOption,
ResultVariableDefinition,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
_FLOW_COEFFICIENT_OPTIONS = (
ParameterOption(1.0, "Cq"),
ParameterOption(2.0, "Cv"),
ParameterOption(3.0, "Kv"),
)
_FLOWSET_USES_CQ = (ParameterCondition("flowset", (1.0,)),)
_FLOWSET_USES_CV = (ParameterCondition("flowset", (2.0,)),)
_FLOWSET_USES_KV = (ParameterCondition("flowset", (3.0,)),)
_PN_PRESSURE_RATIO_ACCURACY = 0.9999
_PN_LAMINAR_SMOOTHING_GAIN = 12.0
_PNVO001_CLOSED_OPENING_ABS_TOL = 1.0e-12
_PNOR001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(
id="flow_coefficient",
label="流量系数",
parameters=("cq", "area", "Cv", "Kv"),
order=10,
)
_PNVO001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(
id="flow_coefficient",
label="流量系数",
parameters=("cq", "area0", "Cv", "Kv"),
order=10,
)
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.
This public component preserves the PNOR001 catalog/XML contract and uses
real-gas pressure-ratio flow with AMESim-style near-equal-pressure smoothing.
"""
MODEL_TYPE = "amesim_pnor001"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"cq",
0.72,
@@ -37,6 +77,8 @@ class AmesimPnor001(AlgebraicComponent):
unit="",
minimum=1.0e-10,
maximum=1.0,
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"area",
@@ -46,6 +88,8 @@ class AmesimPnor001(AlgebraicComponent):
unit="m2",
minimum=0.0,
maximum=1.0,
description="选择 Cq/面积方式时用于流量计算的有效孔口面积。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"Cv",
@@ -54,6 +98,8 @@ class AmesimPnor001(AlgebraicComponent):
quantity="dimensionless",
unit="",
minimum=0.0,
description="选择 Cv 方式时使用的英制流量系数。",
visible_when=_FLOWSET_USES_CV,
),
ParameterDefinition(
"Kv",
@@ -62,15 +108,8 @@ class AmesimPnor001(AlgebraicComponent):
quantity="dimensionless",
unit="",
minimum=0.0,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
description="选择 Kv 方式时使用的公制流量系数。",
visible_when=_FLOWSET_USES_KV,
),
ParameterDefinition(
"flowset",
@@ -80,6 +119,9 @@ class AmesimPnor001(AlgebraicComponent):
unit="",
minimum=1.0,
maximum=3.0,
editor="choice",
options=_FLOW_COEFFICIENT_OPTIONS,
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
),
)
RESULT_VARIABLES = (
@@ -104,18 +146,19 @@ class AmesimPnor001(AlgebraicComponent):
label="PNOR001 常系数气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
symbol="amesim_pnor001",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
),
order=10,
parameter_groups=(_PNOR001_FLOW_COEFFICIENT_GROUP,),
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
cq: float = 0.72,
area: float = 5.0e-6,
@@ -140,7 +183,7 @@ class AmesimPnor001(AlgebraicComponent):
self.area = float(area)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(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.")
@@ -150,6 +193,7 @@ class AmesimPnor001(AlgebraicComponent):
self.port_1.h_outflow = initial_h
self.port_2 = self.register_declared_port("port_2")
self.port_2.h_outflow = initial_h
self._connected_h: dict[str, float] = {}
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
@@ -163,7 +207,7 @@ class AmesimPnor001(AlgebraicComponent):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimPnor001:
return cls(
@@ -205,12 +249,114 @@ class AmesimPnor001(AlgebraicComponent):
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
inlet_h = self._connected_h.get(port_name, port.h_outflow)
return max(
self.medium.temperature_from_pressure_enthalpy(
max(port.p, 1.0),
inlet_h,
),
1.0,
)
@staticmethod
def _subsonic_mass_flow_parameter(
*,
pressure_ratio: float,
gamma_s: float,
density: float,
upstream_temperature: float,
upstream_pressure: float,
) -> float:
expansion = (
pressure_ratio ** (2.0 * gamma_s)
- pressure_ratio ** (1.0 + gamma_s)
)
return sqrt(
max(
2.0
/ (1.0 - gamma_s)
* density
* upstream_temperature
/ upstream_pressure
* expansion,
0.0,
)
)
@lru_cache(maxsize=32768)
def _one_way_flow_characteristics(
self,
*,
upstream_pressure: float,
downstream_pressure: float,
upstream_temperature: float,
) -> tuple[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_s = self.medium.isentropic_density_pressure_factor(
p_up,
T_up,
p_down,
)
gamma_s = min(max(gamma_s, 1.0e-9), 1.0 - 1.0e-9)
density = max(self.medium.density(p_up, T_up), 1.0e-12)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 * gamma_s / (gamma_s + 1.0)) ** (
1.0 / (1.0 - gamma_s)
)
if pressure_ratio <= critical_ratio:
effective_pressure_ratio = critical_ratio
mass_flow_parameter = (
sqrt(2.0 / (1.0 + gamma_s) * density * T_up / p_up)
* (2.0 * gamma_s / (gamma_s + 1.0))
** (gamma_s / (1.0 - gamma_s))
)
gas_velocity = sqrt(2.0 / (1.0 + gamma_s) * p_up / density)
else:
effective_pressure_ratio = pressure_ratio
mass_flow_parameter = self._subsonic_mass_flow_parameter(
pressure_ratio=pressure_ratio,
gamma_s=gamma_s,
density=density,
upstream_temperature=T_up,
upstream_pressure=p_up,
)
gas_velocity = sqrt(
max(
2.0
/ (1.0 - gamma_s)
* p_up
/ density
* (1.0 - pressure_ratio ** (1.0 - gamma_s)),
0.0,
)
)
reference = self._subsonic_mass_flow_parameter(
pressure_ratio=_PN_PRESSURE_RATIO_ACCURACY,
gamma_s=gamma_s,
density=density,
upstream_temperature=T_up,
upstream_pressure=p_up,
)
if mass_flow_parameter > 0.0 and reference > 0.0:
argument = (
_PN_LAMINAR_SMOOTHING_GAIN
* abs(mass_flow_parameter / reference)
* log(effective_pressure_ratio)
/ log(_PN_PRESSURE_RATIO_ACCURACY)
)
smoothing_factor = tanh(max(argument, 0.0))
mass_flow_parameter *= smoothing_factor
gas_velocity *= smoothing_factor
return mass_flow_parameter, gas_velocity
def mass_flow(self, p_1: float, p_2: float) -> float:
if p_1 == p_2 or self.effective_area == 0.0:
if (
isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8)
or self.effective_area == 0.0
):
return 0.0
if p_1 > p_2:
return self._one_way_mass_flow(
@@ -232,45 +378,49 @@ class AmesimPnor001(AlgebraicComponent):
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
mass_flow_parameter, _ = self._one_way_flow_characteristics(
upstream_pressure=p_up,
downstream_pressure=downstream_pressure,
upstream_temperature=T_up,
)
return (
self.effective_cq
* self.effective_area
* p_up
* mass_flow_parameter
/ sqrt(T_up)
)
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"
if p_1 >= p_2:
upstream_port_name = "port_1"
upstream_pressure = p_1
downstream_pressure = p_2
flow_direction = 1.0
else:
upstream_port_name = "port_2"
upstream_pressure = p_2
downstream_pressure = p_1
flow_direction = -1.0
mass_flow_parameter, gas_velocity = self._one_way_flow_characteristics(
upstream_pressure=upstream_pressure,
downstream_pressure=downstream_pressure,
upstream_temperature=self._upstream_temperature(upstream_port_name),
)
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),
"cm": mass_flow_parameter,
"gasvel": flow_direction * gas_velocity,
}
def pressure_flow_equation_values(self) -> tuple[float, ...]:
return (
self.port_1.m_flow + self.port_2.m_flow,
self.port_1.m_flow - self.mass_flow(self.port_1.p, self.port_2.p),
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
@@ -302,6 +452,7 @@ class AmesimPnor001(AlgebraicComponent):
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self._connected_h = dict(connected_h)
self.port_1.h_outflow = connected_h["port_2"]
self.port_2.h_outflow = connected_h["port_1"]
@@ -315,12 +466,17 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
"""
MODEL_TYPE = "amesim_pnvo001_fixed"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.2.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"cq",
0.72,
@@ -329,6 +485,8 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="",
minimum=1.0e-10,
maximum=1.0,
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"area0",
@@ -338,6 +496,8 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="m2",
minimum=0.0,
maximum=1.0,
description="阀门完全开启时的最大有效孔口面积。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"Cv",
@@ -346,6 +506,8 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
quantity="dimensionless",
unit="",
minimum=0.0,
description="选择 Cv 方式时使用的最大英制流量系数。",
visible_when=_FLOWSET_USES_CV,
),
ParameterDefinition(
"Kv",
@@ -354,15 +516,8 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
quantity="dimensionless",
unit="",
minimum=0.0,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
description="选择 Kv 方式时使用的最大公制流量系数。",
visible_when=_FLOWSET_USES_KV,
),
ParameterDefinition(
"flowset",
@@ -372,6 +527,9 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="",
minimum=1.0,
maximum=3.0,
editor="choice",
options=_FLOW_COEFFICIENT_OPTIONS,
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
),
ParameterDefinition(
"opening",
@@ -381,6 +539,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="",
minimum=0.0,
maximum=1.0,
description="固定的归一化阀门开度;0 表示关闭,1 表示完全开启。",
),
)
RESULT_VARIABLES = (
@@ -413,18 +572,20 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
label="PNVO001 固定开度气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
symbol="amesim_pnvo001_fixed",
ports=(
PortDisplaySpec("port_2", "left", order=10),
PortDisplaySpec("port_3", "right", order=20),
# AMESim default geometry places port 2 right and port 3 left.
PortDisplaySpec("port_2", "right", order=10),
PortDisplaySpec("port_3", "left", order=20),
),
order=30,
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
cq: float = 0.72,
area0: float = 5.0e-6,
@@ -451,7 +612,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
self.area0 = float(area0)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(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.")
@@ -462,6 +623,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
self.port_2.h_outflow = initial_h
self.port_3 = self.register_declared_port("port_3")
self.port_3.h_outflow = initial_h
self._connected_h: dict[str, float] = {}
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
@@ -475,7 +637,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimPnvo001FixedOpening:
return cls(
@@ -508,12 +670,47 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
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
# A component port's h_outflow describes fluid leaving the valve; the
# upstream state comes from the connection on that same physical side.
inlet_h = self._connected_h.get(port_name, port.h_outflow)
return max(
self.medium.temperature_from_pressure_enthalpy(
max(port.p, 1.0),
inlet_h,
),
1.0,
)
@staticmethod
def _subsonic_mass_flow_parameter(
*,
pressure_ratio: float,
gamma_s: float,
density: float,
upstream_temperature: float,
upstream_pressure: float,
) -> float:
expansion = (
pressure_ratio ** (2.0 * gamma_s)
- pressure_ratio ** (1.0 + gamma_s)
)
return sqrt(
max(
2.0
/ (1.0 - gamma_s)
* density
* upstream_temperature
/ upstream_pressure
* expansion,
0.0,
)
)
def mass_flow(self, p_2: float, p_3: float) -> float:
if p_2 == p_3 or self.effective_area == 0.0:
if (
isclose(p_2, p_3, rel_tol=0.0, abs_tol=1.0e-8)
or self.effective_area == 0.0
):
return 0.0
if p_2 > p_3:
return self._one_way_mass_flow(
@@ -527,6 +724,82 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
upstream_temperature=self._upstream_temperature("port_3"),
)
@lru_cache(maxsize=32768)
def _one_way_flow_characteristics(
self,
*,
upstream_pressure: float,
downstream_pressure: float,
upstream_temperature: float,
) -> tuple[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_s = self.medium.isentropic_density_pressure_factor(
p_up,
T_up,
p_down,
)
gamma_s = min(max(gamma_s, 1.0e-9), 1.0 - 1.0e-9)
density = max(self.medium.density(p_up, T_up), 1.0e-12)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 * gamma_s / (gamma_s + 1.0)) ** (
1.0 / (1.0 - gamma_s)
)
if pressure_ratio <= critical_ratio:
effective_pressure_ratio = critical_ratio
mass_flow_parameter = (
sqrt(2.0 / (1.0 + gamma_s) * density * T_up / p_up)
* (2.0 * gamma_s / (gamma_s + 1.0))
** (gamma_s / (1.0 - gamma_s))
)
gas_velocity = sqrt(
2.0 / (1.0 + gamma_s) * p_up / density
)
else:
effective_pressure_ratio = pressure_ratio
mass_flow_parameter = self._subsonic_mass_flow_parameter(
pressure_ratio=pressure_ratio,
gamma_s=gamma_s,
density=density,
upstream_temperature=T_up,
upstream_pressure=p_up,
)
gas_velocity = sqrt(
max(
2.0
/ (1.0 - gamma_s)
* p_up
/ density
* (1.0 - pressure_ratio ** (1.0 - gamma_s)),
0.0,
)
)
# AMESim's gas_cm_prc_ applies this factor continuously over the
# complete pressure-ratio range. It is effectively one outside the
# near-equal-pressure region and makes Cm (and vena-contracta
# velocity) approach zero quadratically as the pressure ratio tends
# to one. The reference Cm intentionally reuses the current gamma_s.
reference_mass_flow_parameter = self._subsonic_mass_flow_parameter(
pressure_ratio=_PN_PRESSURE_RATIO_ACCURACY,
gamma_s=gamma_s,
density=density,
upstream_temperature=T_up,
upstream_pressure=p_up,
)
if mass_flow_parameter > 0.0 and reference_mass_flow_parameter > 0.0:
smoothing_argument = (
_PN_LAMINAR_SMOOTHING_GAIN
* abs(mass_flow_parameter / reference_mass_flow_parameter)
* log(effective_pressure_ratio)
/ log(_PN_PRESSURE_RATIO_ACCURACY)
)
smoothing_factor = tanh(max(smoothing_argument, 0.0))
mass_flow_parameter *= smoothing_factor
gas_velocity *= smoothing_factor
return mass_flow_parameter, gas_velocity
def _one_way_mass_flow(
self,
*,
@@ -535,46 +808,63 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
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
mass_flow_parameter, _gas_velocity = self._one_way_flow_characteristics(
upstream_pressure=p_up,
downstream_pressure=downstream_pressure,
upstream_temperature=T_up,
)
return (
self.effective_cq
* self.effective_area
* p_up
* mass_flow_parameter
/ sqrt(T_up)
)
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)
if p_2 >= p_3:
upstream_port_name = "port_2"
upstream_pressure = p_2
downstream_pressure = p_3
flow_direction = 1.0
else:
upstream_port_name = "port_3"
upstream_pressure = p_3
downstream_pressure = p_2
flow_direction = -1.0
upstream_temperature = self._upstream_temperature(
"port_2" if p_2 >= p_3 else "port_3"
upstream_port_name
)
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
area = max(self.effective_area, 1.0e-18)
mass_flow_parameter, gas_velocity = self._one_way_flow_characteristics(
upstream_pressure=upstream_pressure,
downstream_pressure=downstream_pressure,
upstream_temperature=upstream_temperature,
)
# AMESim reports no vena-contracta velocity while the valve is closed.
# Signal propagation around a step can leave a round-off-sized opening,
# so apply the same numerical-zero convention to this diagnostic only.
if isclose(
self.opening,
0.0,
rel_tol=0.0,
abs_tol=_PNVO001_CLOSED_OPENING_ABS_TOL,
):
gas_velocity = 0.0
return {
"xv": self.opening,
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
"gasvel": m_flow / (density * area),
"cm": mass_flow_parameter,
"gasvel": flow_direction * gas_velocity,
}
def pressure_flow_equation_values(self) -> tuple[float, ...]:
return (
self.port_2.m_flow + self.port_3.m_flow,
self.port_2.m_flow - self.mass_flow(self.port_2.p, self.port_3.p),
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
@@ -606,6 +896,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self._connected_h = dict(connected_h)
self.port_2.h_outflow = connected_h["port_3"]
self.port_3.h_outflow = connected_h["port_2"]
@@ -614,39 +905,104 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
"""AMESim PNVO001 signal-controlled pneumatic orifice."""
MODEL_TYPE = "amesim_pnvo001"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.2.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
# Repeat the exact-sum promise on this concrete subclass deliberately.
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.signal("res", nominal_role="input"),
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("opening0", 1.0, label="初始开度", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"cq",
0.72,
label="流量系数 Cq",
quantity="dimensionless",
unit="",
minimum=1.0e-10,
maximum=1.0,
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"area0",
5.0e-6,
label="最大孔口面积",
quantity="area",
unit="m2",
minimum=0.0,
maximum=1.0,
description="阀门完全开启时的最大有效孔口面积。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"Cv",
0.5,
label="最大流量系数 Cv",
quantity="dimensionless",
unit="",
minimum=0.0,
description="选择 Cv 方式时使用的最大英制流量系数。",
visible_when=_FLOWSET_USES_CV,
),
ParameterDefinition(
"Kv",
0.4,
label="最大流量系数 Kv",
quantity="dimensionless",
unit="",
minimum=0.0,
description="选择 Kv 方式时使用的最大公制流量系数。",
visible_when=_FLOWSET_USES_KV,
),
ParameterDefinition(
"flowset",
1.0,
label="流量系数设置",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=3.0,
editor="choice",
options=_FLOW_COEFFICIENT_OPTIONS,
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
),
ParameterDefinition(
"opening0",
1.0,
label="初始开度",
quantity="dimensionless",
unit="",
minimum=0.0,
maximum=1.0,
description="信号尚未传播时使用的归一化初始开度;0 表示关闭,1 表示完全开启。",
),
)
RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
label="PNVO001 信号开度气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
symbol="amesim_pnvo001",
ports=(
PortDisplaySpec("res", "left", order=5),
PortDisplaySpec("port_2", "left", order=10),
PortDisplaySpec("port_3", "right", order=20),
# AMESim default geometry places port 2 right and port 3 left.
PortDisplaySpec("port_2", "right", order=10),
PortDisplaySpec("port_3", "left", order=20),
),
order=35,
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
cq: float = 0.72,
area0: float = 5.0e-6,
@@ -673,7 +1029,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
self.area0 = float(area0)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(gi)
self.flowset = self._integer_parameter("flowset", flowset)
if self.flowset not in {1, 2, 3}:
raise ValueError("PNVO001 signal-opening flowset must be 1, 2, or 3.")
@@ -685,13 +1041,14 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
self.port_2.h_outflow = initial_h
self.port_3 = self.register_declared_port("port_3")
self.port_3.h_outflow = initial_h
self._connected_h: dict[str, float] = {}
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> "AmesimPnvo001SignalOpening":
return cls(name=name, medium=medium, **dict(parameters))
File diff suppressed because it is too large. Load diff
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from math import isclose, isfinite
from types import MappingProxyType
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import GasMedium
AMESIM_BUILTIN_AIR_GAS_INDEX = 0
AMESIM_DEFAULT_GAS_INDEX = AMESIM_BUILTIN_AIR_GAS_INDEX
AMESIM_MIN_GAS_INDEX = AMESIM_BUILTIN_AIR_GAS_INDEX
AMESIM_MIN_DEFINED_GAS_INDEX = 1
AMESIM_MAX_GAS_INDEX = 99
AMESIM_GAS_INDEX_PARAMETER = ParameterDefinition(
"gi",
float(AMESIM_DEFAULT_GAS_INDEX),
label="介质物性模型(gi)",
quantity="dimensionless",
unit="",
minimum=float(AMESIM_MIN_GAS_INDEX),
maximum=float(AMESIM_MAX_GAS_INDEX),
editor="amesimGasReference",
description=(
"选择本元件使用的气体介质定义索引;0 表示内置空气,"
"1–99 引用画布中的介质定义组件。"
),
)
AMESIM_GAS_DEFINITION_INDEX_PARAMETER = ParameterDefinition(
"gi",
float(AMESIM_MIN_DEFINED_GAS_INDEX),
label="介质定义索引(gi)",
quantity="dimensionless",
unit="",
minimum=float(AMESIM_MIN_DEFINED_GAS_INDEX),
maximum=float(AMESIM_MAX_GAS_INDEX),
description=(
"介质定义在当前模型中的唯一索引;由画布自动分配,"
"0 保留给内置空气。"
),
)
def normalize_amesim_gas_index(value: float | int) -> int:
"""Validate an AMESim gas reference.
Index 0 is reserved for the built-in ideal-gas air profile. Positive
indices refer to medium-definition components placed in the project.
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError("AMESim gas type index gi must be a number.")
numeric = float(value)
if not isfinite(numeric):
raise ValueError("AMESim gas type index gi must be finite.")
rounded = round(numeric)
if not isclose(numeric, rounded, rel_tol=0.0, abs_tol=1.0e-12):
raise ValueError("AMESim gas type index gi must be an integer value.")
index = int(rounded)
if not AMESIM_MIN_GAS_INDEX <= index <= AMESIM_MAX_GAS_INDEX:
raise ValueError(
"AMESim gas type index gi must be between "
f"{AMESIM_MIN_GAS_INDEX} and {AMESIM_MAX_GAS_INDEX}."
)
return index
def normalize_amesim_defined_gas_index(value: float | int) -> int:
"""Validate a positive index owned by a project medium definition."""
index = normalize_amesim_gas_index(value)
if index < AMESIM_MIN_DEFINED_GAS_INDEX:
raise ValueError(
"AMESim medium definition index gi must be between "
f"{AMESIM_MIN_DEFINED_GAS_INDEX} and {AMESIM_MAX_GAS_INDEX}; "
"gi=0 is reserved for built-in ideal-gas air."
)
return index
@dataclass(frozen=True)
class AmesimGasDefinition:
"""One AMESim PNGD-style gas-definition slot.
``fluid_type`` and ``eos_type`` are intentionally optional today. They
reserve the metadata needed to map a future PNGD00 helium definition while
the executable behavior is supplied by ``medium``.
"""
gi: int
label: str
medium: GasMedium
fluid_type: int | None = None
eos_type: int | None = None
def __post_init__(self) -> None:
normalized = normalize_amesim_gas_index(self.gi)
object.__setattr__(self, "gi", normalized)
if not self.label.strip():
raise ValueError("AMESim gas definition label must not be empty.")
class AmesimGasRegistry:
"""Resolve AMESim component ``gi`` references to thermodynamic media."""
def __init__(
self,
definitions: Iterable[AmesimGasDefinition] = (),
*,
default_gi: int = AMESIM_DEFAULT_GAS_INDEX,
) -> None:
from app.simulation.components.amesim.media.mediums import (
AmesimIdealAirMedium,
)
self.default_gi = normalize_amesim_gas_index(default_gi)
self._definitions: dict[int, AmesimGasDefinition] = {
AMESIM_BUILTIN_AIR_GAS_INDEX: AmesimGasDefinition(
gi=AMESIM_BUILTIN_AIR_GAS_INDEX,
label="空气(理想气体,内置默认)",
medium=AmesimIdealAirMedium(),
)
}
for definition in definitions:
self.register(definition)
@property
def definitions(self) -> Mapping[int, AmesimGasDefinition]:
return MappingProxyType(self._definitions)
def register(self, definition: AmesimGasDefinition) -> None:
if not isinstance(definition, AmesimGasDefinition):
raise TypeError("AMESim gas registry entries must use AmesimGasDefinition.")
if definition.gi == AMESIM_BUILTIN_AIR_GAS_INDEX:
raise ValueError(
"AMESim gas type index gi=0 is reserved for built-in "
"ideal-gas air and cannot be replaced."
)
if definition.gi in self._definitions:
raise ValueError(
f"AMESim gas type index gi={definition.gi} is already defined."
)
self._definitions[definition.gi] = definition
def copy(self) -> AmesimGasRegistry:
"""Return an independent registry for one project compilation."""
copied = AmesimGasRegistry(
(
definition
for index, definition in self._definitions.items()
if index != AMESIM_BUILTIN_AIR_GAS_INDEX
),
default_gi=self.default_gi,
)
copied._definitions[AMESIM_BUILTIN_AIR_GAS_INDEX] = self._definitions[
AMESIM_BUILTIN_AIR_GAS_INDEX
]
return copied
def resolve(
self,
gi: float | int,
*,
component_name: str | None = None,
) -> GasMedium:
index = normalize_amesim_gas_index(gi)
try:
return self._definitions[index].medium
except KeyError as exc:
owner = f" for component '{component_name}'" if component_name else ""
available = ", ".join(str(index) for index in sorted(self._definitions))
available_message = available or "none"
raise ValueError(
f"AMESim gas type index gi={index}{owner} is not defined. "
"Register a PNGD-style gas definition before using this index. "
f"Available indices: {available_message}."
) from exc
@property
def default_medium(self) -> GasMedium:
return self.resolve(self.default_gi)
def resolve_network_media(
self,
component_gas_indices: Mapping[str, float | int | None],
pneumatic_connections: Iterable[tuple[str, str]],
) -> dict[str, GasMedium]:
"""Assign one medium to every connected pneumatic circuit.
Components without ``gi`` inherit the explicit index used by their
circuit. Conflicting indices inside one circuit are rejected instead
of silently mixing different gases.
"""
parents = {component_id: component_id for component_id in component_gas_indices}
def find(component_id: str) -> str:
parent = parents[component_id]
while parent != parents[parent]:
parent = parents[parent]
while component_id != parent:
next_component = parents[component_id]
parents[component_id] = parent
component_id = next_component
return parent
def union(left: str, right: str) -> None:
left_root = find(left)
right_root = find(right)
if left_root != right_root:
parents[right_root] = left_root
for source, target in pneumatic_connections:
if source in parents and target in parents:
union(source, target)
members_by_root: dict[str, list[str]] = {}
for component_id in component_gas_indices:
members_by_root.setdefault(find(component_id), []).append(component_id)
media: dict[str, GasMedium] = {}
for members in members_by_root.values():
indexed_components: dict[int, list[str]] = {}
for component_id in members:
raw_index = component_gas_indices[component_id]
if raw_index is None:
continue
index = normalize_amesim_gas_index(raw_index)
indexed_components.setdefault(index, []).append(component_id)
if len(indexed_components) > 1:
details = ", ".join(
f"gi={index} ({', '.join(sorted(component_ids))})"
for index, component_ids in sorted(indexed_components.items())
)
raise ValueError(
"Connected pneumatic circuit contains conflicting AMESim "
f"gas definitions: {details}."
)
index = (
next(iter(indexed_components))
if indexed_components
else self.default_gi
)
indexed_members = indexed_components.get(index, members)
medium = self.resolve(
index,
component_name=", ".join(sorted(indexed_members)),
)
for component_id in members:
media[component_id] = medium
return media
def default_amesim_gas_registry() -> AmesimGasRegistry:
"""Create a registry containing only built-in gi=0 ideal-gas air."""
return AmesimGasRegistry()
@@ -9,17 +9,56 @@ 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_OUTFLOW_REGULARIZATION_RATIO = 0.05
def _regularized_inverse_outflow(flow: float, transition_flow: float) -> float:
"""Return a C1 inverse that tends to zero as a negative flow vanishes."""
if flow >= 0.0:
return 0.0
transition_flow = max(float(transition_flow), 1.0e-12)
if -flow >= transition_flow:
return 1.0 / flow
return (
flow
* (2.0 * transition_flow * transition_flow - flow * flow)
/ transition_flow**4
)
class _AmesimPneumaticNode(AlgebraicComponent):
"""Shared implementation for AMESim pneumatic junction submodels.
PN3NODE2/P4NODE2 use port 2 as their pressure and temperature reference.
Non-reference outlet ports use that reference temperature. When port 2 is
an outlet, its enthalpy is the residual that closes the junction energy
balance, matching the AMESim dh2 causality.
"""
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
REFERENCE_PORT = "port_2"
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.temperature_reference_h = 0.0
for definition in self.PORTS:
setattr(self, definition.name, self.register_declared_port(definition.name))
def pressure_flow_equation_values(self) -> tuple[float, ...]:
reference = self.get_port(self.REFERENCE_PORT)
return tuple(
self.get_port(definition.name).p - reference.p
for definition in self.PORTS
if definition.name != self.REFERENCE_PORT
) + (
sum(
self.get_port(definition.name).m_flow
for definition in self.PORTS
),
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
reference = self.get_port(self.REFERENCE_PORT)
residuals: list[EquationResidual] = []
@@ -58,6 +97,10 @@ class _AmesimPneumaticNode(AlgebraicComponent):
return tuple(residuals)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.temperature_reference_h = connected_h.get(
self.REFERENCE_PORT,
sum(connected_h.values()) / len(connected_h) if connected_h else 0.0,
)
incoming = [
(port.m_flow, connected_h[name])
for name, port in self.ports.items()
@@ -67,19 +110,62 @@ class _AmesimPneumaticNode(AlgebraicComponent):
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,
mixed_h = self.temperature_reference_h
reference_port = self.get_port(self.REFERENCE_PORT)
for name, port in self.ports.items():
port.h_outflow = (
mixed_h
if name == self.REFERENCE_PORT
else self.temperature_reference_h
)
if reference_port.m_flow < 0.0:
energy_without_reference = sum(
port.m_flow
* (
connected_h[name]
if port.m_flow > 1e-12
else self.temperature_reference_h
)
for name, port in self.ports.items()
if name != self.REFERENCE_PORT
)
non_reference_flow_scale = sum(
abs(port.m_flow)
for name, port in self.ports.items()
if name != self.REFERENCE_PORT
)
transition_flow = (
_REFERENCE_OUTFLOW_REGULARIZATION_RATIO
* non_reference_flow_scale
)
# Port 2 carries AMESim's residual-energy causality. Exact
# division is singular when its outflow reverses through zero, so
# use a C1 band that matches the exact balance at its boundary and
# tends to the mixed enthalpy at zero flow.
inverse_flow = _regularized_inverse_outflow(
reference_port.m_flow,
transition_flow,
)
energy_residual_at_mixed_h = (
energy_without_reference
+ reference_port.m_flow * mixed_h
)
reference_port.h_outflow = (
mixed_h - energy_residual_at_mixed_h * inverse_flow
)
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"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -91,7 +177,7 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
label="PN3NODE2 三端气动节点",
library_id="amesim",
category_id="junctions",
symbol="tee",
symbol="amesim_pn3node2",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -115,7 +201,11 @@ class AmesimP4Node2(_AmesimPneumaticNode):
"""AMESim P4NODE2 pneumatic four-port junction."""
MODEL_TYPE = "amesim_p4node2"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -128,7 +218,7 @@ class AmesimP4Node2(_AmesimPneumaticNode):
label="P4NODE2 四端气动节点",
library_id="amesim",
category_id="junctions",
symbol="generic",
symbol="amesim_p4node2",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
+5 -1
View File
@@ -9,11 +9,12 @@ from app.simulation.core.catalog import (
LIBRARY = ComponentLibrarySpec(
id="amesim",
label="AMESim 组件库",
version="0.1.0",
version="0.3.0",
source_package="app.simulation.components.amesim",
temporary=True,
order=200,
categories=(
ComponentCategorySpec(id="media", label="介质物性", order=5),
ComponentCategorySpec(id="storage", label="储能元件", order=10),
ComponentCategorySpec(id="flow", label="流动元件", order=20),
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
@@ -22,6 +23,8 @@ LIBRARY = ComponentLibrarySpec(
ComponentCategorySpec(id="mechanical", label="机械元件", order=60),
),
models=(
"app.simulation.components.amesim.media.properties:AmesimIdealAirMediumDefinition",
"app.simulation.components.amesim.media.properties:AmesimHeliumMediumDefinition",
"app.simulation.components.amesim.boundary.sources:AmesimPnpl01",
"app.simulation.components.amesim.signals.sources:AmesimStep0",
"app.simulation.components.amesim.signals.sources:AmesimUd00",
@@ -30,6 +33,7 @@ LIBRARY = ComponentLibrarySpec(
"app.simulation.components.amesim.mechanical.translational:AmesimMecmas21",
"app.simulation.components.amesim.mechanical.translational:AmesimLstp00a",
"app.simulation.components.amesim.mechanical.translational:AmesimLmechn1",
"app.simulation.components.amesim.mechanical.pistons:AmesimPnrp17",
"app.simulation.components.amesim.storage.chambers:AmesimPnch023",
"app.simulation.components.amesim.storage.chambers:AmesimPnch012",
"app.simulation.components.amesim.flow.orifices:AmesimPnor001",
@@ -0,0 +1,305 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from math import isfinite, pi
from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER,
normalize_amesim_gas_index,
)
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 GasMedium
from app.simulation.core.ports import PortDefinition
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
@dataclass(frozen=True)
class Pnrp17Linearization:
volume: float
volume_flow: float
pressure_force: float
volume_tangent: tuple[float, ...]
volume_flow_tangent: tuple[float, ...]
pressure_force_tangent: tuple[float, ...]
valid: bool = True
reason: str | None = None
class AmesimPnrp17(AlgebraicComponent):
"""AMESim PNRP17 pneumatic piston with two mechanical faces.
Mechanical ports 2/5 share the piston-side motion and ports 3/4 share the
cylinder-side motion. The pneumatic port contributes its swept volume and
volume rate to the connected variable-volume chamber.
"""
MODEL_TYPE = "amesim_pnrp17"
MODEL_VERSION = "0.1.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.mechanical_translational("port_2"),
PortDefinition.mechanical_translational("port_3"),
PortDefinition.mechanical_translational("port_4"),
PortDefinition.mechanical_translational("port_5"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"dp",
0.2,
label="活塞直径",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
description="活塞外径;与活塞杆直径共同确定有效受压面积。",
),
ParameterDefinition(
"dr",
0.001,
label="活塞杆直径",
quantity="length",
unit="m",
minimum=0.0,
description="穿过气室一侧的活塞杆直径,必须不大于活塞直径。",
),
ParameterDefinition(
"x0",
0.0,
label="初始腔长",
quantity="length",
unit="m",
description="机械端位移均为零时的气动腔长度。",
),
)
RESULT_VARIABLES = (
ResultVariableDefinition("volume", "扫掠容积", "volume", "m3", "derived", 10),
ResultVariableDefinition(
"volume_flow",
"扫掠容积变化率",
"volume_flow",
"m3/s",
"derived",
20,
),
ResultVariableDefinition("length", "气动腔长度", "length", "m", "derived", 30),
ResultVariableDefinition(
"pressure_force",
"气压力",
"force",
"N",
"derived",
40,
),
)
DISPLAY = ComponentDisplaySpec(
label="PNRP17 气动活塞",
library_id="amesim",
category_id="mechanical",
symbol="amesim_pnrp17",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_3", "left", order=20),
PortDisplaySpec("port_2", "left", order=30),
PortDisplaySpec("port_4", "right", order=40),
PortDisplaySpec("port_5", "right", order=50),
),
order=60,
)
def __init__(
self,
name: str,
medium: GasMedium,
*,
gi: float = 0.0,
dp: float = 0.2,
dr: float = 0.001,
x0: float = 0.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values({"gi": gi, "dp": dp, "dr": dr, "x0": x0})
self.medium = medium
self.gi = normalize_amesim_gas_index(gi)
self.dp = float(dp)
self.dr = float(dr)
self.x0 = float(x0)
if self.dr > self.dp:
raise ValueError("PNRP17 rod diameter dr must not exceed piston diameter dp.")
for definition in self.PORTS:
port = self.register_declared_port(definition.name)
setattr(self, definition.name, port)
self.port_1.h_outflow = medium.specific_enthalpy(medium.T_ref)
@classmethod
def create(
cls,
*,
name: str,
medium: GasMedium,
parameters: Mapping[str, float],
) -> "AmesimPnrp17":
return cls(name=name, medium=medium, **dict(parameters))
@property
def effective_area(self) -> float:
return pi * (self.dp * self.dp - self.dr * self.dr) / 4.0
@property
def chamber_length(self) -> float:
return self.x0 + self.port_5.x - self.port_4.x
@property
def chamber_volume(self) -> float:
return self.effective_area * self.chamber_length
@property
def chamber_volume_flow(self) -> float:
return self.effective_area * (self.port_5.v - self.port_4.v)
@property
def pressure_force(self) -> float:
return (self.port_1.p - AMESIM_REFERENCE_PRESSURE_PA) * self.effective_area
def pressure_flow_equation_values(self) -> tuple[float, ...]:
values = [self.port_1.m_flow]
effort_pairs = (("port_2", "port_5"), ("port_3", "port_4"))
for first_name, second_name in effort_pairs:
first = self.get_port(first_name)
second = self.get_port(second_name)
values.extend((first.x - second.x, first.v - second.v))
force = self.pressure_force
values.extend(
(
self.port_2.f + self.port_5.f + force,
self.port_3.f + self.port_4.f - force,
)
)
return tuple(values)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
effort_pairs = (("port_2", "port_5"), ("port_3", "port_4"))
residuals: list[EquationResidual] = [
EquationResidual(
id=f"{self.name}:pneumatic_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,
)
]
for first_name, second_name in effort_pairs:
first = self.get_port(first_name)
second = self.get_port(second_name)
for variable in ("x", "v"):
residuals.append(
EquationResidual(
id=f"{self.name}:{first_name}_{second_name}_{variable}_equal",
owner="component",
owner_id=self.name,
relation="equal",
variables=(
f"{self.name}.{first_name}.{variable}",
f"{self.name}.{second_name}.{variable}",
),
role="effort",
value=getattr(first, variable) - getattr(second, variable),
)
)
force = self.pressure_force
residuals.extend(
(
EquationResidual(
id=f"{self.name}:piston_side_force_balance",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_2.f", f"{self.name}.port_5.f", f"{self.name}.port_1.p"),
role="flow",
value=self.port_2.f + self.port_5.f + force,
),
EquationResidual(
id=f"{self.name}:cylinder_side_force_balance",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_3.f", f"{self.name}.port_4.f", f"{self.name}.port_1.p"),
role="flow",
value=self.port_3.f + self.port_4.f - force,
),
)
)
return tuple(residuals)
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
return {"port_1": (self.chamber_volume, self.chamber_volume_flow)}
def linearize_geometry_and_force(
self,
port_4_x_tangent: Sequence[float],
port_5_x_tangent: Sequence[float],
port_4_v_tangent: Sequence[float],
port_5_v_tangent: Sequence[float],
port_1_pressure_tangent: Sequence[float],
) -> Pnrp17Linearization:
"""Return exact piston geometry and pressure-force tangents."""
vectors = tuple(
tuple(float(value) for value in values)
for values in (
port_4_x_tangent,
port_5_x_tangent,
port_4_v_tangent,
port_5_v_tangent,
port_1_pressure_tangent,
)
)
widths = {len(values) for values in vectors}
if len(widths) != 1:
raise ValueError("PNRP17 tangent vectors must have equal lengths.")
valid = all(isfinite(value) for values in vectors for value in values)
area = self.effective_area
volume_tangent = tuple(
area * (right - left)
for left, right in zip(vectors[0], vectors[1], strict=True)
)
volume_flow_tangent = tuple(
area * (right - left)
for left, right in zip(vectors[2], vectors[3], strict=True)
)
pressure_force_tangent = tuple(
area * value for value in vectors[4]
)
return Pnrp17Linearization(
volume=self.chamber_volume,
volume_flow=self.chamber_volume_flow,
pressure_force=self.pressure_force,
volume_tangent=volume_tangent,
volume_flow_tangent=volume_flow_tangent,
pressure_force_tangent=pressure_force_tangent,
valid=valid,
reason=None if valid else "non_finite_tangent_input",
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_1.h_outflow = connected_h.get(
"port_1",
self.medium.specific_enthalpy(self.medium.T_ref),
)
def component_result_values(self) -> Mapping[str, float]:
return {
"volume": self.chamber_volume,
"volume_flow": self.chamber_volume_flow,
"length": self.chamber_length,
"pressure_force": self.pressure_force,
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,33 @@
"""AMESim medium-property definition components."""
from app.simulation.components.amesim.media.mediums import (
AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL,
AMESIM_AIR_PROPERTY_MODELS,
AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL,
AMESIM_HELIUM_PROPERTY_MODELS,
AmesimGasPropertyModelSpec,
AmesimHeliumPengRobinsonMedium,
AmesimIdealAirMedium,
)
from app.simulation.components.amesim.media.properties import (
AMESIM_AIR_PROPERTY_MODEL_PARAMETER,
AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER,
AmesimGasMediumDefinitionComponent,
AmesimHeliumMediumDefinition,
AmesimIdealAirMediumDefinition,
)
__all__ = (
"AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL",
"AMESIM_AIR_PROPERTY_MODELS",
"AMESIM_AIR_PROPERTY_MODEL_PARAMETER",
"AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL",
"AMESIM_HELIUM_PROPERTY_MODELS",
"AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER",
"AmesimGasMediumDefinitionComponent",
"AmesimGasPropertyModelSpec",
"AmesimHeliumMediumDefinition",
"AmesimHeliumPengRobinsonMedium",
"AmesimIdealAirMedium",
"AmesimIdealAirMediumDefinition",
)
@@ -0,0 +1,517 @@
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from math import exp, isfinite, log
from typing import ClassVar
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.core.medium import (
GasMedium,
IdealGasMedium,
ThermodynamicProperties,
ThermodynamicPropertiesLinearization,
ThermodynamicPropertyTangents,
)
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from app.simulation.performance import profile_property, record_property_iterations
from app.simulation.property_cache import cache_property_calculation
@dataclass(frozen=True)
class AmesimIdealAirMedium(IdealGasMedium):
"""AMESim air properties evaluated with the ideal-gas method.
Substance identity and property method are part of the concrete Python
type. A future air correlation or helium Peng-Robinson implementation can
therefore coexist as a sibling type without turning ``gi`` into a fluid
enumeration.
"""
SUBSTANCE_ID: ClassVar[str] = "air"
PROPERTY_METHOD_ID: ClassVar[str] = "ideal_gas"
name: str = "AMESimAirIdealGas"
R_gas: float = 287.0
cp_ref: float = 1005.0
T_ref: float = 300.0
cp_slope: float = 0.0
viscosity_ref: float = 1.82e-5
viscosity_T_ref: float = 293.15
sutherland_constant: float = 110.4
@dataclass(frozen=True)
class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
"""AMESim helium with a Peng-Robinson mechanical equation of state.
The pressure-density-temperature relation is evaluated by the shared
``HELIUM_PR`` fluid. The caloric reference follows the constant NASA
polynomial from Simcenter Amesim 2404 ``helium_cp_h_s.data``.
"""
SUBSTANCE_ID: ClassVar[str] = "helium"
PROPERTY_METHOD_ID: ClassVar[str] = "peng_robinson"
fluid: ClassVar[PengRobinsonFluid] = HELIUM_PR
nasa_cp_over_R: ClassVar[float] = 2.5
nasa_enthalpy_constant_K: ClassVar[float] = -745.375
nasa_viscosity_coefficients: ClassVar[tuple[float, float, float, float]] = (
0.7501594,
35.76324,
-2212.129,
0.9212635,
)
name: str = "AMESimHeliumPengRobinson"
R_gas: float = HELIUM_PR.specific_gas_constant
cp_ref: float = nasa_cp_over_R * HELIUM_PR.specific_gas_constant
T_ref: float = 293.15
cp_slope: float = 0.0
viscosity_ref: float = 1.96e-5
viscosity_T_ref: float = 293.15
sutherland_constant: float = 79.4
@property
def cv(self) -> float:
return (self.nasa_cp_over_R - 1.0) * self.R_gas
def cv_at_temperature(self, T: float) -> float:
del T
return self.cv
def diagnostic_dynamic_viscosity(self, T: float) -> float:
"""Return the AMESim NASA-table viscosity used by pipe diagnostics.
pn2pipefr reports Reynolds number with sagum viscosity. Keep this
separate from dynamic_viscosity so matching that diagnostic cannot
alter the already-validated pipe flow or friction dynamics.
"""
if T <= 0.0:
raise ValueError("Temperature must be positive.")
a, b, c, d = self.nasa_viscosity_coefficients
return 1.0e-7 * exp(a * log(T) + b / T + c / (T * T) + d)
@profile_property("density")
@cache_property_calculation("density")
def density(self, p: float, T: float) -> float:
return self.fluid.density(p, T)
def _real_heat_capacities(
self,
p: float,
T: float,
) -> tuple[float, float, float, float, float]:
density = self.density(p, T)
pressure_density_derivative = (
self.fluid.pressure_density_derivative_at_temperature(
T,
density,
)
)
pressure_temperature_derivative = (
self.fluid.pressure_temperature_derivative_at_density(
T,
density,
)
)
cv = (
self.cv_at_temperature(T)
+ self.fluid.residual_isochoric_heat_capacity_at_density(T, density)
)
cp = (
cv
+ T
* pressure_temperature_derivative
* pressure_temperature_derivative
/ (density * density * pressure_density_derivative)
)
if cp <= 0.0 or cv <= 0.0:
raise ValueError("Real-gas heat capacities must be positive.")
return (
cp,
cv,
density,
pressure_density_derivative,
pressure_temperature_derivative,
)
def _local_isentropic_density_pressure_factor(
self,
p: float,
T: float,
) -> tuple[float, float]:
cp, cv, density, pressure_density_derivative, pressure_temperature_derivative = (
self._real_heat_capacities(p, T)
)
heat_capacity_ratio = cp / cv
factor = p / (
density * pressure_density_derivative * heat_capacity_ratio
)
exponent = (
p
* (heat_capacity_ratio - 1.0)
/ (
heat_capacity_ratio
* T
* pressure_temperature_derivative
)
)
return factor, exponent
@profile_property("isentropic_density_pressure_factor")
@cache_property_calculation("isentropic_density_pressure_factor")
def isentropic_density_pressure_factor(
self,
p: float,
T: float,
downstream_pressure: float | None = None,
) -> float:
upstream_factor, isentropic_temperature_exponent = (
self._local_isentropic_density_pressure_factor(p, T)
)
if downstream_pressure is None or downstream_pressure >= p:
return upstream_factor
pressure_ratio = max(downstream_pressure / p, 1.0e-12)
isentropic_temperature = max(
T * pressure_ratio**isentropic_temperature_exponent,
2.2,
)
downstream_factor, _unused_exponent = (
self._local_isentropic_density_pressure_factor(
max(downstream_pressure, 1.0),
isentropic_temperature,
)
)
# AMESim 2404 saggs_ evaluates the local factor at the upstream
# state and at an approximate isentropic downstream state.
return 0.5 * (upstream_factor + downstream_factor)
def pressure(self, m: float, T: float, V: float) -> float:
if V <= 0.0:
raise ValueError("Volume must stay positive.")
return self.fluid.pressure_from_density(T, m / V)
@profile_property("specific_internal_energy")
def specific_internal_energy(self, T: float) -> float:
return self.R_gas * (
(self.nasa_cp_over_R - 1.0) * T
+ self.nasa_enthalpy_constant_K
)
@profile_property("specific_internal_energy_at_pressure")
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
density = self.density(p, T)
return (
self.specific_internal_energy(T)
+ self.fluid.residual_specific_internal_energy_at_density(T, density)
)
@profile_property("specific_enthalpy")
def specific_enthalpy(self, T: float) -> float:
return self.R_gas * (
self.nasa_cp_over_R * T
+ self.nasa_enthalpy_constant_K
)
@profile_property("specific_enthalpy_at_pressure")
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
return self.specific_enthalpy(T) + self.fluid.residual_specific_enthalpy(p, T)
def temperature_from_internal_energy(self, u: float) -> float:
return (
u / self.R_gas - self.nasa_enthalpy_constant_K
) / (self.nasa_cp_over_R - 1.0)
def temperature_from_enthalpy(self, h: float) -> float:
return (
h / self.R_gas - self.nasa_enthalpy_constant_K
) / self.nasa_cp_over_R
@profile_property("temperature_from_pressure_enthalpy")
@cache_property_calculation("temperature_from_pressure_enthalpy")
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
temperature = max(self.temperature_from_enthalpy(h), 2.2)
for _iteration in range(16):
residual_enthalpy = self.fluid.residual_specific_enthalpy(p, temperature)
next_temperature = max(
self.temperature_from_enthalpy(h - residual_enthalpy),
2.2,
)
if abs(next_temperature - temperature) <= 1.0e-10 * max(
temperature,
1.0,
):
record_property_iterations(
"temperature_from_pressure_enthalpy",
_iteration + 1,
True,
)
return next_temperature
temperature = next_temperature
record_property_iterations(
"temperature_from_pressure_enthalpy",
16,
False,
)
return temperature
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
if m <= 0.0:
raise RecoverableTrialStateError(
"Mass must stay positive when recovering temperature."
)
return self.temperature_from_internal_energy(U / m)
@profile_property("properties_from_mU")
@cache_property_calculation("properties_from_mU")
def properties_from_mU(
self,
m: float,
U: float,
V: float,
) -> ThermodynamicProperties:
"""Recover a real-gas state, reusing exact repeated evaluations.
Implicit integration asks several component interfaces for the same
``(m, U, V)`` state while closing one RHS evaluation and while building
finite-difference Jacobians. The calculation is pure and its result is
immutable, so an exact-key bounded cache avoids repeating the
Peng-Robinson temperature iteration without changing model semantics.
"""
if m <= 0.0:
raise RecoverableTrialStateError(
"Mass must stay positive when recovering temperature."
)
if V <= 0.0:
raise ValueError("Volume must stay positive.")
density = m / V
target_internal_energy = U / m
temperature = max(
self.temperature_from_internal_energy(target_internal_energy),
2.2,
)
converged = False
for _iteration in range(16):
residual_internal_energy = (
self.fluid.residual_specific_internal_energy_at_density(
temperature,
density,
)
)
next_temperature = max(
self.temperature_from_internal_energy(
target_internal_energy - residual_internal_energy
),
2.2,
)
if abs(next_temperature - temperature) <= 1.0e-10 * max(
temperature,
1.0,
):
temperature = next_temperature
converged = True
break
temperature = next_temperature
record_property_iterations(
"properties_from_mU",
_iteration + 1,
converged,
)
pressure = self.fluid.pressure_from_density(temperature, density)
return ThermodynamicProperties(
p=pressure,
T=temperature,
rho=density,
u=target_internal_energy,
h=self.specific_enthalpy_at_pressure(
pressure,
temperature,
),
)
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization:
"""Implicitly differentiate the Peng-Robinson m/U/V recovery."""
dm_values = tuple(float(value) for value in dm)
dU_values = tuple(float(value) for value in dU)
dV_values = tuple(float(value) for value in dV)
if not (len(dm_values) == len(dU_values) == len(dV_values)):
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
props = properties or self.properties_from_mU(m, U, V)
width = len(dm_values)
def invalid(reason: str) -> ThermodynamicPropertiesLinearization:
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason=reason,
)
expected_density = m / V
expected_internal_energy = U / m
if (
abs(props.rho - expected_density)
> 1.0e-12 * max(abs(expected_density), 1.0)
or abs(props.u - expected_internal_energy)
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
):
return invalid("properties_primal_mismatch")
if not all(
isfinite(value)
for values in (dm_values, dU_values, dV_values)
for value in values
):
return invalid("non_finite_tangent_input")
if props.T <= 2.2 * (1.0 + 1.0e-10):
return invalid("temperature_floor_boundary")
pressure_temperature_derivative = (
self.fluid.pressure_temperature_derivative_at_density(
props.T,
props.rho,
)
)
pressure_density_derivative = (
self.fluid.pressure_density_derivative_at_temperature(
props.T,
props.rho,
)
)
cv = (
self.cv_at_temperature(props.T)
+ self.fluid.residual_isochoric_heat_capacity_at_density(
props.T,
props.rho,
)
)
recovered_internal_energy = (
self.specific_internal_energy(props.T)
+ self.fluid.residual_specific_internal_energy_at_density(
props.T,
props.rho,
)
)
recovery_scale = max(
abs(props.u),
abs(cv * props.T) if isfinite(cv) else 0.0,
1.0,
)
if (
not all(
isfinite(value)
for value in (
pressure_temperature_derivative,
pressure_density_derivative,
cv,
recovered_internal_energy,
)
)
or cv <= 0.0
):
return invalid("invalid_peng_robinson_derivative")
if abs(recovered_internal_energy - props.u) > 1.0e-8 * recovery_scale:
return invalid("properties_recovery_not_converged")
internal_energy_density_derivative = (
props.p - props.T * pressure_temperature_derivative
) / (props.rho * props.rho)
drho: list[float] = []
du: list[float] = []
dT: list[float] = []
dp: list[float] = []
dh: list[float] = []
for mass_tangent, energy_tangent, volume_tangent in zip(
dm_values,
dU_values,
dV_values,
strict=True,
):
density_tangent = (
mass_tangent / V - m * volume_tangent / (V * V)
)
internal_energy_tangent = (
energy_tangent / m - U * mass_tangent / (m * m)
)
temperature_tangent = (
internal_energy_tangent
- internal_energy_density_derivative * density_tangent
) / cv
pressure_tangent = (
pressure_temperature_derivative * temperature_tangent
+ pressure_density_derivative * density_tangent
)
enthalpy_tangent = (
internal_energy_tangent
+ pressure_tangent / props.rho
- props.p * density_tangent / (props.rho * props.rho)
)
drho.append(density_tangent)
du.append(internal_energy_tangent)
dT.append(temperature_tangent)
dp.append(pressure_tangent)
dh.append(enthalpy_tangent)
tangent_values = (*drho, *du, *dT, *dp, *dh)
if not all(isfinite(value) for value in tangent_values):
return invalid("non_finite_property_tangent")
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents(
p=tuple(dp),
T=tuple(dT),
rho=tuple(drho),
u=tuple(du),
h=tuple(dh),
),
)
@dataclass(frozen=True)
class AmesimGasPropertyModelSpec:
"""A selectable calculation method for one AMESim gas substance."""
value: int
label: str
method_id: str
factory: Callable[[], GasMedium]
eos_type: int
def build_medium(self) -> GasMedium:
return self.factory()
AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL = 0
AMESIM_AIR_PROPERTY_MODELS = (
AmesimGasPropertyModelSpec(
value=AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL,
label="理想气体",
method_id=AmesimIdealAirMedium.PROPERTY_METHOD_ID,
factory=AmesimIdealAirMedium,
eos_type=1,
),
)
AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL = 0
AMESIM_HELIUM_PROPERTY_MODELS = (
AmesimGasPropertyModelSpec(
value=AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL,
label="Peng–Robinson",
method_id=AmesimHeliumPengRobinsonMedium.PROPERTY_METHOD_ID,
factory=AmesimHeliumPengRobinsonMedium,
eos_type=6,
),
)
@@ -0,0 +1,196 @@
from __future__ import annotations
from abc import ABC
from collections.abc import Mapping
from app.simulation.components.amesim.gases import (
AMESIM_GAS_DEFINITION_INDEX_PARAMETER,
AmesimGasDefinition,
normalize_amesim_defined_gas_index,
)
from app.simulation.components.amesim.media.mediums import (
AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL,
AMESIM_AIR_PROPERTY_MODELS,
AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL,
AMESIM_HELIUM_PROPERTY_MODELS,
AmesimGasPropertyModelSpec,
)
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec
from app.simulation.core.medium import GasMedium
from app.simulation.core.metadata import ParameterDefinition, ParameterOption
AMESIM_AIR_PROPERTY_MODEL_PARAMETER = ParameterDefinition(
"property_model",
float(AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL),
label="物性计算模型",
quantity="dimensionless",
unit="",
minimum=float(min(model.value for model in AMESIM_AIR_PROPERTY_MODELS)),
maximum=float(max(model.value for model in AMESIM_AIR_PROPERTY_MODELS)),
editor="amesimGasPropertyModel",
options=tuple(
ParameterOption(value=model.value, label=model.label)
for model in AMESIM_AIR_PROPERTY_MODELS
),
description="选择空气介质的物性计算方法;当前首版提供理想气体模型。",
)
AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER = ParameterDefinition(
"property_model",
float(AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL),
label="物性计算模型",
quantity="dimensionless",
unit="",
minimum=float(min(model.value for model in AMESIM_HELIUM_PROPERTY_MODELS)),
maximum=float(max(model.value for model in AMESIM_HELIUM_PROPERTY_MODELS)),
editor="amesimGasPropertyModel",
options=tuple(
ParameterOption(value=model.value, label=model.label)
for model in AMESIM_HELIUM_PROPERTY_MODELS
),
description=(
"选择氦气介质的物性计算方法;当前首版提供 "
"Peng–Robinson 状态方程模型。"
),
)
class AmesimGasMediumDefinitionComponent(AlgebraicComponent, ABC):
"""Compile-time definition of one project-scoped AMESim gas medium.
Concrete subclasses declare one substance and its available calculation
methods; each instance selects a method through ``property_model``. They
deliberately expose no physical ports or equations: the compiler consumes
them before it creates the simulation network.
"""
IS_AMESIM_GAS_MEDIUM_DEFINITION = True
MEDIUM_LABEL = ""
FLUID_TYPE: int | None = None
PROPERTY_MODELS: tuple[AmesimGasPropertyModelSpec, ...] = ()
def __init__(
self,
name: str,
gi: float,
property_model: float = float(AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL),
) -> None:
super().__init__(name)
self.gi = normalize_amesim_defined_gas_index(gi)
self.property_model = self._resolve_property_model(property_model).value
self.set_parameter_values(
{
"gi": self.gi,
"property_model": self.property_model,
}
)
def _resolve_property_model(
self,
value: float | int,
) -> AmesimGasPropertyModelSpec:
for model in self.PROPERTY_MODELS:
if float(model.value) == float(value):
return model
available = ", ".join(str(model.value) for model in self.PROPERTY_MODELS)
raise ValueError(
f"AMESim medium definition '{self.name}' does not support property "
f"model {value:g}; available models: {available or 'none'}."
)
def build_medium(self) -> GasMedium:
"""Create the executable property model selected by this instance."""
return self._resolve_property_model(self.property_model).build_medium()
def gas_definition(self) -> AmesimGasDefinition:
model = self._resolve_property_model(self.property_model)
return AmesimGasDefinition(
gi=self.gi,
label=f"{self.MEDIUM_LABEL}({model.label})",
medium=self.build_medium(),
fluid_type=self.FLUID_TYPE,
eos_type=model.eos_type,
)
class AmesimIdealAirMediumDefinition(AmesimGasMediumDefinitionComponent):
"""Project gas slot using the built-in ideal-gas air property method."""
MODEL_TYPE = "amesim_ideal_air_medium"
MODEL_VERSION = "0.2.0"
PORTS = ()
PARAMETERS = (
AMESIM_GAS_DEFINITION_INDEX_PARAMETER,
AMESIM_AIR_PROPERTY_MODEL_PARAMETER,
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="空气介质定义",
library_id="amesim",
category_id="media",
symbol="amesim_ideal_air_medium",
ports=(),
order=10,
role="amesimGasMediumDefinition",
)
MEDIUM_LABEL = "空气"
FLUID_TYPE = 2
PROPERTY_MODELS = AMESIM_AIR_PROPERTY_MODELS
@classmethod
def create(
cls,
*,
name: str,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimIdealAirMediumDefinition:
del medium
return cls(
name=name,
gi=parameters["gi"],
property_model=parameters["property_model"],
)
class AmesimHeliumMediumDefinition(AmesimGasMediumDefinitionComponent):
"""Project gas slot using the AMESim helium Peng-Robinson method."""
MODEL_TYPE = "amesim_helium_medium"
MODEL_VERSION = "0.1.0"
PORTS = ()
PARAMETERS = (
AMESIM_GAS_DEFINITION_INDEX_PARAMETER,
AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER,
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="氦气介质定义",
library_id="amesim",
category_id="media",
symbol="amesim_helium_medium",
ports=(),
order=20,
role="amesimGasMediumDefinition",
)
MEDIUM_LABEL = "氦气"
FLUID_TYPE = 12
PROPERTY_MODELS = AMESIM_HELIUM_PROPERTY_MODELS
@classmethod
def create(
cls,
*,
name: str,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimHeliumMediumDefinition:
del medium
return cls(
name=name,
gi=parameters["gi"],
property_model=parameters["property_model"],
)
@@ -1,14 +1,74 @@
from __future__ import annotations
from collections.abc import Mapping
from math import floor
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.catalog import (
ComponentDisplaySpec,
ParameterGroupDisplaySpec,
PortDisplaySpec,
)
from app.simulation.core.metadata import (
ParameterCondition,
ParameterDefinition,
ParameterOption,
ResultVariableDefinition,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
def _ud00_stage_parameters(index: int) -> tuple[ParameterDefinition, ...]:
visible_when = (
()
if index == 1
else (
ParameterCondition(
"nstages",
tuple(float(stage_count) for stage_count in range(index, 9)),
),
)
)
return (
ParameterDefinition(
f"start{index}",
0.0 if index == 1 else 1.0,
label=f"第 {index} 段起点",
quantity="dimensionless",
unit="",
description=f"第 {index} 段开始时的输出值。",
visible_when=visible_when,
),
ParameterDefinition(
f"end{index}",
1.0,
label=f"第 {index} 段终点",
quantity="dimensionless",
unit="",
description=f"第 {index} 段结束时的输出值。",
visible_when=visible_when,
),
ParameterDefinition(
f"t{index}",
1.0 if index == 1 else 0.0,
label=f"第 {index} 段时长",
quantity="time",
unit="s",
minimum=0.0,
description=f"第 {index} 段的持续时间。",
visible_when=visible_when,
),
)
_UD00_STAGE_PARAMETERS = tuple(
parameter
for stage_index in range(1, 9)
for parameter in _ud00_stage_parameters(stage_index)
)
class AmesimStep0(AlgebraicComponent):
"""AMESim STEP0 scalar step signal source."""
@@ -27,7 +87,7 @@ class AmesimStep0(AlgebraicComponent):
label="STEP0 阶跃信号",
library_id="amesim",
category_id="signals",
symbol="signal",
symbol="amesim_step0",
ports=(PortDisplaySpec("out", "right", order=10),),
order=10,
)
@@ -71,6 +131,15 @@ class AmesimStep0(AlgebraicComponent):
def signal_output_values(self, time: float) -> dict[str, float]:
return {"out": self.output_at(time)}
def signal_event_times(
self,
start_time: float,
stop_time: float,
) -> tuple[float, ...]:
"""Expose the exact STEP0 switch time as an integration split point."""
return (self.time,) if start_time < self.time < stop_time else ()
def component_result_values(self) -> Mapping[str, float]:
return {"y": self.out.signal}
@@ -79,36 +148,48 @@ class AmesimUd00(AlgebraicComponent):
"""AMESim UD00 piecewise-linear scalar signal source."""
MODEL_TYPE = "amesim_ud00"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.2.0"
PORTS = (PortDefinition.signal("out", nominal_role="output"),)
PARAMETERS = (
ParameterDefinition("tstart", 0.0, label="启动时间", quantity="time", unit="s"),
ParameterDefinition("start1", 0.0, label="第 1 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end1", 1.0, label="第 1 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t1", 1.0, label="第 1 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start2", 1.0, label="第 2 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end2", 1.0, label="第 2 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t2", 0.0, label="第 2 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start3", 1.0, label="第 3 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end3", 1.0, label="第 3 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t3", 0.0, label="第 3 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start4", 1.0, label="第 4 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end4", 1.0, label="第 4 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t4", 0.0, label="第 4 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start5", 1.0, label="第 5 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end5", 1.0, label="第 5 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t5", 0.0, label="第 5 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start6", 1.0, label="第 6 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end6", 1.0, label="第 6 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t6", 0.0, label="第 6 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start7", 1.0, label="第 7 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end7", 1.0, label="第 7 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t7", 0.0, label="第 7 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start8", 1.0, label="第 8 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end8", 1.0, label="第 8 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t8", 0.0, label="第 8 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("nstages", 1.0, label="段数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0),
ParameterDefinition("iscyclic", 0.0, label="循环", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
ParameterDefinition(
"tstart",
0.0,
label="启动时间",
quantity="time",
unit="s",
description="分段信号开始输出第一段之前的等待时间。",
),
*_UD00_STAGE_PARAMETERS,
ParameterDefinition(
"nstages",
1.0,
label="段数",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=8.0,
editor="choice",
options=tuple(
ParameterOption(float(stage_count), str(stage_count))
for stage_count in range(1, 9)
),
description="参与输出计算的有效线性分段数量。",
),
ParameterDefinition(
"iscyclic",
0.0,
label="循环",
quantity="dimensionless",
unit="",
minimum=0.0,
maximum=1.0,
editor="choice",
options=(
ParameterOption(0.0, "否"),
ParameterOption(1.0, "是"),
),
description="当前公共协议编码:0 表示单次输出,1 表示循环输出。",
),
)
RESULT_VARIABLES = (
ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10),
@@ -117,9 +198,19 @@ class AmesimUd00(AlgebraicComponent):
label="UD00 分段线性信号",
library_id="amesim",
category_id="signals",
symbol="signal",
symbol="amesim_ud00",
ports=(PortDisplaySpec("out", "right", order=10),),
order=20,
parameter_groups=(
ParameterGroupDisplaySpec(
id="stages",
label="分段参数",
parameters=tuple(
parameter.name for parameter in _UD00_STAGE_PARAMETERS
),
order=10,
),
),
)
def __init__(
@@ -164,10 +255,17 @@ class AmesimUd00(AlgebraicComponent):
) -> "AmesimUd00":
nstages = parameters["nstages"]
iscyclic = parameters["iscyclic"]
if not float(nstages).is_integer():
raise ValueError("UD00 nstages must be an integer.")
if not float(iscyclic).is_integer():
raise ValueError("UD00 iscyclic must be 0 or 1.")
definitions = {definition.name: definition for definition in cls.PARAMETERS}
for parameter_name, value in (
("nstages", nstages),
("iscyclic", iscyclic),
):
numeric_value = float(value)
if not numeric_value.is_integer():
raise ValueError(f"UD00 {parameter_name} must be an integer.")
message = definitions[parameter_name].validation_message(numeric_value)
if message is not None:
raise ValueError(f"UD00 {parameter_name} {message}.")
return cls(
name=name,
medium=medium,
@@ -200,5 +298,58 @@ class AmesimUd00(AlgebraicComponent):
def signal_output_values(self, time: float) -> dict[str, float]:
return {"out": self.output_at(time)}
def signal_event_times(
self,
start_time: float,
stop_time: float,
) -> tuple[float, ...]:
"""Return UD00 start, stage, and repeated cycle boundaries.
The final non-cyclic stage is intentionally not given an end event:
``output_at`` continues that stage's slope after its configured duration.
"""
if stop_time <= start_time:
return ()
active_durations = self.durations[: self.nstages]
stage_offsets = [0.0]
elapsed = 0.0
for duration in active_durations[:-1]:
elapsed += duration
stage_offsets.append(elapsed)
if not self.iscyclic:
return tuple(
sorted(
{
event_time
for offset in stage_offsets
if start_time
< (event_time := self.tstart + offset)
< stop_time
}
)
)
cycle_duration = sum(active_durations)
if cycle_duration <= 0.0:
return ()
events: set[float] = set()
for offset in stage_offsets:
first_boundary = self.tstart + offset
cycle_index = max(
0,
floor((start_time - first_boundary) / cycle_duration) + 1,
)
event_time = first_boundary + cycle_index * cycle_duration
while event_time < stop_time:
if event_time > start_time:
events.add(event_time)
cycle_index += 1
event_time = first_boundary + cycle_index * cycle_duration
return tuple(sorted(events))
def component_result_values(self) -> Mapping[str, float]:
return {"y": self.out.signal}
@@ -1,8 +1,13 @@
from __future__ import annotations
from collections.abc import Mapping
from math import isclose
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from math import isfinite
from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER,
normalize_amesim_gas_index,
)
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
@@ -11,11 +16,24 @@ from app.simulation.core.metadata import (
ResultVariableDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.medium import (
GasMedium,
ThermodynamicProperties,
ThermodynamicPropertiesLinearization,
)
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
@dataclass(frozen=True)
class Pnch012DerivativeLinearization:
derivative: tuple[float, float]
tangents: tuple[tuple[float, ...], tuple[float, ...]]
properties: ThermodynamicPropertiesLinearization
valid: bool = True
reason: str | None = None
class AmesimPnch023(ThermodynamicVolumeComponent):
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
@@ -32,6 +50,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"cvol",
0.057,
@@ -40,6 +59,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
unit="m3",
minimum=0.0,
minimum_exclusive=True,
description="气室内部用于储存气体的固定有效容积。",
),
ParameterDefinition(
"kth",
@@ -48,6 +68,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
quantity="heat_transfer_coefficient",
unit="W/(m2*K)",
minimum=0.0,
description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。",
),
ParameterDefinition(
"sth",
@@ -56,6 +77,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
quantity="area",
unit="m2",
minimum=0.0,
description="气室与环境进行热交换的有效表面积。",
),
ParameterDefinition(
"extemp",
@@ -65,15 +87,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。",
),
ParameterDefinition(
"p0",
@@ -83,6 +97,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
description="仿真开始时气室内气体的绝对压力。",
),
ParameterDefinition(
"T0",
@@ -92,6 +107,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
unit="K",
minimum=0.0,
minimum_exclusive=True,
description="仿真开始时气室内气体的绝对温度。",
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
@@ -99,7 +115,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
label="PNCH023 固定容积气室",
library_id="amesim",
category_id="storage",
symbol="tank",
symbol="amesim_pnch023",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -110,7 +126,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
cvol: float = 0.057,
kth: float = 0.0,
@@ -137,13 +153,13 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
self.kth = float(kth)
self.sth = float(sth)
self.extemp = float(extemp)
self.gi = self._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(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)
m0 = medium.density(self.p0, self.T0) * self.cvol
U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0)
self.state = VolumeState(m=m0, U=U0)
initial_h = medium.specific_enthalpy(self.T0)
initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0)
self.port_1 = self.register_declared_port("port_1")
self.port_1.p = self.p0
self.port_1.h_outflow = initial_h
@@ -151,19 +167,12 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
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,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimPnch023:
return cls(
@@ -223,6 +232,17 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
)
return derivative.as_vector()
def pressure_flow_equation_values(self) -> tuple[float, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.cvol,
).p
return (
self.port_1.p - pressure,
self.port_2.p - pressure,
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
@@ -255,10 +275,9 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
"""AMESim PNCH012 variable-volume pneumatic chamber.
AMESim supplies four external volume and volume-rate inputs through the
chamber ports. The current public System XML contract has pneumatic ports
only, so this first public model exposes those external volume inputs as SI
parameters. This represents fixed or prescribed-volume PNCH012 cases and is
not yet the full mechanical-coupled submodel.
chamber ports. Fixed/prescribed contributions remain available as SI
parameters, while connected moving-boundary components can now add live
volume and volume-rate values through the pneumatic connector contract.
"""
MODEL_TYPE = "amesim_pnch012"
@@ -270,6 +289,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"cvol0",
0.015,
@@ -278,6 +298,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
unit="m3",
minimum=0.0,
minimum_exclusive=True,
description="变容气室在所有外部容积为零时仍保留的基础容积。",
),
ParameterDefinition(
"kth",
@@ -286,6 +307,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
quantity="heat_transfer_coefficient",
unit="W/(m2*K)",
minimum=0.0,
description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。",
),
ParameterDefinition(
"sth",
@@ -294,6 +316,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
quantity="area",
unit="m2",
minimum=0.0,
description="气室与环境进行热交换的有效表面积。",
),
ParameterDefinition(
"extemp",
@@ -303,15 +326,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。",
),
ParameterDefinition(
"p0",
@@ -321,6 +336,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
description="仿真开始时气室内气体的绝对压力。",
),
ParameterDefinition(
"T0",
@@ -330,6 +346,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
unit="K",
minimum=0.0,
minimum_exclusive=True,
description="仿真开始时气室内气体的绝对温度。",
),
ParameterDefinition("vol1", 0.0, label="端口 1 外部容积", quantity="volume", unit="m3"),
ParameterDefinition("vol2", 0.0, label="端口 2 外部容积", quantity="volume", unit="m3"),
@@ -348,7 +365,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
label="PNCH012 变容气室",
library_id="amesim",
category_id="storage",
symbol="tank",
symbol="amesim_pnch012",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -361,7 +378,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
cvol0: float = 0.015,
kth: float = 0.0,
@@ -404,7 +421,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
self.kth = float(kth)
self.sth = float(sth)
self.extemp = float(extemp)
self.gi = AmesimPnch023._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(gi)
self.p0 = float(p0)
self.T0 = float(T0)
self.external_volumes = {
@@ -421,10 +438,10 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
}
if self.total_volume() <= 0.0:
raise ValueError("PNCH012 total volume must be positive.")
m0 = self.p0 * self.total_volume() / (medium.R_gas * self.T0)
U0 = m0 * medium.specific_internal_energy(self.T0)
m0 = medium.density(self.p0, self.T0) * self.total_volume()
U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0)
self.state = VolumeState(m=m0, U=U0)
initial_h = medium.specific_enthalpy(self.T0)
initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0)
for port_name in ("port_1", "port_2", "port_3", "port_4"):
port = self.register_declared_port(port_name)
port.p = self.p0
@@ -436,19 +453,34 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> "AmesimPnch012":
return cls(name=name, medium=medium, **dict(parameters))
def connected_external_volume(self) -> float:
return sum(
getattr(getattr(self, port_name, None), "volume", 0.0)
for port_name in self.external_volumes
)
def connected_external_volume_rate(self) -> float:
return sum(
getattr(getattr(self, port_name, None), "volume_flow", 0.0)
for port_name in self.external_volume_rates
)
def total_volume(self) -> float:
minimum_volume = self.cvol0 / 100.0
return max(self.cvol0 + sum(self.external_volumes.values()), minimum_volume)
return max(
self.cvol0 + sum(self.external_volumes.values()) + self.connected_external_volume(),
minimum_volume,
)
def total_volume_rate(self) -> float:
if self.total_volume() <= self.cvol0 / 100.0:
return 0.0
return sum(self.external_volume_rates.values())
return sum(self.external_volume_rates.values()) + self.connected_external_volume_rate()
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
@@ -501,6 +533,143 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
energy_derivative -= props.p * self.total_volume_rate()
return VolumeState(m=mass_derivative, U=energy_derivative).as_vector()
def linearize_state_derivative(
self,
connected_h: Mapping[str, float],
*,
state_mass_tangent: Sequence[float],
state_energy_tangent: Sequence[float],
external_volume_tangent: Sequence[float],
external_volume_rate_tangent: Sequence[float],
port_mass_flow_tangents: Mapping[str, Sequence[float]],
connected_h_tangents: Mapping[str, Sequence[float]],
property_linearization: ThermodynamicPropertiesLinearization | None = None,
flow_boundary_tolerance: float = 1.0e-12,
) -> Pnch012DerivativeLinearization:
"""Linearize the chamber balance while keeping stream modes fixed."""
port_names = ("port_1", "port_2", "port_3", "port_4")
vectors = {
"state_mass": tuple(float(value) for value in state_mass_tangent),
"state_energy": tuple(float(value) for value in state_energy_tangent),
"volume": tuple(float(value) for value in external_volume_tangent),
"volume_rate": tuple(
float(value) for value in external_volume_rate_tangent
),
}
for port_name in port_names:
vectors[f"flow:{port_name}"] = tuple(
float(value) for value in port_mass_flow_tangents[port_name]
)
vectors[f"enthalpy:{port_name}"] = tuple(
float(value) for value in connected_h_tangents[port_name]
)
widths = {len(values) for values in vectors.values()}
if len(widths) != 1:
raise ValueError("PNCH012 tangent vectors must have equal lengths.")
width = len(vectors["state_mass"])
invalid_reason: str | None = None
if not all(isfinite(value) for values in vectors.values() for value in values):
invalid_reason = "non_finite_tangent_input"
raw_volume = (
self.cvol0
+ sum(self.external_volumes.values())
+ self.connected_external_volume()
)
minimum_volume = self.cvol0 / 100.0
volume_scale = max(abs(raw_volume), abs(minimum_volume), 1.0e-18)
on_volume_boundary = (
abs(raw_volume - minimum_volume) <= 1.0e-12 * volume_scale
)
supplied_volume_tangent = vectors["volume"]
if raw_volume < minimum_volume or on_volume_boundary:
used_volume_tangent = (0.0,) * width
used_volume_rate_tangent = (0.0,) * width
if on_volume_boundary and any(
value != 0.0
for value in (
*supplied_volume_tangent,
*vectors["volume_rate"],
)
):
invalid_reason = invalid_reason or "volume_floor_boundary"
else:
used_volume_tangent = supplied_volume_tangent
used_volume_rate_tangent = vectors["volume_rate"]
properties = property_linearization or self.medium.linearize_properties_from_mU(
self.state.m,
self.state.U,
self.total_volume(),
vectors["state_mass"],
vectors["state_energy"],
used_volume_tangent,
)
if properties.tangents.width != width:
raise ValueError(
"PNCH012 property tangent width must match balance tangents."
)
props = properties.properties
if not properties.valid:
invalid_reason = invalid_reason or properties.reason
mass_derivative = sum(
self.get_port(port_name).m_flow for port_name in port_names
)
volume_rate = self.total_volume_rate()
energy_derivative = self.thermal_energy_flow_w(props.T) - props.p * volume_rate
mass_tangent = [0.0] * width
energy_tangent = [
-self.kth * self.sth * properties.tangents.T[index]
- volume_rate * properties.tangents.p[index]
- props.p * used_volume_rate_tangent[index]
for index in range(width)
]
for port_name in port_names:
port = self.get_port(port_name)
flow_tangent = vectors[f"flow:{port_name}"]
if (
abs(port.m_flow) <= flow_boundary_tolerance
and any(value != 0.0 for value in flow_tangent)
):
invalid_reason = invalid_reason or (
f"flow_direction_boundary:{port_name}"
)
if port.m_flow > 0.0:
inlet_h = connected_h[port_name]
inlet_h_tangent = vectors[f"enthalpy:{port_name}"]
else:
inlet_h = props.h
inlet_h_tangent = properties.tangents.h
energy_derivative += port.m_flow * inlet_h
for index in range(width):
mass_tangent[index] += flow_tangent[index]
energy_tangent[index] += (
inlet_h * flow_tangent[index]
+ port.m_flow * inlet_h_tangent[index]
)
return Pnch012DerivativeLinearization(
derivative=(mass_derivative, energy_derivative),
tangents=(tuple(mass_tangent), tuple(energy_tangent)),
properties=properties,
valid=invalid_reason is None,
reason=invalid_reason,
)
def pressure_flow_equation_values(self) -> tuple[float, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.total_volume(),
).p
return tuple(
self.get_port(port_name).p - pressure
for port_name in ("port_1", "port_2", "port_3", "port_4")
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
+2 -2
View File
@@ -1,7 +1,7 @@
# 元件建模规范与示例
规范的权威版本位于
[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。
[`docs/standard/component-model-authoring-spec-v1.md`](../../../docs/standard/component-model-authoring-spec-v1.md)。
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
修改中同步,不能让示例形成另一套规则。
@@ -279,4 +279,4 @@ models=(
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
组件库、分类和自动发现的完整规则参见
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
[`组件库分类、发现与读取规范 v1`](../../../docs/standard/component-library-spec-v1.md)。
@@ -1,12 +1,3 @@
"""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
@@ -16,6 +16,10 @@ class Orifice(AlgebraicComponent):
MODEL_TYPE = "orifice"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
@@ -115,4 +119,3 @@ class Orifice(AlgebraicComponent):
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"]
@@ -16,6 +16,10 @@ class ResistivePipe(AlgebraicComponent):
MODEL_TYPE = "pipe"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
@@ -14,6 +14,10 @@ class Tee(AlgebraicComponent):
MODEL_TYPE = "tee"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
+93 -5
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from app.simulation.core.catalog import ComponentDisplaySpec
@@ -15,12 +15,26 @@ from app.simulation.core.metadata import (
from app.simulation.core.ports import PortDefinition, PortState
if TYPE_CHECKING:
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.medium import GasMedium
class Component(ABC):
MODEL_TYPE: ClassVar[str | None] = None
MODEL_VERSION: ClassVar[str | None] = None
# ``True`` means that pressure/flow residuals read values written by
# ``update_stream_outflows`` or ``update_flow_temperature_references``.
# ``False`` is an explicit promise that those residuals are independent of
# stream propagation. ``None`` keeps custom components conservative: when
# they override either stream hook, the closure planner retains the legacy
# full-network thermofluid fixed point.
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
# Exact residual suffixes whose declared variables are summed, in order,
# to form a ``sumToZero`` flow equation. The causal solver deliberately
# reads this capability from the concrete class ``__dict__``: subclasses
# must repeat the promise after changing any equation semantics.
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES: ClassVar[
frozenset[str]
] = frozenset()
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
@@ -44,6 +58,31 @@ class Component(ABC):
if port.definition is not None
)
@classmethod
def active_port_definitions_for_parameters(
cls,
parameters: Mapping[str, float],
) -> tuple[PortDefinition, ...]:
"""Declared ports enabled by one normalized parameter set."""
return cls.PORTS
@property
def active_port_definitions(self) -> tuple[PortDefinition, ...]:
"""Instance ports that participate in execution and result reporting."""
return self.port_definitions
@property
def required_connection_ports(self) -> tuple[str, ...]:
"""Physical ports that must have an external connection before simulation."""
return tuple(
definition.name
for definition in self.active_port_definitions
if definition.kind == "physical"
)
def register_port(self, port: PortState) -> PortState:
definition = port.definition
if definition is None:
@@ -124,7 +163,7 @@ class Component(ABC):
)
values[name] = float(component_values[name])
for port_definition in self.port_definitions:
for port_definition in self.active_port_definitions:
port = self.get_port(port_definition.name)
for variable in port_definition.variables:
if not variable.result_visible:
@@ -151,7 +190,7 @@ class Component(ABC):
for definition in self.RESULT_VARIABLES
if definition.visible
]
for port_definition in self.port_definitions:
for port_definition in self.active_port_definitions:
for variable in port_definition.variables:
if not variable.result_visible:
continue
@@ -185,7 +224,7 @@ class Component(ABC):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> Component:
"""Create a catalog model from normalized SI parameters."""
@@ -199,11 +238,60 @@ class Component(ABC):
return ()
def pressure_flow_equation_values(self) -> tuple[float, ...]:
"""Return live residual values in the declared equation order.
Components with frequently evaluated equations can override this
method to avoid rebuilding immutable equation metadata during closure.
The default keeps third-party components compatible with the public
residual API.
"""
return tuple(
float(equation.value)
for equation in self.pressure_flow_equation_residuals()
)
def pressure_flow_equation_value_readers(
self,
) -> Mapping[str, Callable[[], float]]:
"""Return explicitly separable scalar residual readers.
The solver consumes this optional capability only when the concrete
component class declares the method itself. Subclasses therefore
cannot accidentally inherit an equation-purity promise.
"""
return {}
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
"""Update connector outflow properties from current flow directions."""
return None
def update_flow_temperature_references(
self,
connected_h: Mapping[str, float],
) -> None:
"""Update enthalpy references used only by pressure-flow laws.
Most components use the normal stream enthalpy for both energy
transport and upstream-property evaluation. AMESim node submodels can
expose a distinct temperature reference, so the default is a no-op.
"""
return None
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
"""Return directed ``volume``/``volume_flow`` values by pneumatic port.
Most pneumatic components contribute no external chamber volume. Moving
boundaries such as PNRP17 override this hook; the network resolver then
propagates the pair to the component connected at the same physical port.
"""
return {}
class DynamicComponent(Component):
state_size = 2
+23
View File
@@ -5,6 +5,7 @@ from typing import Literal
PortDisplaySide = Literal["left", "right"]
ComponentCatalogRole = Literal["amesimGasMediumDefinition"]
@dataclass(frozen=True)
@@ -32,6 +33,26 @@ class PortDisplaySpec:
order: int = 0
@dataclass(frozen=True)
class ParameterGroupDisplaySpec:
"""Ordered, collapsible presentation group for component parameters."""
id: str
label: str
parameters: tuple[str, ...]
order: int = 0
default_expanded: bool = False
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"parameters": list(self.parameters),
"order": self.order,
"defaultExpanded": self.default_expanded,
}
@dataclass(frozen=True)
class ComponentDisplaySpec:
"""Frontend metadata co-located with a component implementation."""
@@ -42,6 +63,8 @@ class ComponentDisplaySpec:
symbol: str
ports: tuple[PortDisplaySpec, ...]
order: int = 0
role: ComponentCatalogRole | None = None
parameter_groups: tuple[ParameterGroupDisplaySpec, ...] = ()
@property
def port_by_name(self) -> dict[str, PortDisplaySpec]:
+1 -1
View File
@@ -10,7 +10,7 @@ EquationOwner = Literal["connection", "component"]
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
@dataclass(frozen=True)
@dataclass(frozen=True, slots=True)
class EquationResidual:
"""One executable scalar equation in the pressure-flow subsystem."""
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
class RecoverableTrialStateError(ValueError):
"""A physical-domain failure caused by an integrator trial state."""
+283 -1
View File
@@ -1,6 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Protocol, Sequence
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.performance import profile_property
@dataclass(frozen=True)
@@ -12,6 +17,110 @@ class ThermodynamicProperties:
h: float
@dataclass(frozen=True)
class ThermodynamicPropertyTangents:
"""Directional derivatives of a recovered thermodynamic state."""
p: tuple[float, ...]
T: tuple[float, ...]
rho: tuple[float, ...]
u: tuple[float, ...]
h: tuple[float, ...]
@property
def width(self) -> int:
return len(self.p)
@classmethod
def zeros(cls, width: int) -> "ThermodynamicPropertyTangents":
values = (0.0,) * width
return cls(p=values, T=values, rho=values, u=values, h=values)
@dataclass(frozen=True)
class ThermodynamicPropertiesLinearization:
"""Primal properties and a validity-checked directional linearization."""
properties: ThermodynamicProperties
tangents: ThermodynamicPropertyTangents
valid: bool = True
reason: str | None = None
class GasMedium(Protocol):
"""Thermodynamic contract required by pneumatic components.
``IdealGasMedium`` is the default implementation. Keeping the component
boundary structural allows a later helium/Peng-Robinson implementation to
be registered without changing every AMESim component constructor.
"""
name: str
R_gas: float
cp_ref: float
T_ref: float
@property
def cv(self) -> float: ...
@property
def gamma(self) -> float: ...
def cp_at_temperature(self, T: float) -> float: ...
def cv_at_temperature(self, T: float) -> float: ...
def density(self, p: float, T: float) -> float: ...
def isentropic_density_pressure_factor(
self,
p: float,
T: float,
downstream_pressure: float | None = None,
) -> float: ...
def dynamic_viscosity(self, T: float) -> float: ...
def diagnostic_dynamic_viscosity(self, T: float) -> float: ...
def specific_internal_energy(self, T: float) -> float: ...
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: ...
def specific_enthalpy(self, T: float) -> float: ...
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float: ...
def temperature_from_internal_energy(self, u: float) -> float: ...
def temperature_from_enthalpy(self, h: float) -> float: ...
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float: ...
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: ...
def pressure(self, m: float, T: float, V: float) -> float: ...
def properties_from_mU(
self,
m: float,
U: float,
V: float,
) -> ThermodynamicProperties: ...
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization: ...
@dataclass(frozen=True)
class IdealGasMedium:
"""Temperature-dependent ideal-gas air approximation.
@@ -27,6 +136,9 @@ class IdealGasMedium:
cp_ref: float = 1005.0
T_ref: float = 300.0
cp_slope: float = 0.0
viscosity_ref: float = 1.82e-5
viscosity_T_ref: float = 293.15
sutherland_constant: float = 110.4
@property
def cv(self) -> float:
@@ -42,9 +154,47 @@ class IdealGasMedium:
def cv_at_temperature(self, T: float) -> float:
return self.cp_at_temperature(T) - self.R_gas
@profile_property("density")
def density(self, p: float, T: float) -> float:
return p / (self.R_gas * T)
@profile_property("isentropic_density_pressure_factor")
def isentropic_density_pressure_factor(
self,
p: float,
T: float,
downstream_pressure: float | None = None,
) -> float:
del p
del downstream_pressure
cp = self.cp_at_temperature(T)
cv = self.cv_at_temperature(T)
return cv / cp
@profile_property("dynamic_viscosity")
def dynamic_viscosity(self, T: float) -> float:
"""Return dynamic viscosity using the default air Sutherland law."""
if T <= 0.0:
raise ValueError("Temperature must be positive.")
return (
self.viscosity_ref
* (T / self.viscosity_T_ref) ** 1.5
* (self.viscosity_T_ref + self.sutherland_constant)
/ (T + self.sutherland_constant)
)
def diagnostic_dynamic_viscosity(self, T: float) -> float:
"""Return the viscosity convention used by derived diagnostics.
Most media use the same transport property for dynamics and reported
diagnostics. Reference-library media may override this without
changing a calibrated constitutive flow relation.
"""
return self.dynamic_viscosity(T)
@profile_property("specific_internal_energy")
def specific_internal_energy(self, T: float) -> float:
delta_T = T - self.T_ref
return (
@@ -53,6 +203,12 @@ class IdealGasMedium:
+ 0.5 * self.cp_slope * delta_T * delta_T
)
@profile_property("specific_internal_energy_at_pressure")
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
del p
return self.specific_internal_energy(T)
@profile_property("specific_enthalpy")
def specific_enthalpy(self, T: float) -> float:
delta_T = T - self.T_ref
return (
@@ -61,6 +217,11 @@ class IdealGasMedium:
+ 0.5 * self.cp_slope * delta_T * delta_T
)
@profile_property("specific_enthalpy_at_pressure")
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
del p
return self.specific_enthalpy(T)
def temperature_from_internal_energy(self, u: float) -> float:
reference_internal_energy = self.cv * self.T_ref
delta_u = u - reference_internal_energy
@@ -77,9 +238,32 @@ class IdealGasMedium:
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
return self.T_ref + delta_T
def temperature_from_enthalpy(self, h: float) -> float:
reference_enthalpy = self.cp_ref * self.T_ref
delta_h = h - reference_enthalpy
if abs(self.cp_slope) <= 1e-15:
return self.T_ref + delta_h / self.cp_ref
a = 0.5 * self.cp_slope
b = self.cp_ref
c = -delta_h
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
@profile_property("temperature_from_pressure_enthalpy")
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
del p
return self.temperature_from_enthalpy(h)
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.")
raise RecoverableTrialStateError(
"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:
@@ -87,6 +271,7 @@ class IdealGasMedium:
raise ValueError("Volume must stay positive.")
return m * self.R_gas * T / V
@profile_property("properties_from_mU")
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)
@@ -94,3 +279,100 @@ class IdealGasMedium:
u = U / m
h = self.specific_enthalpy(T)
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization:
"""Linearize properties_from_mU for several seed directions."""
dm_values = tuple(float(value) for value in dm)
dU_values = tuple(float(value) for value in dU)
dV_values = tuple(float(value) for value in dV)
if not (len(dm_values) == len(dU_values) == len(dV_values)):
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
props = properties or self.properties_from_mU(m, U, V)
width = len(dm_values)
expected_density = m / V
expected_internal_energy = U / m
if (
abs(props.rho - expected_density)
> 1.0e-12 * max(abs(expected_density), 1.0)
or abs(props.u - expected_internal_energy)
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
):
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="properties_primal_mismatch",
)
if not all(
isfinite(value)
for values in (dm_values, dU_values, dV_values)
for value in values
):
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="non_finite_tangent_input",
)
cv = self.cv_at_temperature(props.T)
cp = self.cp_at_temperature(props.T)
if not isfinite(cv) or not isfinite(cp) or cv <= 0.0 or cp <= 0.0:
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="non_positive_heat_capacity",
)
drho: list[float] = []
du: list[float] = []
dT: list[float] = []
dp: list[float] = []
dh: list[float] = []
for mass_tangent, energy_tangent, volume_tangent in zip(
dm_values,
dU_values,
dV_values,
strict=True,
):
density_tangent = mass_tangent / V - m * volume_tangent / (V * V)
internal_energy_tangent = (
energy_tangent / m - U * mass_tangent / (m * m)
)
temperature_tangent = internal_energy_tangent / cv
pressure_tangent = self.R_gas * (
props.T * density_tangent + props.rho * temperature_tangent
)
enthalpy_tangent = cp * temperature_tangent
drho.append(density_tangent)
du.append(internal_energy_tangent)
dT.append(temperature_tangent)
dp.append(pressure_tangent)
dh.append(enthalpy_tangent)
tangent_values = (*drho, *du, *dT, *dp, *dh)
valid = all(isfinite(value) for value in tangent_values)
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents(
p=tuple(dp),
T=tuple(dT),
rho=tuple(drho),
u=tuple(du),
h=tuple(dh),
),
valid=valid,
reason=None if valid else "non_finite_property_tangent",
)
+59 -1
View File
@@ -6,6 +6,11 @@ from typing import Literal
ResultVariableScope = Literal["component", "port"]
ParameterEditor = Literal[
"amesimGasReference",
"amesimGasPropertyModel",
"choice",
]
SI_UNIT_BY_QUANTITY: dict[str, str] = {
@@ -34,9 +39,41 @@ SI_UNIT_BY_QUANTITY: dict[str, str] = {
}
@dataclass(frozen=True)
class ParameterOption:
"""One numeric choice exposed by a catalog-backed parameter editor."""
value: float
label: str
def as_interface_dict(self) -> dict[str, object]:
return {
"value": self.value,
"label": self.label,
}
@dataclass(frozen=True)
class ParameterCondition:
"""Match when the controlling parameter equals any declared value."""
parameter: str
values: tuple[float, ...]
def as_interface_dict(self) -> dict[str, object]:
return {
"parameter": self.parameter,
"values": list(self.values),
}
@dataclass(frozen=True)
class ParameterDefinition:
"""User-configurable model input expressed in the backend SI contract."""
"""User-configurable model input expressed in the backend SI contract.
Every ``visible_when`` condition must match for the catalog parameter to
be visible; each individual condition matches any one of its ``values``.
"""
name: str
default: float
@@ -46,6 +83,10 @@ class ParameterDefinition:
minimum: float | None = None
maximum: float | None = None
minimum_exclusive: bool = False
editor: ParameterEditor | None = None
options: tuple[ParameterOption, ...] = ()
description: str = ""
visible_when: tuple[ParameterCondition, ...] = ()
def validation_message(self, value: float) -> str | None:
if not isfinite(value):
@@ -57,6 +98,11 @@ class ParameterDefinition:
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}"
if self.options and value not in {
float(option.value) for option in self.options
}:
available = ", ".join(f"{option.value:g}" for option in self.options)
return f"must be one of {available}"
return None
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
@@ -72,6 +118,18 @@ class ParameterDefinition:
payload["minimum"] = self.minimum
if self.maximum is not None:
payload["maximum"] = self.maximum
if self.editor is not None:
payload["editor"] = self.editor
if self.options:
payload["options"] = [
option.as_interface_dict() for option in self.options
]
if self.description:
payload["description"] = self.description
if self.visible_when:
payload["visibleWhen"] = [
condition.as_interface_dict() for condition in self.visible_when
]
if value is not None:
payload["value"] = value
return payload
+193 -6
View File
@@ -1,9 +1,17 @@
from __future__ import annotations
from app.simulation.core.errors import RecoverableTrialStateError
from dataclasses import dataclass
from math import acos, cos, isfinite, log, pi, sqrt
from app.simulation.performance import profile_property
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
# Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the
# commonly printed Peng-Robinson constants 0.45724 and 0.07780.
PENG_ROBINSON_A_COEFFICIENT = 0.457235583
PENG_ROBINSON_B_COEFFICIENT = 0.07779607
@dataclass(frozen=True)
@@ -27,7 +35,7 @@ class PengRobinsonFluid:
@property
def a_parameter(self) -> float:
return (
0.45724
PENG_ROBINSON_A_COEFFICIENT
* UNIVERSAL_GAS_CONSTANT
* UNIVERSAL_GAS_CONSTANT
* self.critical_temperature
@@ -37,7 +45,12 @@ class PengRobinsonFluid:
@property
def b_parameter(self) -> float:
return 0.07780 * UNIVERSAL_GAS_CONSTANT * self.critical_temperature / self.critical_pressure
return (
PENG_ROBINSON_B_COEFFICIENT
* UNIVERSAL_GAS_CONSTANT
* self.critical_temperature
/ self.critical_pressure
)
@property
def kappa(self) -> float:
@@ -60,27 +73,112 @@ class PengRobinsonFluid:
/ (self.critical_temperature * sqrt_reduced_temperature)
)
def alpha_temperature_second_derivative(self, temperature: float) -> float:
self._validate_temperature(temperature)
reduced_temperature = temperature / self.critical_temperature
sqrt_reduced_temperature = sqrt(reduced_temperature)
alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature)
return (
self.kappa
/ (2.0 * self.critical_temperature * self.critical_temperature)
* (
self.kappa / reduced_temperature
+ alpha_base / (reduced_temperature * sqrt_reduced_temperature)
)
)
def attractive_parameter(self, temperature: float) -> float:
return self.a_parameter * self.alpha(temperature)
def attractive_parameter_temperature_derivative(self, temperature: float) -> float:
return self.a_parameter * self.alpha_temperature_derivative(temperature)
def attractive_parameter_temperature_second_derivative(
self,
temperature: float,
) -> float:
return self.a_parameter * self.alpha_temperature_second_derivative(temperature)
@profile_property(
"pressure_from_molar_volume",
layer="kernel",
minimum_mode="audit",
)
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
self._validate_temperature(temperature)
if molar_volume <= self.b_parameter:
raise ValueError("Molar volume must be larger than Peng-Robinson b parameter.")
raise RecoverableTrialStateError("Molar volume must be larger than Peng-Robinson b parameter.")
a_alpha = self.attractive_parameter(temperature)
b = self.b_parameter
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
return repulsive - attractive
@profile_property(
"pressure_from_density",
layer="kernel",
minimum_mode="audit",
)
def pressure_from_density(self, temperature: float, density: float) -> float:
if density <= 0.0:
raise ValueError("Density must be positive.")
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
@profile_property(
"pressure_temperature_derivative_at_density",
layer="kernel",
minimum_mode="audit",
)
def pressure_temperature_derivative_at_density(
self,
temperature: float,
density: float,
) -> float:
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
if molar_volume <= self.b_parameter:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
b = self.b_parameter
denominator = molar_volume * (molar_volume + b) + b * (molar_volume - b)
return (
UNIVERSAL_GAS_CONSTANT / (molar_volume - b)
- self.attractive_parameter_temperature_derivative(temperature) / denominator
)
@profile_property(
"pressure_density_derivative_at_temperature",
layer="kernel",
minimum_mode="audit",
)
def pressure_density_derivative_at_temperature(
self,
temperature: float,
density: float,
) -> float:
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
if molar_volume <= self.b_parameter:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
b = self.b_parameter
denominator = molar_volume * (molar_volume + b) + b * (molar_volume - b)
pressure_molar_volume_derivative = (
-UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b) ** 2
+ self.attractive_parameter(temperature)
* 2.0
* (molar_volume + b)
/ denominator**2
)
molar_volume_density_derivative = -self.molar_mass / (density * density)
return pressure_molar_volume_derivative * molar_volume_density_derivative
def reduced_parameters(self, pressure: float, temperature: float) -> tuple[float, float]:
self._validate_pressure_temperature(pressure, temperature)
a_alpha = self.attractive_parameter(temperature)
@@ -89,6 +187,11 @@ class PengRobinsonFluid:
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
return A, B
@profile_property(
"compressibility_roots",
layer="kernel",
minimum_mode="audit",
)
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
A, B = self.reduced_parameters(pressure, temperature)
coefficients = (
@@ -102,6 +205,11 @@ class PengRobinsonFluid:
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
return physical_roots
@profile_property(
"compressibility_factor",
layer="kernel",
minimum_mode="audit",
)
def compressibility_factor(
self,
pressure: float,
@@ -117,6 +225,11 @@ class PengRobinsonFluid:
return roots[-1]
raise ValueError(f"Unsupported phase selector: {phase!r}")
@profile_property(
"molar_volume",
layer="kernel",
minimum_mode="audit",
)
def molar_volume(
self,
pressure: float,
@@ -126,6 +239,7 @@ class PengRobinsonFluid:
z = self.compressibility_factor(pressure, temperature, phase=phase)
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
@profile_property("density", layer="kernel", minimum_mode="audit")
def density(
self,
pressure: float,
@@ -134,6 +248,11 @@ class PengRobinsonFluid:
) -> float:
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
@profile_property(
"residual_specific_enthalpy",
layer="kernel",
minimum_mode="audit",
)
def residual_specific_enthalpy(
self,
pressure: float,
@@ -163,15 +282,82 @@ class PengRobinsonFluid:
)
return residual_molar_enthalpy / self.molar_mass
@profile_property(
"residual_specific_internal_energy_at_density",
layer="kernel",
minimum_mode="audit",
)
def residual_specific_internal_energy_at_density(
self,
temperature: float,
density: float,
) -> float:
"""Return Peng-Robinson internal-energy departure, J/kg."""
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
b = self.b_parameter
if molar_volume <= b:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
attractive = self.attractive_parameter(temperature)
d_attractive_d_temperature = (
self.attractive_parameter_temperature_derivative(temperature)
)
log_argument = (
molar_volume + (1.0 + sqrt(2.0)) * b
) / (
molar_volume + (1.0 - sqrt(2.0)) * b
)
residual_molar_internal_energy = (
temperature * d_attractive_d_temperature - attractive
) * log(log_argument) / (2.0 * sqrt(2.0) * b)
return residual_molar_internal_energy / self.molar_mass
@profile_property(
"residual_isochoric_heat_capacity_at_density",
layer="kernel",
minimum_mode="audit",
)
def residual_isochoric_heat_capacity_at_density(
self,
temperature: float,
density: float,
) -> float:
"""Return the constant-volume heat-capacity departure, J/kg/K."""
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
b = self.b_parameter
if molar_volume <= b:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
log_argument = (
molar_volume + (1.0 + sqrt(2.0)) * b
) / (
molar_volume + (1.0 - sqrt(2.0)) * b
)
residual_molar_cv = (
temperature
* self.attractive_parameter_temperature_second_derivative(temperature)
* log(log_argument)
/ (2.0 * sqrt(2.0) * b)
)
return residual_molar_cv / self.molar_mass
@staticmethod
def _validate_temperature(temperature: float) -> None:
if temperature <= 0.0:
raise ValueError("Temperature must be positive.")
raise RecoverableTrialStateError("Temperature must be positive.")
@classmethod
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
if pressure <= 0.0:
raise ValueError("Pressure must be positive.")
raise RecoverableTrialStateError("Pressure must be positive.")
cls._validate_temperature(temperature)
HELIUM_PR = PengRobinsonFluid(
@@ -179,7 +365,8 @@ HELIUM_PR = PengRobinsonFluid(
molar_mass=0.004002602,
critical_temperature=5.1953,
critical_pressure=227_460.0,
acentric_factor=-0.385,
# Simcenter Amesim 2404 helium_eos.data.
acentric_factor=-0.382,
)
NITROGEN_PR = PengRobinsonFluid(
+22
View File
@@ -87,6 +87,26 @@ class PortDefinition:
unit="J/kg",
order=30,
),
PortVariableDefinition(
"volume",
"signal",
"directed",
label="外部容积",
quantity="volume",
unit="m3",
result_visible=False,
order=40,
),
PortVariableDefinition(
"volume_flow",
"signal",
"directed",
label="外部容积变化率",
quantity="volume_flow",
unit="m3/s",
result_visible=False,
order=50,
),
),
)
@@ -178,6 +198,8 @@ class PortState:
p: float = 0.0
m_flow: float = 0.0
h_outflow: float = 0.0
volume: float = 0.0
volume_flow: float = 0.0
signal: float = 0.0
x: float = 0.0
v: float = 0.0
@@ -1,5 +1,10 @@
from __future__ import annotations
from app.simulation.examples.test_mql.system import (
TestMqlRunConfig,
TestMqlSimulationResult,
TestMqlSystem,
)
from app.simulation.examples.test_mql.run import (
PreparedTestMqlRun,
TestMqlRunResult,
@@ -10,7 +15,10 @@ from app.simulation.examples.test_mql.run import (
__all__ = [
"PreparedTestMqlRun",
"TestMqlRunConfig",
"TestMqlRunResult",
"TestMqlSimulationResult",
"TestMqlSystem",
"prepare_test_mql_run",
"run_prepared_test_mql",
"run_test_mql",
@@ -3,17 +3,17 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
from PythonModels.reporting.test_mql_observations import (
from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
from app.simulation.reporting.test_mql_comparison import TestMqlComparisonResult
from app.simulation.reporting.test_mql_observations import (
TestMqlObservationCatalog,
build_test_mql_observation_catalog,
)
from PythonModels.reporting.test_mql_output_schema import (
from app.simulation.reporting.test_mql_output_schema import (
TestMqlOutputSchema,
build_test_mql_output_schema,
)
from PythonModels.reporting.test_mql_output_validation import (
from app.simulation.reporting.test_mql_output_validation import (
TestMqlValidatedOutput,
compare_validated_test_mql_output,
validate_test_mql_output,
@@ -3,21 +3,21 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
AmesimPneumaticGas,
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
)
from PythonModels.components.amesim_pneumatic_line import (
from app.simulation.examples.test_mql.primitives.pneumatic_lines import (
AmesimPnl0001Pipe,
AmesimPnl0002Pipe,
AmesimPnl0003Pipe,
AmesimPnl00rPipe,
)
from PythonModels.core.medium import ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
from PythonModels.systems.test_mql_nodes import (
from app.simulation.core.medium import ThermodynamicProperties
from app.simulation.core.ports import PortState
from app.simulation.core.state import VolumeState
from app.simulation.examples.test_mql.nodes import (
TestMqlPneumaticNode3,
TestMqlPneumaticNode3Balance,
TestMqlPneumaticNode4,
@@ -3,28 +3,28 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.components.amesim_pneumatic import m3_to_cm3
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
from PythonModels.reporting.test_mql_observations import (
from app.simulation.examples.test_mql.primitives.pneumatic import m3_to_cm3
from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
from app.simulation.reporting.test_mql_comparison import TestMqlComparisonResult
from app.simulation.reporting.test_mql_observations import (
TestMqlObservationCatalog,
build_test_mql_observation_catalog,
)
from PythonModels.reporting.test_mql_output_schema import (
from app.simulation.reporting.test_mql_output_schema import (
TestMqlOutputSchema,
build_test_mql_output_schema,
)
from PythonModels.reporting.test_mql_output_validation import (
from app.simulation.reporting.test_mql_output_validation import (
TestMqlValidatedOutput,
compare_validated_test_mql_output,
validate_test_mql_output,
)
from PythonModels.reporting.test_mql_variables import build_test_mql_variable_catalog
from PythonModels.systems.test_mql_mechanical import (
from app.simulation.reporting.test_mql_variables import build_test_mql_variable_catalog
from app.simulation.examples.test_mql.mechanical import (
TestMqlMechanicalAssembly,
build_test_mql_mechanical_assembly,
)
from PythonModels.systems.test_mql_pneumatic import (
from app.simulation.examples.test_mql.pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
@@ -6,8 +6,8 @@ from dataclasses import dataclass
from math import isfinite
from typing import Any
from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from PythonModels.systems.test_mql import COMPONENT_SPECS, GLOBAL_PARAMETERS
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from app.simulation.examples.test_mql.system import COMPONENT_SPECS, GLOBAL_PARAMETERS
_BINARY_OPERATORS = {
@@ -5,8 +5,8 @@ import tarfile
from dataclasses import dataclass
from pathlib import Path
from PythonModels.systems.test_mql import CONNECTION_SPECS, GLOBAL_PARAMETERS
from PythonModels.systems.test_mql_config import resolve_numeric_expression
from app.simulation.examples.test_mql.system import CONNECTION_SPECS, GLOBAL_PARAMETERS
from app.simulation.examples.test_mql.config import resolve_numeric_expression
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
@@ -4,12 +4,12 @@ import re
from collections import Counter
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql import CONNECTION_SPECS
from app.simulation.examples.test_mql.system import CONNECTION_SPECS
TEST_MQL_PNEUMATIC_LINE_SUBMODELS = ("PNL0001", "PNL0002", "PNL0003", "PNL00R")
@@ -2,19 +2,19 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.components.amesim_mechanical import (
from app.simulation.examples.test_mql.primitives.mechanical import (
AmesimElasticEndstop,
AmesimMassFrictionEndstops,
AmesimPistonGeometry,
circular_area,
mm_to_m,
)
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
from app.simulation.examples.test_mql.config import TestMqlConfig, TestMqlResolvedComponent
MM_TO_M = 1.0e-3
@@ -433,7 +433,7 @@ def _build_mass(
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
rest_coeff=component.parameter_value("restcoeff"),
stribeck_constant_m_s=component.parameter_value("astrib"),
use_friction=bool(int(component.parameter_value("useFriction"))),
use_friction=int(component.parameter_value("useFriction")) == 2,
stop_type=int(component.parameter_value("stoptype")),
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"),
@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.systems.test_mql import COMPONENT_SPECS
from app.simulation.examples.test_mql.system import COMPONENT_SPECS
@dataclass(frozen=True)
@@ -2,14 +2,14 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
AmesimVariablePneumaticVolume,
)
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
from app.simulation.examples.test_mql.config import TestMqlConfig, TestMqlResolvedComponent
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
@@ -162,7 +162,7 @@ def build_test_mql_pneumatic_assembly(
def _build_variable_orifice_controls(
config: TestMqlConfig,
) -> dict[str, TestMqlVariableOrificeControl]:
from PythonModels.systems.test_mql import CONNECTION_SPECS
from app.simulation.examples.test_mql.system import CONNECTION_SPECS
components_by_alias = {component.alias: component for component in config.components}
variable_orifice_aliases = {
@@ -3,17 +3,17 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
)
from PythonModels.components.amesim_pneumatic_line import (
from app.simulation.examples.test_mql.primitives.pneumatic_lines import (
AmesimPnl0001Pipe,
AmesimPnl0002Pipe,
AmesimPnl0003Pipe,
AmesimPnl00rPipe,
)
from PythonModels.systems.test_mql_line_parameters import (
from app.simulation.examples.test_mql.line_parameters import (
TestMqlPnl0001Spec,
TestMqlPnl0002Spec,
TestMqlPnl0003Spec,
@@ -1,7 +1,7 @@
from __future__ import annotations
from PythonModels.systems.test_mql_closure import TestMqlPneumaticChamberSegmentSpec
from PythonModels.systems.test_mql_topology import TestMqlCirTopology
from app.simulation.examples.test_mql.closure import TestMqlPneumaticChamberSegmentSpec
from app.simulation.examples.test_mql.topology import TestMqlCirTopology
def discover_fixed_chamber_segments(
@@ -0,0 +1 @@
"""Calibrated component primitives used only by the ``test_mql`` example."""
@@ -3,11 +3,11 @@ from __future__ import annotations
from dataclasses import dataclass
from math import pi, sqrt
from PythonModels.core.base import AlgebraicComponent, DynamicComponent
from PythonModels.core.medium import ThermodynamicProperties
from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
from app.simulation.core.medium import ThermodynamicProperties
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from app.simulation.core.ports import PortState
from app.simulation.core.state import VolumeState
@dataclass(frozen=True)
Loaded 100 of 376 files, more files were not shown because too many files have changed in this diff. Show more