Compare commits
51
Commits
e87df789b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
408b4ecb22 | ||
|
|
e18399c022 | ||
|
|
c19cf77aee | ||
|
|
eb6ea70e19 | ||
|
|
27f9f4add8 | ||
|
|
60b743dd81 | ||
|
|
a8c733883c | ||
|
|
53f8601fec | ||
|
|
f725f038b6 | ||
|
|
684d28752a | ||
|
|
b435daecf2 | ||
|
|
143e8dd309 | ||
|
|
0fa166c8e5 | ||
|
|
16a7eb2d6c | ||
|
|
6bb0591d32 | ||
|
|
cca9d1e883 | ||
|
|
22b35b2945 | ||
|
|
f09dfcf542 | ||
|
|
5332a788f3 | ||
|
|
57b459bc72 | ||
|
|
4e0b9fd8cc | ||
|
|
6a064892e2 | ||
|
|
6572defaa4 | ||
|
|
456c29b3b6 | ||
|
|
caca32a513 | ||
|
|
0f73d5b568 | ||
|
|
6abcc220de | ||
|
|
7671418582 | ||
|
|
40c72422ff | ||
|
|
e9fc855a1c | ||
|
|
09778972a6 | ||
|
|
df1d676131 | ||
|
|
be54070855 | ||
|
|
32f21f08ff | ||
|
|
046aa49814 | ||
|
|
18d9802f03 | ||
|
|
971e8f2336 | ||
|
|
de265cdde6 | ||
|
|
cf249d6c0d | ||
|
|
11093613c5 | ||
|
|
1f4027573f | ||
|
|
503394f3ed | ||
|
|
410ef535e8 | ||
|
|
e7177ab03e | ||
|
|
3e4f466114 | ||
|
|
127ec36a55 | ||
|
|
bbc88a6bbb | ||
|
|
f7f1078911 | ||
|
|
f1256a121d | ||
|
|
104d41d294 | ||
|
|
c52266ba11 |
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -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
|
||||||
@@ -12,6 +12,10 @@ htmlcov/
|
|||||||
# Local virtual environments
|
# Local virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
.venv-win/
|
.venv-win/
|
||||||
|
|
||||||
|
# Local Linux toolchain (downloaded for the startup scripts)
|
||||||
|
.tools/node-*-linux-x64/
|
||||||
|
|
||||||
app/data/
|
app/data/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|||||||
@@ -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 能映射到正确模型。
|
|
||||||
- 最小系统能够编译;声称可仿真的模型必须产生有限结果。
|
|
||||||
- 针对性测试、完整回归和必要的前端构建通过。
|
|
||||||
- 文档记录了模型假设、适用范围和已知限制。
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# test_mql
|
# 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`
|
- AMESim 源模型:`AmesimModels/test_mql.ame`
|
||||||
- Python 系统类:`PythonModels.systems.test_mql.TestMqlSystem`
|
- Python 系统类:`app.simulation.examples.test_mql.system.TestMqlSystem`
|
||||||
- 结构运行入口:`PythonModels/scripts/run_test_mql.py`
|
- 结构运行入口:`app.simulation.examples.test_mql.run`
|
||||||
- 132 状态比较入口:`PythonModels/scripts/run_test_mql_full_state_comparison.py`
|
- 132 状态比较入口:`app.simulation.examples.test_mql.run_full_state_comparison`
|
||||||
- AMESim 组件数:117
|
- AMESim 组件数:117
|
||||||
- LINE 连接数:84
|
- LINE 连接数:84
|
||||||
- 连续状态数:132
|
- 连续状态数:132
|
||||||
@@ -47,16 +47,16 @@ Python 输出只有通过 AMESim baseline 对比后才能作为数值一致性
|
|||||||
|
|
||||||
主要实现位置:
|
主要实现位置:
|
||||||
|
|
||||||
- `PythonModels/systems/test_mql.py`
|
- `app/simulation/examples/test_mql/system.py`
|
||||||
- `PythonModels/systems/test_mql_closure.py`
|
- `app/simulation/examples/test_mql/closure.py`
|
||||||
- `PythonModels/systems/test_mql_pneumatic.py`
|
- `app/simulation/examples/test_mql/pneumatic.py`
|
||||||
- `PythonModels/systems/test_mql_mechanical.py`
|
- `app/simulation/examples/test_mql/mechanical.py`
|
||||||
- `PythonModels/systems/test_mql_lines.py`
|
- `app/simulation/examples/test_mql/lines.py`
|
||||||
- `PythonModels/components/amesim_pneumatic.py`
|
- `app/simulation/examples/test_mql/primitives/`
|
||||||
- `PythonModels/components/amesim_mechanical.py`
|
- `app/simulation/examples/test_mql/structural_network.py`
|
||||||
- `PythonModels/reporting/amesim_results.py`
|
- `app/simulation/reporting/amesim_results.py`
|
||||||
- `PythonModels/reporting/test_mql_comparison.py`
|
- `app/simulation/reporting/test_mql_comparison.py`
|
||||||
- `PythonModels/scripts/run_test_mql_full_state_comparison.py`
|
- `app/simulation/examples/test_mql/run_full_state_comparison.py`
|
||||||
|
|
||||||
## 当前对比结果
|
## 当前对比结果
|
||||||
|
|
||||||
@@ -109,13 +109,13 @@ Python 输出只有通过 AMESim baseline 对比后才能作为数值一致性
|
|||||||
运行默认短时域 comparison:
|
运行默认短时域 comparison:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m PythonModels.scripts.run_test_mql_full_state_comparison
|
python3 -m app.simulation.examples.test_mql.run_full_state_comparison
|
||||||
```
|
```
|
||||||
|
|
||||||
运行 PNVO 事件边界诊断:
|
运行 PNVO 事件边界诊断:
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
运行相关测试:
|
运行相关测试:
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
@@ -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 原始结果,参数大概率还要继续调。
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""Python port scaffold for the Modelica-based pressurization system."""
|
|
||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""Component implementations for the Python system model."""
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""Core abstractions for the Python system model."""
|
|
||||||
|
|
||||||
@@ -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."""
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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])
|
|
||||||
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
-8
@@ -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
|
|
||||||
-8
@@ -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,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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""System assembly modules."""
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
]
|
|
||||||
@@ -2,36 +2,113 @@
|
|||||||
|
|
||||||
ReactFlow 系统建模与 `app.simulation` 仿真后端。
|
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 启动时自动加载。
|
- `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/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
|
||||||
- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
|
- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
|
||||||
- `POST /api/reactflow/simulate-test-mql`:运行现有固定拓扑 AMESim `test_mql` 迁移模型;该接口不把 AMESim 子模型注册为公开拖拽组件。
|
- `POST /api/reactflow/simulate-test-mql`:返回固定拓扑 AMESim `test_mql` 的结构与采样摘要;132 状态数值对比使用独立 comparison 入口。AMESim 子模型已有 19 个第一版公开模型,但该接口本身不是任意拖拽拓扑求解器。
|
||||||
- `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。
|
- `POST /api/system-xml/validate`:接收原始 System XML v3,返回 XML、XSD 和模型语义三层诊断。
|
||||||
- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。
|
- `POST /api/system-xml/parse`:校验 XML,并返回可直接编译、求解的规范化模型数据;它不还原 ReactFlow 画布布局。
|
||||||
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。
|
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。
|
||||||
- `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。
|
- `POST /api/system-xml/simulate`:按 XML 中的组件、连接、参数和仿真设置运行当前支持的气动、标量信号及一维机械网络 MVP,并返回组件及端口时间序列。
|
||||||
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
|
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
|
||||||
|
|
||||||
气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。
|
气动端口的后端契约采用 `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 环境时使用:
|
XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
- [开发文档索引](docs/README.md)
|
- [开发文档索引](docs/README.md)
|
||||||
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md)
|
- [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-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)
|
||||||
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
|
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
|
||||||
- [System XML v2 协议](docs/system-xml-v2.md)
|
- [System XML v3 协议(当前规范)](docs/standard/system-xml-v3.md)
|
||||||
- [System XML v2 XSD](schemas/system-simulation-v2.xsd)
|
- [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd)
|
||||||
- [System XML v1 协议(旧版)](docs/system-xml-v1.md)
|
|
||||||
- [System XML v1 XSD(旧版)](schemas/system-simulation-v1.xsd)
|
|
||||||
+561
-205
File diff suppressed because it is too large.
Load diff
+73
-18
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
|
`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
|
||||||
|
|
||||||
目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。
|
目标不是逐行翻译源模型,而是建立可运行、可测试、可导出,并能与 OpenModelica 或 AMESim baseline 对比的 Python 仿真框架。
|
||||||
|
|
||||||
当前状态不是“只有骨架”,而是“`Testmodel` 已有一版可运行的 ODE 近似实现,并具备基础结果导出与对比能力”。
|
当前包含两条模型线:`Testmodel` 已有可运行的 ODE 近似和 OpenModelica 对比能力;`test_mql` 已形成 132 状态气动机械总闭包,正在按 AMESim baseline 做数值校准。
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
@@ -14,9 +14,11 @@
|
|||||||
- `components/experimental/storage/`: 气瓶和贮箱等储能元件。
|
- `components/experimental/storage/`: 气瓶和贮箱等储能元件。
|
||||||
- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。
|
- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。
|
||||||
- `components/experimental/junctions/`: 三通等连接节点。
|
- `components/experimental/junctions/`: 三通等连接节点。
|
||||||
|
- `components/amesim/`: AMESim 气动、信号和机械组件原语。
|
||||||
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
|
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
|
||||||
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和运行入口。
|
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和基线运行入口。
|
||||||
- `reporting/`: CSV、SVG、运行报告和 Modelica 对比结果导出。
|
- `examples/test_mql/`: AMESim `test_mql` 的系统装配、校准原语、诊断和运行入口。
|
||||||
|
- `reporting/`: CSV、SVG、运行报告、Modelica 对比结果、AMESim 结果读取和诊断报告导出。
|
||||||
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
|
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
|
||||||
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
|
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
|
||||||
|
|
||||||
@@ -27,25 +29,66 @@
|
|||||||
FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow
|
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 /
|
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
|
||||||
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
|
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
|
||||||
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
|
[`组件模型建模规范 v1`](../../docs/standard/component-model-authoring-spec-v1.md)和
|
||||||
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。
|
[`组件库分类、发现与读取规范 v1`](../../docs/standard/component-library-spec-v1.md)。
|
||||||
|
|
||||||
当前关键文件:
|
当前关键文件:
|
||||||
|
|
||||||
- `core/medium.py`: 理想气体近似介质 `IdealGasMedium`
|
- `core/medium.py`: 气体介质协议 `GasMedium` 与通用理想气体实现 `IdealGasMedium`
|
||||||
- `core/medium.py`: 温度相关的空气近似介质 `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`,负责组件注册、连接拓扑和状态向量拼装
|
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
||||||
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
||||||
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
||||||
- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
|
- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
|
||||||
- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
|
- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
|
||||||
- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
|
- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
|
||||||
|
- `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/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
|
||||||
- `examples/testmodel/run.py`: 基线运行与程序化执行入口
|
- `reporting/amesim_results.py`: AMESim 结果读取入口
|
||||||
- `tests/`: 当前组件契约、XML、通用系统和结果导出测试
|
- `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)。
|
||||||
|
|
||||||
## 当前阶段进度
|
## 当前阶段进度
|
||||||
|
|
||||||
@@ -175,7 +218,7 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
|
|||||||
`testmodel_tank_temperature.svg`
|
`testmodel_tank_temperature.svg`
|
||||||
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
|
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
|
||||||
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
|
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
|
||||||
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
|
13. 面向 System XML v3 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
|
||||||
|
|
||||||
当前没有实现:
|
当前没有实现:
|
||||||
|
|
||||||
@@ -268,18 +311,30 @@ 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%`
|
- `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.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%`
|
- `mycylinder.T`: `max_abs_error = 0.009069 K`, `max_rel_error = 0.003870%`
|
||||||
|
|
||||||
这说明在当前基线工况下,Python 版主变量已经能较好贴近 OpenModelica 结果。
|
这说明在当前基线工况下,Python 版主变量已经能较好贴近 OpenModelica 结果。
|
||||||
|
|
||||||
## 当前架构判断
|
## AMESim test_mql 当前进度
|
||||||
|
|
||||||
|
`test_mql` 从 `AmesimModels/test_mql.ame` 迁移,并与旧 `testmodel` 保持独立。
|
||||||
|
固定算例实现统一位于 `examples/test_mql/`,结果读取和比较能力位于
|
||||||
|
`reporting/`;公开拖拽组件由 `components/amesim/library.py` 单独登记。
|
||||||
|
|
||||||
|
当前已形成 112 个气动状态和 20 个机械状态的总闭包、AMESim 原生结果读取、
|
||||||
|
`Data_Path` 输出校验及短时域 comparison。这里不再复制易过期的数值进度;
|
||||||
|
最新对比结果、运行命令、限制和下一步校准路径以
|
||||||
|
[`AmesimModels/test_mql/README.md`](../../AmesimModels/test_mql/README.md)
|
||||||
|
为唯一说明。当前仍不能宣称 Python 时域仿真与 AMESim 全局一致。
|
||||||
|
|
||||||
|
## Testmodel 当前架构判断
|
||||||
|
|
||||||
如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于:
|
如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于:
|
||||||
|
|
||||||
@@ -293,7 +348,7 @@ print(result.used_modelica_reference)
|
|||||||
|
|
||||||
`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。
|
`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。
|
||||||
|
|
||||||
## 已知限制
|
## Testmodel 已知限制
|
||||||
|
|
||||||
当前最主要的限制可以直接理解成下面几条:
|
当前最主要的限制可以直接理解成下面几条:
|
||||||
|
|
||||||
@@ -319,7 +374,7 @@ print(result.used_modelica_reference)
|
|||||||
- 作为最终工程结论的唯一依据
|
- 作为最终工程结论的唯一依据
|
||||||
- 直接扩展到更复杂拓扑而不补通用连接器语义
|
- 直接扩展到更复杂拓扑而不补通用连接器语义
|
||||||
|
|
||||||
## 文件级现状
|
## Testmodel 文件级现状
|
||||||
|
|
||||||
按代码现状逐项看:
|
按代码现状逐项看:
|
||||||
|
|
||||||
@@ -342,7 +397,7 @@ print(result.used_modelica_reference)
|
|||||||
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
|
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
|
||||||
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
|
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
|
||||||
|
|
||||||
## 当前主技术债
|
## Testmodel 当前主技术债
|
||||||
|
|
||||||
目前最主要的技术债,可以直接理解成下面 4 件事:
|
目前最主要的技术债,可以直接理解成下面 4 件事:
|
||||||
|
|
||||||
|
|||||||
@@ -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_TYPE = "amesim_pnpl01"
|
||||||
MODEL_VERSION = "0.1.0"
|
MODEL_VERSION = "0.1.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
|
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
|
||||||
PARAMETERS = ()
|
PARAMETERS = ()
|
||||||
RESULT_VARIABLES = ()
|
RESULT_VARIABLES = ()
|
||||||
@@ -26,7 +27,7 @@ class AmesimPnpl01(AlgebraicComponent):
|
|||||||
label="PNPL01 零气动流边界",
|
label="PNPL01 零气动流边界",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="boundary",
|
category_id="boundary",
|
||||||
symbol="generic",
|
symbol="amesim_pnpl01",
|
||||||
ports=(PortDisplaySpec("port_1", "left", order=10),),
|
ports=(PortDisplaySpec("port_1", "left", order=10),),
|
||||||
order=10,
|
order=10,
|
||||||
)
|
)
|
||||||
@@ -46,6 +47,9 @@ class AmesimPnpl01(AlgebraicComponent):
|
|||||||
) -> AmesimPnpl01:
|
) -> AmesimPnpl01:
|
||||||
return cls(name=name)
|
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, ...]:
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
return (
|
return (
|
||||||
EquationResidual(
|
EquationResidual(
|
||||||
|
|||||||
@@ -1,34 +1,74 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
from collections.abc import Mapping
|
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.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.equations import EquationResidual
|
||||||
from app.simulation.core.metadata import (
|
from app.simulation.core.metadata import (
|
||||||
|
ParameterCondition,
|
||||||
ParameterDefinition,
|
ParameterDefinition,
|
||||||
|
ParameterOption,
|
||||||
ResultVariableDefinition,
|
ResultVariableDefinition,
|
||||||
)
|
)
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import GasMedium
|
||||||
from app.simulation.core.ports import PortDefinition
|
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):
|
class AmesimPnor001(AlgebraicComponent):
|
||||||
"""AMESim PNOR001 constant-flow-coefficient pneumatic orifice.
|
"""AMESim PNOR001 constant-flow-coefficient pneumatic orifice.
|
||||||
|
|
||||||
This public component preserves the PNOR001 catalog/XML contract and uses a
|
This public component preserves the PNOR001 catalog/XML contract and uses
|
||||||
finite bidirectional compressible-orifice approximation. The Siemens
|
real-gas pressure-ratio flow with AMESim-style near-equal-pressure smoothing.
|
||||||
`pn2rcqfix_` details remain a later calibration target.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pnor001"
|
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 = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
)
|
)
|
||||||
PARAMETERS = (
|
PARAMETERS = (
|
||||||
|
AMESIM_GAS_INDEX_PARAMETER,
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"cq",
|
"cq",
|
||||||
0.72,
|
0.72,
|
||||||
@@ -37,6 +77,8 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
unit="",
|
unit="",
|
||||||
minimum=1.0e-10,
|
minimum=1.0e-10,
|
||||||
maximum=1.0,
|
maximum=1.0,
|
||||||
|
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
|
||||||
|
visible_when=_FLOWSET_USES_CQ,
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"area",
|
"area",
|
||||||
@@ -46,6 +88,8 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
unit="m2",
|
unit="m2",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
maximum=1.0,
|
maximum=1.0,
|
||||||
|
description="选择 Cq/面积方式时用于流量计算的有效孔口面积。",
|
||||||
|
visible_when=_FLOWSET_USES_CQ,
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"Cv",
|
"Cv",
|
||||||
@@ -54,6 +98,8 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
quantity="dimensionless",
|
quantity="dimensionless",
|
||||||
unit="",
|
unit="",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
|
description="选择 Cv 方式时使用的英制流量系数。",
|
||||||
|
visible_when=_FLOWSET_USES_CV,
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"Kv",
|
"Kv",
|
||||||
@@ -62,15 +108,8 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
quantity="dimensionless",
|
quantity="dimensionless",
|
||||||
unit="",
|
unit="",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
),
|
description="选择 Kv 方式时使用的公制流量系数。",
|
||||||
ParameterDefinition(
|
visible_when=_FLOWSET_USES_KV,
|
||||||
"gi",
|
|
||||||
1.0,
|
|
||||||
label="气体类型索引",
|
|
||||||
quantity="dimensionless",
|
|
||||||
unit="",
|
|
||||||
minimum=1.0,
|
|
||||||
maximum=99.0,
|
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"flowset",
|
"flowset",
|
||||||
@@ -80,6 +119,9 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
unit="",
|
unit="",
|
||||||
minimum=1.0,
|
minimum=1.0,
|
||||||
maximum=3.0,
|
maximum=3.0,
|
||||||
|
editor="choice",
|
||||||
|
options=_FLOW_COEFFICIENT_OPTIONS,
|
||||||
|
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
RESULT_VARIABLES = (
|
RESULT_VARIABLES = (
|
||||||
@@ -104,18 +146,19 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
label="PNOR001 常系数气动孔口",
|
label="PNOR001 常系数气动孔口",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="orifice",
|
symbol="amesim_pnor001",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
),
|
),
|
||||||
order=10,
|
order=10,
|
||||||
|
parameter_groups=(_PNOR001_FLOW_COEFFICIENT_GROUP,),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
*,
|
*,
|
||||||
cq: float = 0.72,
|
cq: float = 0.72,
|
||||||
area: float = 5.0e-6,
|
area: float = 5.0e-6,
|
||||||
@@ -140,7 +183,7 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
self.area = float(area)
|
self.area = float(area)
|
||||||
self.Cv = float(Cv)
|
self.Cv = float(Cv)
|
||||||
self.Kv = float(Kv)
|
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)
|
self.flowset = self._integer_parameter("flowset", flowset)
|
||||||
if self.flowset not in {1, 2, 3}:
|
if self.flowset not in {1, 2, 3}:
|
||||||
raise ValueError("PNOR001 flowset must be 1, 2, or 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_1.h_outflow = initial_h
|
||||||
self.port_2 = self.register_declared_port("port_2")
|
self.port_2 = self.register_declared_port("port_2")
|
||||||
self.port_2.h_outflow = initial_h
|
self.port_2.h_outflow = initial_h
|
||||||
|
self._connected_h: dict[str, float] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _integer_parameter(name: str, value: float) -> int:
|
def _integer_parameter(name: str, value: float) -> int:
|
||||||
@@ -163,7 +207,7 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
parameters: Mapping[str, float],
|
parameters: Mapping[str, float],
|
||||||
) -> AmesimPnor001:
|
) -> AmesimPnor001:
|
||||||
return cls(
|
return cls(
|
||||||
@@ -205,12 +249,114 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
|
|
||||||
def _upstream_temperature(self, port_name: str) -> float:
|
def _upstream_temperature(self, port_name: str) -> float:
|
||||||
port = self.get_port(port_name)
|
port = self.get_port(port_name)
|
||||||
if port.h_outflow > 0.0:
|
inlet_h = self._connected_h.get(port_name, port.h_outflow)
|
||||||
return max(port.h_outflow / self.medium.cp_ref, 1.0)
|
return max(
|
||||||
return self.medium.T_ref
|
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:
|
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
|
return 0.0
|
||||||
if p_1 > p_2:
|
if p_1 > p_2:
|
||||||
return self._one_way_mass_flow(
|
return self._one_way_mass_flow(
|
||||||
@@ -232,45 +378,49 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
upstream_temperature: float,
|
upstream_temperature: float,
|
||||||
) -> float:
|
) -> float:
|
||||||
p_up = max(upstream_pressure, 1.0)
|
p_up = max(upstream_pressure, 1.0)
|
||||||
p_down = max(min(downstream_pressure, p_up), 0.0)
|
|
||||||
T_up = max(upstream_temperature, 1.0)
|
T_up = max(upstream_temperature, 1.0)
|
||||||
gamma = max(self.medium.gamma, 1.000001)
|
mass_flow_parameter, _ = self._one_way_flow_characteristics(
|
||||||
pressure_ratio = max(p_down / p_up, 0.0)
|
upstream_pressure=p_up,
|
||||||
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
|
downstream_pressure=downstream_pressure,
|
||||||
if pressure_ratio <= critical_ratio:
|
upstream_temperature=T_up,
|
||||||
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * (
|
)
|
||||||
2.0 / (gamma + 1.0)
|
return (
|
||||||
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
|
self.effective_cq
|
||||||
else:
|
* self.effective_area
|
||||||
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** (
|
* p_up
|
||||||
(gamma + 1.0) / gamma
|
* mass_flow_parameter
|
||||||
)
|
/ sqrt(T_up)
|
||||||
flow_factor = sqrt(
|
)
|
||||||
max(
|
|
||||||
2.0
|
|
||||||
* gamma
|
|
||||||
* expansion
|
|
||||||
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
|
|
||||||
0.0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return self.effective_cq * self.effective_area * p_up * flow_factor
|
|
||||||
|
|
||||||
def component_result_values(self) -> Mapping[str, float]:
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
p_1 = max(self.port_1.p, 1.0)
|
p_1 = max(self.port_1.p, 1.0)
|
||||||
p_2 = max(self.port_2.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))
|
if p_1 >= p_2:
|
||||||
upstream_pressure = max(p_1, p_2)
|
upstream_port_name = "port_1"
|
||||||
upstream_temperature = self._upstream_temperature(
|
upstream_pressure = p_1
|
||||||
"port_1" if p_1 >= p_2 else "port_2"
|
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 {
|
return {
|
||||||
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
|
"cm": mass_flow_parameter,
|
||||||
"gasvel": m_flow / (density * area),
|
"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, ...]:
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
return (
|
return (
|
||||||
EquationResidual(
|
EquationResidual(
|
||||||
@@ -302,6 +452,7 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
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_1.h_outflow = connected_h["port_2"]
|
||||||
self.port_2.h_outflow = connected_h["port_1"]
|
self.port_2.h_outflow = connected_h["port_1"]
|
||||||
|
|
||||||
@@ -315,12 +466,17 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pnvo001_fixed"
|
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 = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||||
)
|
)
|
||||||
PARAMETERS = (
|
PARAMETERS = (
|
||||||
|
AMESIM_GAS_INDEX_PARAMETER,
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"cq",
|
"cq",
|
||||||
0.72,
|
0.72,
|
||||||
@@ -329,6 +485,8 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
unit="",
|
unit="",
|
||||||
minimum=1.0e-10,
|
minimum=1.0e-10,
|
||||||
maximum=1.0,
|
maximum=1.0,
|
||||||
|
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
|
||||||
|
visible_when=_FLOWSET_USES_CQ,
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"area0",
|
"area0",
|
||||||
@@ -338,6 +496,8 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
unit="m2",
|
unit="m2",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
maximum=1.0,
|
maximum=1.0,
|
||||||
|
description="阀门完全开启时的最大有效孔口面积。",
|
||||||
|
visible_when=_FLOWSET_USES_CQ,
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"Cv",
|
"Cv",
|
||||||
@@ -346,6 +506,8 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
quantity="dimensionless",
|
quantity="dimensionless",
|
||||||
unit="",
|
unit="",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
|
description="选择 Cv 方式时使用的最大英制流量系数。",
|
||||||
|
visible_when=_FLOWSET_USES_CV,
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"Kv",
|
"Kv",
|
||||||
@@ -354,15 +516,8 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
quantity="dimensionless",
|
quantity="dimensionless",
|
||||||
unit="",
|
unit="",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
),
|
description="选择 Kv 方式时使用的最大公制流量系数。",
|
||||||
ParameterDefinition(
|
visible_when=_FLOWSET_USES_KV,
|
||||||
"gi",
|
|
||||||
1.0,
|
|
||||||
label="气体类型索引",
|
|
||||||
quantity="dimensionless",
|
|
||||||
unit="",
|
|
||||||
minimum=1.0,
|
|
||||||
maximum=99.0,
|
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"flowset",
|
"flowset",
|
||||||
@@ -372,6 +527,9 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
unit="",
|
unit="",
|
||||||
minimum=1.0,
|
minimum=1.0,
|
||||||
maximum=3.0,
|
maximum=3.0,
|
||||||
|
editor="choice",
|
||||||
|
options=_FLOW_COEFFICIENT_OPTIONS,
|
||||||
|
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"opening",
|
"opening",
|
||||||
@@ -381,6 +539,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
unit="",
|
unit="",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
maximum=1.0,
|
maximum=1.0,
|
||||||
|
description="固定的归一化阀门开度;0 表示关闭,1 表示完全开启。",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
RESULT_VARIABLES = (
|
RESULT_VARIABLES = (
|
||||||
@@ -413,18 +572,20 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
label="PNVO001 固定开度气动孔口",
|
label="PNVO001 固定开度气动孔口",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="orifice",
|
symbol="amesim_pnvo001_fixed",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_2", "left", order=10),
|
# AMESim default geometry places port 2 right and port 3 left.
|
||||||
PortDisplaySpec("port_3", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=10),
|
||||||
|
PortDisplaySpec("port_3", "left", order=20),
|
||||||
),
|
),
|
||||||
order=30,
|
order=30,
|
||||||
|
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
*,
|
*,
|
||||||
cq: float = 0.72,
|
cq: float = 0.72,
|
||||||
area0: float = 5.0e-6,
|
area0: float = 5.0e-6,
|
||||||
@@ -451,7 +612,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
self.area0 = float(area0)
|
self.area0 = float(area0)
|
||||||
self.Cv = float(Cv)
|
self.Cv = float(Cv)
|
||||||
self.Kv = float(Kv)
|
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)
|
self.flowset = self._integer_parameter("flowset", flowset)
|
||||||
if self.flowset not in {1, 2, 3}:
|
if self.flowset not in {1, 2, 3}:
|
||||||
raise ValueError("PNVO001 fixed-opening flowset must be 1, 2, or 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_2.h_outflow = initial_h
|
||||||
self.port_3 = self.register_declared_port("port_3")
|
self.port_3 = self.register_declared_port("port_3")
|
||||||
self.port_3.h_outflow = initial_h
|
self.port_3.h_outflow = initial_h
|
||||||
|
self._connected_h: dict[str, float] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _integer_parameter(name: str, value: float) -> int:
|
def _integer_parameter(name: str, value: float) -> int:
|
||||||
@@ -475,7 +637,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
parameters: Mapping[str, float],
|
parameters: Mapping[str, float],
|
||||||
) -> AmesimPnvo001FixedOpening:
|
) -> AmesimPnvo001FixedOpening:
|
||||||
return cls(
|
return cls(
|
||||||
@@ -508,12 +670,47 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
|
|
||||||
def _upstream_temperature(self, port_name: str) -> float:
|
def _upstream_temperature(self, port_name: str) -> float:
|
||||||
port = self.get_port(port_name)
|
port = self.get_port(port_name)
|
||||||
if port.h_outflow > 0.0:
|
# A component port's h_outflow describes fluid leaving the valve; the
|
||||||
return max(port.h_outflow / self.medium.cp_ref, 1.0)
|
# upstream state comes from the connection on that same physical side.
|
||||||
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def mass_flow(self, p_2: float, p_3: float) -> float:
|
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
|
return 0.0
|
||||||
if p_2 > p_3:
|
if p_2 > p_3:
|
||||||
return self._one_way_mass_flow(
|
return self._one_way_mass_flow(
|
||||||
@@ -527,6 +724,82 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
upstream_temperature=self._upstream_temperature("port_3"),
|
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(
|
def _one_way_mass_flow(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -535,46 +808,63 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
upstream_temperature: float,
|
upstream_temperature: float,
|
||||||
) -> float:
|
) -> float:
|
||||||
p_up = max(upstream_pressure, 1.0)
|
p_up = max(upstream_pressure, 1.0)
|
||||||
p_down = max(min(downstream_pressure, p_up), 0.0)
|
|
||||||
T_up = max(upstream_temperature, 1.0)
|
T_up = max(upstream_temperature, 1.0)
|
||||||
gamma = max(self.medium.gamma, 1.000001)
|
mass_flow_parameter, _gas_velocity = self._one_way_flow_characteristics(
|
||||||
pressure_ratio = max(p_down / p_up, 0.0)
|
upstream_pressure=p_up,
|
||||||
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
|
downstream_pressure=downstream_pressure,
|
||||||
if pressure_ratio <= critical_ratio:
|
upstream_temperature=T_up,
|
||||||
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * (
|
)
|
||||||
2.0 / (gamma + 1.0)
|
return (
|
||||||
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
|
self.effective_cq
|
||||||
else:
|
* self.effective_area
|
||||||
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** (
|
* p_up
|
||||||
(gamma + 1.0) / gamma
|
* mass_flow_parameter
|
||||||
)
|
/ sqrt(T_up)
|
||||||
flow_factor = sqrt(
|
)
|
||||||
max(
|
|
||||||
2.0
|
|
||||||
* gamma
|
|
||||||
* expansion
|
|
||||||
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
|
|
||||||
0.0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return self.effective_cq * self.effective_area * p_up * flow_factor
|
|
||||||
|
|
||||||
def component_result_values(self) -> Mapping[str, float]:
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
p_2 = max(self.port_2.p, 1.0)
|
p_2 = max(self.port_2.p, 1.0)
|
||||||
p_3 = max(self.port_3.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))
|
if p_2 >= p_3:
|
||||||
upstream_pressure = max(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(
|
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)
|
mass_flow_parameter, gas_velocity = self._one_way_flow_characteristics(
|
||||||
area = max(self.effective_area, 1.0e-18)
|
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 {
|
return {
|
||||||
"xv": self.opening,
|
"xv": self.opening,
|
||||||
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
|
"cm": mass_flow_parameter,
|
||||||
"gasvel": m_flow / (density * area),
|
"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, ...]:
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
return (
|
return (
|
||||||
EquationResidual(
|
EquationResidual(
|
||||||
@@ -606,6 +896,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
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_2.h_outflow = connected_h["port_3"]
|
||||||
self.port_3.h_outflow = connected_h["port_2"]
|
self.port_3.h_outflow = connected_h["port_2"]
|
||||||
|
|
||||||
@@ -614,39 +905,104 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
|||||||
"""AMESim PNVO001 signal-controlled pneumatic orifice."""
|
"""AMESim PNVO001 signal-controlled pneumatic orifice."""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pnvo001"
|
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 = (
|
PORTS = (
|
||||||
PortDefinition.signal("res", nominal_role="input"),
|
PortDefinition.signal("res", nominal_role="input"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||||
)
|
)
|
||||||
PARAMETERS = (
|
PARAMETERS = (
|
||||||
ParameterDefinition("cq", 0.72, label="流量系数 Cq", quantity="dimensionless", unit="", minimum=1.0e-10, maximum=1.0),
|
AMESIM_GAS_INDEX_PARAMETER,
|
||||||
ParameterDefinition("area0", 5.0e-6, label="最大孔口面积", quantity="area", unit="m2", minimum=0.0, maximum=1.0),
|
ParameterDefinition(
|
||||||
ParameterDefinition("Cv", 0.5, label="最大流量系数 Cv", quantity="dimensionless", unit="", minimum=0.0),
|
"cq",
|
||||||
ParameterDefinition("Kv", 0.4, label="最大流量系数 Kv", quantity="dimensionless", unit="", minimum=0.0),
|
0.72,
|
||||||
ParameterDefinition("gi", 1.0, label="气体类型索引", quantity="dimensionless", unit="", minimum=1.0, maximum=99.0),
|
label="流量系数 Cq",
|
||||||
ParameterDefinition("flowset", 1.0, label="流量系数设置", quantity="dimensionless", unit="", minimum=1.0, maximum=3.0),
|
quantity="dimensionless",
|
||||||
ParameterDefinition("opening0", 1.0, label="初始开度", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
|
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
|
RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES
|
||||||
DISPLAY = ComponentDisplaySpec(
|
DISPLAY = ComponentDisplaySpec(
|
||||||
label="PNVO001 信号开度气动孔口",
|
label="PNVO001 信号开度气动孔口",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="orifice",
|
symbol="amesim_pnvo001",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("res", "left", order=5),
|
PortDisplaySpec("res", "left", order=5),
|
||||||
PortDisplaySpec("port_2", "left", order=10),
|
# AMESim default geometry places port 2 right and port 3 left.
|
||||||
PortDisplaySpec("port_3", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=10),
|
||||||
|
PortDisplaySpec("port_3", "left", order=20),
|
||||||
),
|
),
|
||||||
order=35,
|
order=35,
|
||||||
|
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
*,
|
*,
|
||||||
cq: float = 0.72,
|
cq: float = 0.72,
|
||||||
area0: float = 5.0e-6,
|
area0: float = 5.0e-6,
|
||||||
@@ -673,7 +1029,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
|||||||
self.area0 = float(area0)
|
self.area0 = float(area0)
|
||||||
self.Cv = float(Cv)
|
self.Cv = float(Cv)
|
||||||
self.Kv = float(Kv)
|
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)
|
self.flowset = self._integer_parameter("flowset", flowset)
|
||||||
if self.flowset not in {1, 2, 3}:
|
if self.flowset not in {1, 2, 3}:
|
||||||
raise ValueError("PNVO001 signal-opening flowset must be 1, 2, or 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_2.h_outflow = initial_h
|
||||||
self.port_3 = self.register_declared_port("port_3")
|
self.port_3 = self.register_declared_port("port_3")
|
||||||
self.port_3.h_outflow = initial_h
|
self.port_3.h_outflow = initial_h
|
||||||
|
self._connected_h: dict[str, float] = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
parameters: Mapping[str, float],
|
parameters: Mapping[str, float],
|
||||||
) -> "AmesimPnvo001SignalOpening":
|
) -> "AmesimPnvo001SignalOpening":
|
||||||
return cls(name=name, medium=medium, **dict(parameters))
|
return cls(name=name, medium=medium, **dict(parameters))
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
from app.simulation.core.ports import PortDefinition, PortState
|
||||||
|
|
||||||
|
|
||||||
class _AmesimPneumaticNode(AlgebraicComponent):
|
_REFERENCE_OUTFLOW_REGULARIZATION_RATIO = 0.05
|
||||||
"""Shared implementation for AMESim pneumatic junction submodels."""
|
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
REFERENCE_PORT = "port_2"
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
def __init__(self, name: str) -> None:
|
||||||
super().__init__(name=name)
|
super().__init__(name=name)
|
||||||
self.set_parameter_values({})
|
self.set_parameter_values({})
|
||||||
|
self.temperature_reference_h = 0.0
|
||||||
for definition in self.PORTS:
|
for definition in self.PORTS:
|
||||||
setattr(self, definition.name, self.register_declared_port(definition.name))
|
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, ...]:
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
reference = self.get_port(self.REFERENCE_PORT)
|
reference = self.get_port(self.REFERENCE_PORT)
|
||||||
residuals: list[EquationResidual] = []
|
residuals: list[EquationResidual] = []
|
||||||
@@ -58,6 +97,10 @@ class _AmesimPneumaticNode(AlgebraicComponent):
|
|||||||
return tuple(residuals)
|
return tuple(residuals)
|
||||||
|
|
||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
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 = [
|
incoming = [
|
||||||
(port.m_flow, connected_h[name])
|
(port.m_flow, connected_h[name])
|
||||||
for name, port in self.ports.items()
|
for name, port in self.ports.items()
|
||||||
@@ -67,19 +110,62 @@ class _AmesimPneumaticNode(AlgebraicComponent):
|
|||||||
if total_flow > 1e-12:
|
if total_flow > 1e-12:
|
||||||
mixed_h = sum(m_flow * h for m_flow, h in incoming) / total_flow
|
mixed_h = sum(m_flow * h for m_flow, h in incoming) / total_flow
|
||||||
else:
|
else:
|
||||||
mixed_h = connected_h.get(
|
mixed_h = self.temperature_reference_h
|
||||||
self.REFERENCE_PORT,
|
|
||||||
sum(connected_h.values()) / len(connected_h) if connected_h else 0.0,
|
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):
|
class AmesimPn3Node2(_AmesimPneumaticNode):
|
||||||
"""AMESim PN3NODE2 pneumatic three-port junction."""
|
"""AMESim PN3NODE2 pneumatic three-port junction."""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pn3node2"
|
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 = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -91,7 +177,7 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
|
|||||||
label="PN3NODE2 三端气动节点",
|
label="PN3NODE2 三端气动节点",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="junctions",
|
category_id="junctions",
|
||||||
symbol="tee",
|
symbol="amesim_pn3node2",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -115,7 +201,11 @@ class AmesimP4Node2(_AmesimPneumaticNode):
|
|||||||
"""AMESim P4NODE2 pneumatic four-port junction."""
|
"""AMESim P4NODE2 pneumatic four-port junction."""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_p4node2"
|
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 = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -128,7 +218,7 @@ class AmesimP4Node2(_AmesimPneumaticNode):
|
|||||||
label="P4NODE2 四端气动节点",
|
label="P4NODE2 四端气动节点",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="junctions",
|
category_id="junctions",
|
||||||
symbol="generic",
|
symbol="amesim_p4node2",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ from app.simulation.core.catalog import (
|
|||||||
LIBRARY = ComponentLibrarySpec(
|
LIBRARY = ComponentLibrarySpec(
|
||||||
id="amesim",
|
id="amesim",
|
||||||
label="AMESim 组件库",
|
label="AMESim 组件库",
|
||||||
version="0.1.0",
|
version="0.3.0",
|
||||||
source_package="app.simulation.components.amesim",
|
source_package="app.simulation.components.amesim",
|
||||||
temporary=True,
|
temporary=True,
|
||||||
order=200,
|
order=200,
|
||||||
categories=(
|
categories=(
|
||||||
|
ComponentCategorySpec(id="media", label="介质物性", order=5),
|
||||||
ComponentCategorySpec(id="storage", label="储能元件", order=10),
|
ComponentCategorySpec(id="storage", label="储能元件", order=10),
|
||||||
ComponentCategorySpec(id="flow", label="流动元件", order=20),
|
ComponentCategorySpec(id="flow", label="流动元件", order=20),
|
||||||
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
|
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
|
||||||
@@ -22,6 +23,8 @@ LIBRARY = ComponentLibrarySpec(
|
|||||||
ComponentCategorySpec(id="mechanical", label="机械元件", order=60),
|
ComponentCategorySpec(id="mechanical", label="机械元件", order=60),
|
||||||
),
|
),
|
||||||
models=(
|
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.boundary.sources:AmesimPnpl01",
|
||||||
"app.simulation.components.amesim.signals.sources:AmesimStep0",
|
"app.simulation.components.amesim.signals.sources:AmesimStep0",
|
||||||
"app.simulation.components.amesim.signals.sources:AmesimUd00",
|
"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:AmesimMecmas21",
|
||||||
"app.simulation.components.amesim.mechanical.translational:AmesimLstp00a",
|
"app.simulation.components.amesim.mechanical.translational:AmesimLstp00a",
|
||||||
"app.simulation.components.amesim.mechanical.translational:AmesimLmechn1",
|
"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:AmesimPnch023",
|
||||||
"app.simulation.components.amesim.storage.chambers:AmesimPnch012",
|
"app.simulation.components.amesim.storage.chambers:AmesimPnch012",
|
||||||
"app.simulation.components.amesim.flow.orifices:AmesimPnor001",
|
"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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from math import floor
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import (
|
||||||
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
|
ComponentDisplaySpec,
|
||||||
|
ParameterGroupDisplaySpec,
|
||||||
|
PortDisplaySpec,
|
||||||
|
)
|
||||||
|
from app.simulation.core.metadata import (
|
||||||
|
ParameterCondition,
|
||||||
|
ParameterDefinition,
|
||||||
|
ParameterOption,
|
||||||
|
ResultVariableDefinition,
|
||||||
|
)
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
from app.simulation.core.ports import PortDefinition
|
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):
|
class AmesimStep0(AlgebraicComponent):
|
||||||
"""AMESim STEP0 scalar step signal source."""
|
"""AMESim STEP0 scalar step signal source."""
|
||||||
|
|
||||||
@@ -27,7 +87,7 @@ class AmesimStep0(AlgebraicComponent):
|
|||||||
label="STEP0 阶跃信号",
|
label="STEP0 阶跃信号",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="signals",
|
category_id="signals",
|
||||||
symbol="signal",
|
symbol="amesim_step0",
|
||||||
ports=(PortDisplaySpec("out", "right", order=10),),
|
ports=(PortDisplaySpec("out", "right", order=10),),
|
||||||
order=10,
|
order=10,
|
||||||
)
|
)
|
||||||
@@ -71,6 +131,15 @@ class AmesimStep0(AlgebraicComponent):
|
|||||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
def signal_output_values(self, time: float) -> dict[str, float]:
|
||||||
return {"out": self.output_at(time)}
|
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]:
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
return {"y": self.out.signal}
|
return {"y": self.out.signal}
|
||||||
|
|
||||||
@@ -79,36 +148,48 @@ class AmesimUd00(AlgebraicComponent):
|
|||||||
"""AMESim UD00 piecewise-linear scalar signal source."""
|
"""AMESim UD00 piecewise-linear scalar signal source."""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_ud00"
|
MODEL_TYPE = "amesim_ud00"
|
||||||
MODEL_VERSION = "0.1.0"
|
MODEL_VERSION = "0.2.0"
|
||||||
PORTS = (PortDefinition.signal("out", nominal_role="output"),)
|
PORTS = (PortDefinition.signal("out", nominal_role="output"),)
|
||||||
PARAMETERS = (
|
PARAMETERS = (
|
||||||
ParameterDefinition("tstart", 0.0, label="启动时间", quantity="time", unit="s"),
|
ParameterDefinition(
|
||||||
ParameterDefinition("start1", 0.0, label="第 1 段起点", quantity="dimensionless", unit=""),
|
"tstart",
|
||||||
ParameterDefinition("end1", 1.0, label="第 1 段终点", quantity="dimensionless", unit=""),
|
0.0,
|
||||||
ParameterDefinition("t1", 1.0, label="第 1 段时长", quantity="time", unit="s", minimum=0.0),
|
label="启动时间",
|
||||||
ParameterDefinition("start2", 1.0, label="第 2 段起点", quantity="dimensionless", unit=""),
|
quantity="time",
|
||||||
ParameterDefinition("end2", 1.0, label="第 2 段终点", quantity="dimensionless", unit=""),
|
unit="s",
|
||||||
ParameterDefinition("t2", 0.0, label="第 2 段时长", quantity="time", unit="s", minimum=0.0),
|
description="分段信号开始输出第一段之前的等待时间。",
|
||||||
ParameterDefinition("start3", 1.0, label="第 3 段起点", quantity="dimensionless", unit=""),
|
),
|
||||||
ParameterDefinition("end3", 1.0, label="第 3 段终点", quantity="dimensionless", unit=""),
|
*_UD00_STAGE_PARAMETERS,
|
||||||
ParameterDefinition("t3", 0.0, label="第 3 段时长", quantity="time", unit="s", minimum=0.0),
|
ParameterDefinition(
|
||||||
ParameterDefinition("start4", 1.0, label="第 4 段起点", quantity="dimensionless", unit=""),
|
"nstages",
|
||||||
ParameterDefinition("end4", 1.0, label="第 4 段终点", quantity="dimensionless", unit=""),
|
1.0,
|
||||||
ParameterDefinition("t4", 0.0, label="第 4 段时长", quantity="time", unit="s", minimum=0.0),
|
label="段数",
|
||||||
ParameterDefinition("start5", 1.0, label="第 5 段起点", quantity="dimensionless", unit=""),
|
quantity="dimensionless",
|
||||||
ParameterDefinition("end5", 1.0, label="第 5 段终点", quantity="dimensionless", unit=""),
|
unit="",
|
||||||
ParameterDefinition("t5", 0.0, label="第 5 段时长", quantity="time", unit="s", minimum=0.0),
|
minimum=1.0,
|
||||||
ParameterDefinition("start6", 1.0, label="第 6 段起点", quantity="dimensionless", unit=""),
|
maximum=8.0,
|
||||||
ParameterDefinition("end6", 1.0, label="第 6 段终点", quantity="dimensionless", unit=""),
|
editor="choice",
|
||||||
ParameterDefinition("t6", 0.0, label="第 6 段时长", quantity="time", unit="s", minimum=0.0),
|
options=tuple(
|
||||||
ParameterDefinition("start7", 1.0, label="第 7 段起点", quantity="dimensionless", unit=""),
|
ParameterOption(float(stage_count), str(stage_count))
|
||||||
ParameterDefinition("end7", 1.0, label="第 7 段终点", quantity="dimensionless", unit=""),
|
for stage_count in range(1, 9)
|
||||||
ParameterDefinition("t7", 0.0, label="第 7 段时长", quantity="time", unit="s", minimum=0.0),
|
),
|
||||||
ParameterDefinition("start8", 1.0, label="第 8 段起点", quantity="dimensionless", unit=""),
|
description="参与输出计算的有效线性分段数量。",
|
||||||
ParameterDefinition("end8", 1.0, label="第 8 段终点", quantity="dimensionless", unit=""),
|
),
|
||||||
ParameterDefinition("t8", 0.0, label="第 8 段时长", quantity="time", unit="s", minimum=0.0),
|
ParameterDefinition(
|
||||||
ParameterDefinition("nstages", 1.0, label="段数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0),
|
"iscyclic",
|
||||||
ParameterDefinition("iscyclic", 0.0, label="循环", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
|
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 = (
|
RESULT_VARIABLES = (
|
||||||
ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10),
|
ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10),
|
||||||
@@ -117,9 +198,19 @@ class AmesimUd00(AlgebraicComponent):
|
|||||||
label="UD00 分段线性信号",
|
label="UD00 分段线性信号",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="signals",
|
category_id="signals",
|
||||||
symbol="signal",
|
symbol="amesim_ud00",
|
||||||
ports=(PortDisplaySpec("out", "right", order=10),),
|
ports=(PortDisplaySpec("out", "right", order=10),),
|
||||||
order=20,
|
order=20,
|
||||||
|
parameter_groups=(
|
||||||
|
ParameterGroupDisplaySpec(
|
||||||
|
id="stages",
|
||||||
|
label="分段参数",
|
||||||
|
parameters=tuple(
|
||||||
|
parameter.name for parameter in _UD00_STAGE_PARAMETERS
|
||||||
|
),
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -164,10 +255,17 @@ class AmesimUd00(AlgebraicComponent):
|
|||||||
) -> "AmesimUd00":
|
) -> "AmesimUd00":
|
||||||
nstages = parameters["nstages"]
|
nstages = parameters["nstages"]
|
||||||
iscyclic = parameters["iscyclic"]
|
iscyclic = parameters["iscyclic"]
|
||||||
if not float(nstages).is_integer():
|
definitions = {definition.name: definition for definition in cls.PARAMETERS}
|
||||||
raise ValueError("UD00 nstages must be an integer.")
|
for parameter_name, value in (
|
||||||
if not float(iscyclic).is_integer():
|
("nstages", nstages),
|
||||||
raise ValueError("UD00 iscyclic must be 0 or 1.")
|
("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(
|
return cls(
|
||||||
name=name,
|
name=name,
|
||||||
medium=medium,
|
medium=medium,
|
||||||
@@ -200,5 +298,58 @@ class AmesimUd00(AlgebraicComponent):
|
|||||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
def signal_output_values(self, time: float) -> dict[str, float]:
|
||||||
return {"out": self.output_at(time)}
|
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]:
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
return {"y": self.out.signal}
|
return {"y": self.out.signal}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
from math import isclose
|
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.base import ThermodynamicVolumeComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
from app.simulation.core.equations import EquationResidual
|
||||||
@@ -11,11 +16,24 @@ from app.simulation.core.metadata import (
|
|||||||
ResultVariableDefinition,
|
ResultVariableDefinition,
|
||||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
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.ports import PortDefinition
|
||||||
from app.simulation.core.state import VolumeState
|
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):
|
class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||||
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
|
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
|
||||||
|
|
||||||
@@ -32,6 +50,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
)
|
)
|
||||||
PARAMETERS = (
|
PARAMETERS = (
|
||||||
|
AMESIM_GAS_INDEX_PARAMETER,
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"cvol",
|
"cvol",
|
||||||
0.057,
|
0.057,
|
||||||
@@ -40,6 +59,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
unit="m3",
|
unit="m3",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
minimum_exclusive=True,
|
minimum_exclusive=True,
|
||||||
|
description="气室内部用于储存气体的固定有效容积。",
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"kth",
|
"kth",
|
||||||
@@ -48,6 +68,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
quantity="heat_transfer_coefficient",
|
quantity="heat_transfer_coefficient",
|
||||||
unit="W/(m2*K)",
|
unit="W/(m2*K)",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
|
description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。",
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"sth",
|
"sth",
|
||||||
@@ -56,6 +77,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
quantity="area",
|
quantity="area",
|
||||||
unit="m2",
|
unit="m2",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
|
description="气室与环境进行热交换的有效表面积。",
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"extemp",
|
"extemp",
|
||||||
@@ -65,15 +87,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
unit="K",
|
unit="K",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
minimum_exclusive=True,
|
minimum_exclusive=True,
|
||||||
),
|
description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。",
|
||||||
ParameterDefinition(
|
|
||||||
"gi",
|
|
||||||
1.0,
|
|
||||||
label="气体类型索引",
|
|
||||||
quantity="dimensionless",
|
|
||||||
unit="",
|
|
||||||
minimum=1.0,
|
|
||||||
maximum=99.0,
|
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"p0",
|
"p0",
|
||||||
@@ -83,6 +97,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
unit="Pa",
|
unit="Pa",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
minimum_exclusive=True,
|
minimum_exclusive=True,
|
||||||
|
description="仿真开始时气室内气体的绝对压力。",
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"T0",
|
"T0",
|
||||||
@@ -92,6 +107,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
unit="K",
|
unit="K",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
minimum_exclusive=True,
|
minimum_exclusive=True,
|
||||||
|
description="仿真开始时气室内气体的绝对温度。",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||||
@@ -99,7 +115,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
label="PNCH023 固定容积气室",
|
label="PNCH023 固定容积气室",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="storage",
|
category_id="storage",
|
||||||
symbol="tank",
|
symbol="amesim_pnch023",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -110,7 +126,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
*,
|
*,
|
||||||
cvol: float = 0.057,
|
cvol: float = 0.057,
|
||||||
kth: float = 0.0,
|
kth: float = 0.0,
|
||||||
@@ -137,13 +153,13 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
self.kth = float(kth)
|
self.kth = float(kth)
|
||||||
self.sth = float(sth)
|
self.sth = float(sth)
|
||||||
self.extemp = float(extemp)
|
self.extemp = float(extemp)
|
||||||
self.gi = self._integer_parameter("gi", gi)
|
self.gi = normalize_amesim_gas_index(gi)
|
||||||
self.p0 = float(p0)
|
self.p0 = float(p0)
|
||||||
self.T0 = float(T0)
|
self.T0 = float(T0)
|
||||||
m0 = self.p0 * self.cvol / (medium.R_gas * self.T0)
|
m0 = medium.density(self.p0, self.T0) * self.cvol
|
||||||
U0 = m0 * medium.specific_internal_energy(self.T0)
|
U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0)
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
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 = self.register_declared_port("port_1")
|
||||||
self.port_1.p = self.p0
|
self.port_1.p = self.p0
|
||||||
self.port_1.h_outflow = initial_h
|
self.port_1.h_outflow = initial_h
|
||||||
@@ -151,19 +167,12 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
self.port_2.p = self.p0
|
self.port_2.p = self.p0
|
||||||
self.port_2.h_outflow = initial_h
|
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
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
parameters: Mapping[str, float],
|
parameters: Mapping[str, float],
|
||||||
) -> AmesimPnch023:
|
) -> AmesimPnch023:
|
||||||
return cls(
|
return cls(
|
||||||
@@ -223,6 +232,17 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
)
|
)
|
||||||
return derivative.as_vector()
|
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, ...]:
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
pressure = self.medium.properties_from_mU(
|
pressure = self.medium.properties_from_mU(
|
||||||
self.state.m,
|
self.state.m,
|
||||||
@@ -255,10 +275,9 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
"""AMESim PNCH012 variable-volume pneumatic chamber.
|
"""AMESim PNCH012 variable-volume pneumatic chamber.
|
||||||
|
|
||||||
AMESim supplies four external volume and volume-rate inputs through the
|
AMESim supplies four external volume and volume-rate inputs through the
|
||||||
chamber ports. The current public System XML contract has pneumatic ports
|
chamber ports. Fixed/prescribed contributions remain available as SI
|
||||||
only, so this first public model exposes those external volume inputs as SI
|
parameters, while connected moving-boundary components can now add live
|
||||||
parameters. This represents fixed or prescribed-volume PNCH012 cases and is
|
volume and volume-rate values through the pneumatic connector contract.
|
||||||
not yet the full mechanical-coupled submodel.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pnch012"
|
MODEL_TYPE = "amesim_pnch012"
|
||||||
@@ -270,6 +289,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
|
||||||
)
|
)
|
||||||
PARAMETERS = (
|
PARAMETERS = (
|
||||||
|
AMESIM_GAS_INDEX_PARAMETER,
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"cvol0",
|
"cvol0",
|
||||||
0.015,
|
0.015,
|
||||||
@@ -278,6 +298,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
unit="m3",
|
unit="m3",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
minimum_exclusive=True,
|
minimum_exclusive=True,
|
||||||
|
description="变容气室在所有外部容积为零时仍保留的基础容积。",
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"kth",
|
"kth",
|
||||||
@@ -286,6 +307,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
quantity="heat_transfer_coefficient",
|
quantity="heat_transfer_coefficient",
|
||||||
unit="W/(m2*K)",
|
unit="W/(m2*K)",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
|
description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。",
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"sth",
|
"sth",
|
||||||
@@ -294,6 +316,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
quantity="area",
|
quantity="area",
|
||||||
unit="m2",
|
unit="m2",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
|
description="气室与环境进行热交换的有效表面积。",
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"extemp",
|
"extemp",
|
||||||
@@ -303,15 +326,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
unit="K",
|
unit="K",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
minimum_exclusive=True,
|
minimum_exclusive=True,
|
||||||
),
|
description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。",
|
||||||
ParameterDefinition(
|
|
||||||
"gi",
|
|
||||||
1.0,
|
|
||||||
label="气体类型索引",
|
|
||||||
quantity="dimensionless",
|
|
||||||
unit="",
|
|
||||||
minimum=1.0,
|
|
||||||
maximum=99.0,
|
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"p0",
|
"p0",
|
||||||
@@ -321,6 +336,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
unit="Pa",
|
unit="Pa",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
minimum_exclusive=True,
|
minimum_exclusive=True,
|
||||||
|
description="仿真开始时气室内气体的绝对压力。",
|
||||||
),
|
),
|
||||||
ParameterDefinition(
|
ParameterDefinition(
|
||||||
"T0",
|
"T0",
|
||||||
@@ -330,6 +346,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
unit="K",
|
unit="K",
|
||||||
minimum=0.0,
|
minimum=0.0,
|
||||||
minimum_exclusive=True,
|
minimum_exclusive=True,
|
||||||
|
description="仿真开始时气室内气体的绝对温度。",
|
||||||
),
|
),
|
||||||
ParameterDefinition("vol1", 0.0, label="端口 1 外部容积", quantity="volume", unit="m3"),
|
ParameterDefinition("vol1", 0.0, label="端口 1 外部容积", quantity="volume", unit="m3"),
|
||||||
ParameterDefinition("vol2", 0.0, label="端口 2 外部容积", quantity="volume", unit="m3"),
|
ParameterDefinition("vol2", 0.0, label="端口 2 外部容积", quantity="volume", unit="m3"),
|
||||||
@@ -348,7 +365,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
label="PNCH012 变容气室",
|
label="PNCH012 变容气室",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="storage",
|
category_id="storage",
|
||||||
symbol="tank",
|
symbol="amesim_pnch012",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -361,7 +378,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
*,
|
*,
|
||||||
cvol0: float = 0.015,
|
cvol0: float = 0.015,
|
||||||
kth: float = 0.0,
|
kth: float = 0.0,
|
||||||
@@ -404,7 +421,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
self.kth = float(kth)
|
self.kth = float(kth)
|
||||||
self.sth = float(sth)
|
self.sth = float(sth)
|
||||||
self.extemp = float(extemp)
|
self.extemp = float(extemp)
|
||||||
self.gi = AmesimPnch023._integer_parameter("gi", gi)
|
self.gi = normalize_amesim_gas_index(gi)
|
||||||
self.p0 = float(p0)
|
self.p0 = float(p0)
|
||||||
self.T0 = float(T0)
|
self.T0 = float(T0)
|
||||||
self.external_volumes = {
|
self.external_volumes = {
|
||||||
@@ -421,10 +438,10 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
}
|
}
|
||||||
if self.total_volume() <= 0.0:
|
if self.total_volume() <= 0.0:
|
||||||
raise ValueError("PNCH012 total volume must be positive.")
|
raise ValueError("PNCH012 total volume must be positive.")
|
||||||
m0 = self.p0 * self.total_volume() / (medium.R_gas * self.T0)
|
m0 = medium.density(self.p0, self.T0) * self.total_volume()
|
||||||
U0 = m0 * medium.specific_internal_energy(self.T0)
|
U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0)
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
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"):
|
for port_name in ("port_1", "port_2", "port_3", "port_4"):
|
||||||
port = self.register_declared_port(port_name)
|
port = self.register_declared_port(port_name)
|
||||||
port.p = self.p0
|
port.p = self.p0
|
||||||
@@ -436,19 +453,34 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
parameters: Mapping[str, float],
|
parameters: Mapping[str, float],
|
||||||
) -> "AmesimPnch012":
|
) -> "AmesimPnch012":
|
||||||
return cls(name=name, medium=medium, **dict(parameters))
|
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:
|
def total_volume(self) -> float:
|
||||||
minimum_volume = self.cvol0 / 100.0
|
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:
|
def total_volume_rate(self) -> float:
|
||||||
if self.total_volume() <= self.cvol0 / 100.0:
|
if self.total_volume() <= self.cvol0 / 100.0:
|
||||||
return 0.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]:
|
def get_state_vector(self) -> list[float]:
|
||||||
return self.state.as_vector()
|
return self.state.as_vector()
|
||||||
@@ -501,6 +533,143 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
energy_derivative -= props.p * self.total_volume_rate()
|
energy_derivative -= props.p * self.total_volume_rate()
|
||||||
return VolumeState(m=mass_derivative, U=energy_derivative).as_vector()
|
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, ...]:
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
pressure = self.medium.properties_from_mU(
|
pressure = self.medium.properties_from_mU(
|
||||||
self.state.m,
|
self.state.m,
|
||||||
|
|||||||
@@ -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. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
|
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."""
|
"""Temporary component library used to validate the model authoring contract."""
|
||||||
|
|
||||||
from app.simulation.components.experimental.library import LIBRARY
|
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_TYPE = "orifice"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||||
@@ -115,4 +119,3 @@ class Orifice(AlgebraicComponent):
|
|||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
self.port_a.h_outflow = connected_h["port_b"]
|
self.port_a.h_outflow = connected_h["port_b"]
|
||||||
self.port_b.h_outflow = connected_h["port_a"]
|
self.port_b.h_outflow = connected_h["port_a"]
|
||||||
|
|
||||||
@@ -16,6 +16,10 @@ class ResistivePipe(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "pipe"
|
MODEL_TYPE = "pipe"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class Tee(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "tee"
|
MODEL_TYPE = "tee"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar
|
from typing import TYPE_CHECKING, Any, ClassVar
|
||||||
|
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec
|
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
|
from app.simulation.core.ports import PortDefinition, PortState
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import GasMedium
|
||||||
|
|
||||||
|
|
||||||
class Component(ABC):
|
class Component(ABC):
|
||||||
MODEL_TYPE: ClassVar[str | None] = None
|
MODEL_TYPE: ClassVar[str | None] = None
|
||||||
MODEL_VERSION: 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, ...]] = ()
|
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
||||||
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
||||||
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
||||||
@@ -44,6 +58,31 @@ class Component(ABC):
|
|||||||
if port.definition is not None
|
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:
|
def register_port(self, port: PortState) -> PortState:
|
||||||
definition = port.definition
|
definition = port.definition
|
||||||
if definition is None:
|
if definition is None:
|
||||||
@@ -124,7 +163,7 @@ class Component(ABC):
|
|||||||
)
|
)
|
||||||
values[name] = float(component_values[name])
|
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)
|
port = self.get_port(port_definition.name)
|
||||||
for variable in port_definition.variables:
|
for variable in port_definition.variables:
|
||||||
if not variable.result_visible:
|
if not variable.result_visible:
|
||||||
@@ -151,7 +190,7 @@ class Component(ABC):
|
|||||||
for definition in self.RESULT_VARIABLES
|
for definition in self.RESULT_VARIABLES
|
||||||
if definition.visible
|
if definition.visible
|
||||||
]
|
]
|
||||||
for port_definition in self.port_definitions:
|
for port_definition in self.active_port_definitions:
|
||||||
for variable in port_definition.variables:
|
for variable in port_definition.variables:
|
||||||
if not variable.result_visible:
|
if not variable.result_visible:
|
||||||
continue
|
continue
|
||||||
@@ -185,7 +224,7 @@ class Component(ABC):
|
|||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
parameters: Mapping[str, float],
|
parameters: Mapping[str, float],
|
||||||
) -> Component:
|
) -> Component:
|
||||||
"""Create a catalog model from normalized SI parameters."""
|
"""Create a catalog model from normalized SI parameters."""
|
||||||
@@ -199,11 +238,60 @@ class Component(ABC):
|
|||||||
|
|
||||||
return ()
|
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:
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
"""Update connector outflow properties from current flow directions."""
|
"""Update connector outflow properties from current flow directions."""
|
||||||
|
|
||||||
return None
|
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):
|
class DynamicComponent(Component):
|
||||||
state_size = 2
|
state_size = 2
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Literal
|
|||||||
|
|
||||||
|
|
||||||
PortDisplaySide = Literal["left", "right"]
|
PortDisplaySide = Literal["left", "right"]
|
||||||
|
ComponentCatalogRole = Literal["amesimGasMediumDefinition"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -32,6 +33,26 @@ class PortDisplaySpec:
|
|||||||
order: int = 0
|
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)
|
@dataclass(frozen=True)
|
||||||
class ComponentDisplaySpec:
|
class ComponentDisplaySpec:
|
||||||
"""Frontend metadata co-located with a component implementation."""
|
"""Frontend metadata co-located with a component implementation."""
|
||||||
@@ -42,6 +63,8 @@ class ComponentDisplaySpec:
|
|||||||
symbol: str
|
symbol: str
|
||||||
ports: tuple[PortDisplaySpec, ...]
|
ports: tuple[PortDisplaySpec, ...]
|
||||||
order: int = 0
|
order: int = 0
|
||||||
|
role: ComponentCatalogRole | None = None
|
||||||
|
parameter_groups: tuple[ParameterGroupDisplaySpec, ...] = ()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def port_by_name(self) -> dict[str, PortDisplaySpec]:
|
def port_by_name(self) -> dict[str, PortDisplaySpec]:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ EquationOwner = Literal["connection", "component"]
|
|||||||
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
|
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class EquationResidual:
|
class EquationResidual:
|
||||||
"""One executable scalar equation in the pressure-flow subsystem."""
|
"""One executable scalar equation in the pressure-flow subsystem."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class RecoverableTrialStateError(ValueError):
|
||||||
|
"""A physical-domain failure caused by an integrator trial state."""
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -12,6 +17,110 @@ class ThermodynamicProperties:
|
|||||||
h: float
|
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)
|
@dataclass(frozen=True)
|
||||||
class IdealGasMedium:
|
class IdealGasMedium:
|
||||||
"""Temperature-dependent ideal-gas air approximation.
|
"""Temperature-dependent ideal-gas air approximation.
|
||||||
@@ -27,6 +136,9 @@ class IdealGasMedium:
|
|||||||
cp_ref: float = 1005.0
|
cp_ref: float = 1005.0
|
||||||
T_ref: float = 300.0
|
T_ref: float = 300.0
|
||||||
cp_slope: float = 0.0
|
cp_slope: float = 0.0
|
||||||
|
viscosity_ref: float = 1.82e-5
|
||||||
|
viscosity_T_ref: float = 293.15
|
||||||
|
sutherland_constant: float = 110.4
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cv(self) -> float:
|
def cv(self) -> float:
|
||||||
@@ -42,9 +154,47 @@ class IdealGasMedium:
|
|||||||
def cv_at_temperature(self, T: float) -> float:
|
def cv_at_temperature(self, T: float) -> float:
|
||||||
return self.cp_at_temperature(T) - self.R_gas
|
return self.cp_at_temperature(T) - self.R_gas
|
||||||
|
|
||||||
|
@profile_property("density")
|
||||||
def density(self, p: float, T: float) -> float:
|
def density(self, p: float, T: float) -> float:
|
||||||
return p / (self.R_gas * T)
|
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:
|
def specific_internal_energy(self, T: float) -> float:
|
||||||
delta_T = T - self.T_ref
|
delta_T = T - self.T_ref
|
||||||
return (
|
return (
|
||||||
@@ -53,6 +203,12 @@ class IdealGasMedium:
|
|||||||
+ 0.5 * self.cp_slope * delta_T * delta_T
|
+ 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:
|
def specific_enthalpy(self, T: float) -> float:
|
||||||
delta_T = T - self.T_ref
|
delta_T = T - self.T_ref
|
||||||
return (
|
return (
|
||||||
@@ -61,6 +217,11 @@ class IdealGasMedium:
|
|||||||
+ 0.5 * self.cp_slope * delta_T * delta_T
|
+ 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:
|
def temperature_from_internal_energy(self, u: float) -> float:
|
||||||
reference_internal_energy = self.cv * self.T_ref
|
reference_internal_energy = self.cv * self.T_ref
|
||||||
delta_u = u - reference_internal_energy
|
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
|
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
|
||||||
return self.T_ref + delta_T
|
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:
|
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
|
||||||
if m <= 0.0:
|
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)
|
return self.temperature_from_internal_energy(U / m)
|
||||||
|
|
||||||
def pressure(self, m: float, T: float, V: float) -> float:
|
def pressure(self, m: float, T: float, V: float) -> float:
|
||||||
@@ -87,6 +271,7 @@ class IdealGasMedium:
|
|||||||
raise ValueError("Volume must stay positive.")
|
raise ValueError("Volume must stay positive.")
|
||||||
return m * self.R_gas * T / V
|
return m * self.R_gas * T / V
|
||||||
|
|
||||||
|
@profile_property("properties_from_mU")
|
||||||
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
|
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
|
||||||
T = self.temperature_from_mass_internal_energy(m, U)
|
T = self.temperature_from_mass_internal_energy(m, U)
|
||||||
p = self.pressure(m, T, V)
|
p = self.pressure(m, T, V)
|
||||||
@@ -94,3 +279,100 @@ class IdealGasMedium:
|
|||||||
u = U / m
|
u = U / m
|
||||||
h = self.specific_enthalpy(T)
|
h = self.specific_enthalpy(T)
|
||||||
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
|
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",
|
||||||
|
)
|
||||||
@@ -6,6 +6,11 @@ from typing import Literal
|
|||||||
|
|
||||||
|
|
||||||
ResultVariableScope = Literal["component", "port"]
|
ResultVariableScope = Literal["component", "port"]
|
||||||
|
ParameterEditor = Literal[
|
||||||
|
"amesimGasReference",
|
||||||
|
"amesimGasPropertyModel",
|
||||||
|
"choice",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
SI_UNIT_BY_QUANTITY: dict[str, str] = {
|
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)
|
@dataclass(frozen=True)
|
||||||
class ParameterDefinition:
|
class ParameterDefinition:
|
||||||
"""User-configurable model input expressed in the backend SI contract."""
|
"""User-configurable model input expressed in the backend SI contract.
|
||||||
|
|
||||||
|
Every ``visible_when`` condition must match for the catalog parameter to
|
||||||
|
be visible; each individual condition matches any one of its ``values``.
|
||||||
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
default: float
|
default: float
|
||||||
@@ -46,6 +83,10 @@ class ParameterDefinition:
|
|||||||
minimum: float | None = None
|
minimum: float | None = None
|
||||||
maximum: float | None = None
|
maximum: float | None = None
|
||||||
minimum_exclusive: bool = False
|
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:
|
def validation_message(self, value: float) -> str | None:
|
||||||
if not isfinite(value):
|
if not isfinite(value):
|
||||||
@@ -57,6 +98,11 @@ class ParameterDefinition:
|
|||||||
return f"must be at least {self.minimum:g}"
|
return f"must be at least {self.minimum:g}"
|
||||||
if self.maximum is not None and value > self.maximum:
|
if self.maximum is not None and value > self.maximum:
|
||||||
return f"must be at most {self.maximum:g}"
|
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
|
return None
|
||||||
|
|
||||||
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
|
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
|
||||||
@@ -72,6 +118,18 @@ class ParameterDefinition:
|
|||||||
payload["minimum"] = self.minimum
|
payload["minimum"] = self.minimum
|
||||||
if self.maximum is not None:
|
if self.maximum is not None:
|
||||||
payload["maximum"] = self.maximum
|
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:
|
if value is not None:
|
||||||
payload["value"] = value
|
payload["value"] = value
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.simulation.core.errors import RecoverableTrialStateError
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import acos, cos, isfinite, log, pi, sqrt
|
from math import acos, cos, isfinite, log, pi, sqrt
|
||||||
|
|
||||||
|
from app.simulation.performance import profile_property
|
||||||
|
|
||||||
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -27,7 +35,7 @@ class PengRobinsonFluid:
|
|||||||
@property
|
@property
|
||||||
def a_parameter(self) -> float:
|
def a_parameter(self) -> float:
|
||||||
return (
|
return (
|
||||||
0.45724
|
PENG_ROBINSON_A_COEFFICIENT
|
||||||
* UNIVERSAL_GAS_CONSTANT
|
* UNIVERSAL_GAS_CONSTANT
|
||||||
* UNIVERSAL_GAS_CONSTANT
|
* UNIVERSAL_GAS_CONSTANT
|
||||||
* self.critical_temperature
|
* self.critical_temperature
|
||||||
@@ -37,7 +45,12 @@ class PengRobinsonFluid:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def b_parameter(self) -> float:
|
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
|
@property
|
||||||
def kappa(self) -> float:
|
def kappa(self) -> float:
|
||||||
@@ -60,27 +73,112 @@ class PengRobinsonFluid:
|
|||||||
/ (self.critical_temperature * sqrt_reduced_temperature)
|
/ (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:
|
def attractive_parameter(self, temperature: float) -> float:
|
||||||
return self.a_parameter * self.alpha(temperature)
|
return self.a_parameter * self.alpha(temperature)
|
||||||
|
|
||||||
def attractive_parameter_temperature_derivative(self, temperature: float) -> float:
|
def attractive_parameter_temperature_derivative(self, temperature: float) -> float:
|
||||||
return self.a_parameter * self.alpha_temperature_derivative(temperature)
|
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:
|
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
|
||||||
self._validate_temperature(temperature)
|
self._validate_temperature(temperature)
|
||||||
if molar_volume <= self.b_parameter:
|
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)
|
a_alpha = self.attractive_parameter(temperature)
|
||||||
b = self.b_parameter
|
b = self.b_parameter
|
||||||
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
|
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
|
||||||
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
|
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
|
||||||
return repulsive - attractive
|
return repulsive - attractive
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"pressure_from_density",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def pressure_from_density(self, temperature: float, density: float) -> float:
|
def pressure_from_density(self, temperature: float, density: float) -> float:
|
||||||
if density <= 0.0:
|
if density <= 0.0:
|
||||||
raise ValueError("Density must be positive.")
|
raise ValueError("Density must be positive.")
|
||||||
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
|
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]:
|
def reduced_parameters(self, pressure: float, temperature: float) -> tuple[float, float]:
|
||||||
self._validate_pressure_temperature(pressure, temperature)
|
self._validate_pressure_temperature(pressure, temperature)
|
||||||
a_alpha = self.attractive_parameter(temperature)
|
a_alpha = self.attractive_parameter(temperature)
|
||||||
@@ -89,6 +187,11 @@ class PengRobinsonFluid:
|
|||||||
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
|
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
|
||||||
return A, B
|
return A, B
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"compressibility_roots",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
|
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
|
||||||
A, B = self.reduced_parameters(pressure, temperature)
|
A, B = self.reduced_parameters(pressure, temperature)
|
||||||
coefficients = (
|
coefficients = (
|
||||||
@@ -102,6 +205,11 @@ class PengRobinsonFluid:
|
|||||||
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
|
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
|
||||||
return physical_roots
|
return physical_roots
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"compressibility_factor",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def compressibility_factor(
|
def compressibility_factor(
|
||||||
self,
|
self,
|
||||||
pressure: float,
|
pressure: float,
|
||||||
@@ -117,6 +225,11 @@ class PengRobinsonFluid:
|
|||||||
return roots[-1]
|
return roots[-1]
|
||||||
raise ValueError(f"Unsupported phase selector: {phase!r}")
|
raise ValueError(f"Unsupported phase selector: {phase!r}")
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"molar_volume",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def molar_volume(
|
def molar_volume(
|
||||||
self,
|
self,
|
||||||
pressure: float,
|
pressure: float,
|
||||||
@@ -126,6 +239,7 @@ class PengRobinsonFluid:
|
|||||||
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
||||||
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
|
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
|
||||||
|
|
||||||
|
@profile_property("density", layer="kernel", minimum_mode="audit")
|
||||||
def density(
|
def density(
|
||||||
self,
|
self,
|
||||||
pressure: float,
|
pressure: float,
|
||||||
@@ -134,6 +248,11 @@ class PengRobinsonFluid:
|
|||||||
) -> float:
|
) -> float:
|
||||||
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
|
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(
|
def residual_specific_enthalpy(
|
||||||
self,
|
self,
|
||||||
pressure: float,
|
pressure: float,
|
||||||
@@ -163,15 +282,82 @@ class PengRobinsonFluid:
|
|||||||
)
|
)
|
||||||
return residual_molar_enthalpy / self.molar_mass
|
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
|
@staticmethod
|
||||||
def _validate_temperature(temperature: float) -> None:
|
def _validate_temperature(temperature: float) -> None:
|
||||||
if temperature <= 0.0:
|
if temperature <= 0.0:
|
||||||
raise ValueError("Temperature must be positive.")
|
raise RecoverableTrialStateError("Temperature must be positive.")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
|
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
|
||||||
if pressure <= 0.0:
|
if pressure <= 0.0:
|
||||||
raise ValueError("Pressure must be positive.")
|
raise RecoverableTrialStateError("Pressure must be positive.")
|
||||||
cls._validate_temperature(temperature)
|
cls._validate_temperature(temperature)
|
||||||
|
|
||||||
HELIUM_PR = PengRobinsonFluid(
|
HELIUM_PR = PengRobinsonFluid(
|
||||||
@@ -179,7 +365,8 @@ HELIUM_PR = PengRobinsonFluid(
|
|||||||
molar_mass=0.004002602,
|
molar_mass=0.004002602,
|
||||||
critical_temperature=5.1953,
|
critical_temperature=5.1953,
|
||||||
critical_pressure=227_460.0,
|
critical_pressure=227_460.0,
|
||||||
acentric_factor=-0.385,
|
# Simcenter Amesim 2404 helium_eos.data.
|
||||||
|
acentric_factor=-0.382,
|
||||||
)
|
)
|
||||||
|
|
||||||
NITROGEN_PR = PengRobinsonFluid(
|
NITROGEN_PR = PengRobinsonFluid(
|
||||||
@@ -87,6 +87,26 @@ class PortDefinition:
|
|||||||
unit="J/kg",
|
unit="J/kg",
|
||||||
order=30,
|
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
|
p: float = 0.0
|
||||||
m_flow: float = 0.0
|
m_flow: float = 0.0
|
||||||
h_outflow: float = 0.0
|
h_outflow: float = 0.0
|
||||||
|
volume: float = 0.0
|
||||||
|
volume_flow: float = 0.0
|
||||||
signal: float = 0.0
|
signal: float = 0.0
|
||||||
x: float = 0.0
|
x: float = 0.0
|
||||||
v: float = 0.0
|
v: float = 0.0
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.simulation.examples.test_mql.system import (
|
||||||
|
TestMqlRunConfig,
|
||||||
|
TestMqlSimulationResult,
|
||||||
|
TestMqlSystem,
|
||||||
|
)
|
||||||
from app.simulation.examples.test_mql.run import (
|
from app.simulation.examples.test_mql.run import (
|
||||||
PreparedTestMqlRun,
|
PreparedTestMqlRun,
|
||||||
TestMqlRunResult,
|
TestMqlRunResult,
|
||||||
@@ -10,7 +15,10 @@ from app.simulation.examples.test_mql.run import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PreparedTestMqlRun",
|
"PreparedTestMqlRun",
|
||||||
|
"TestMqlRunConfig",
|
||||||
"TestMqlRunResult",
|
"TestMqlRunResult",
|
||||||
|
"TestMqlSimulationResult",
|
||||||
|
"TestMqlSystem",
|
||||||
"prepare_test_mql_run",
|
"prepare_test_mql_run",
|
||||||
"run_prepared_test_mql",
|
"run_prepared_test_mql",
|
||||||
"run_test_mql",
|
"run_test_mql",
|
||||||
|
|||||||
+5
-5
@@ -3,17 +3,17 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||||
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
|
from app.simulation.reporting.test_mql_comparison import TestMqlComparisonResult
|
||||||
from PythonModels.reporting.test_mql_observations import (
|
from app.simulation.reporting.test_mql_observations import (
|
||||||
TestMqlObservationCatalog,
|
TestMqlObservationCatalog,
|
||||||
build_test_mql_observation_catalog,
|
build_test_mql_observation_catalog,
|
||||||
)
|
)
|
||||||
from PythonModels.reporting.test_mql_output_schema import (
|
from app.simulation.reporting.test_mql_output_schema import (
|
||||||
TestMqlOutputSchema,
|
TestMqlOutputSchema,
|
||||||
build_test_mql_output_schema,
|
build_test_mql_output_schema,
|
||||||
)
|
)
|
||||||
from PythonModels.reporting.test_mql_output_validation import (
|
from app.simulation.reporting.test_mql_output_validation import (
|
||||||
TestMqlValidatedOutput,
|
TestMqlValidatedOutput,
|
||||||
compare_validated_test_mql_output,
|
compare_validated_test_mql_output,
|
||||||
validate_test_mql_output,
|
validate_test_mql_output,
|
||||||
+6
-6
@@ -3,21 +3,21 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from PythonModels.components.amesim_pneumatic import (
|
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||||
AmesimPneumaticGas,
|
AmesimPneumaticGas,
|
||||||
AmesimPneumaticOrifice,
|
AmesimPneumaticOrifice,
|
||||||
AmesimPneumaticVolume,
|
AmesimPneumaticVolume,
|
||||||
)
|
)
|
||||||
from PythonModels.components.amesim_pneumatic_line import (
|
from app.simulation.examples.test_mql.primitives.pneumatic_lines import (
|
||||||
AmesimPnl0001Pipe,
|
AmesimPnl0001Pipe,
|
||||||
AmesimPnl0002Pipe,
|
AmesimPnl0002Pipe,
|
||||||
AmesimPnl0003Pipe,
|
AmesimPnl0003Pipe,
|
||||||
AmesimPnl00rPipe,
|
AmesimPnl00rPipe,
|
||||||
)
|
)
|
||||||
from PythonModels.core.medium import ThermodynamicProperties
|
from app.simulation.core.medium import ThermodynamicProperties
|
||||||
from PythonModels.core.ports import PortState
|
from app.simulation.core.ports import PortState
|
||||||
from PythonModels.core.state import VolumeState
|
from app.simulation.core.state import VolumeState
|
||||||
from PythonModels.systems.test_mql_nodes import (
|
from app.simulation.examples.test_mql.nodes import (
|
||||||
TestMqlPneumaticNode3,
|
TestMqlPneumaticNode3,
|
||||||
TestMqlPneumaticNode3Balance,
|
TestMqlPneumaticNode3Balance,
|
||||||
TestMqlPneumaticNode4,
|
TestMqlPneumaticNode4,
|
||||||
+9
-9
@@ -3,28 +3,28 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PythonModels.components.amesim_pneumatic import m3_to_cm3
|
from app.simulation.examples.test_mql.primitives.pneumatic import m3_to_cm3
|
||||||
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||||
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
|
from app.simulation.reporting.test_mql_comparison import TestMqlComparisonResult
|
||||||
from PythonModels.reporting.test_mql_observations import (
|
from app.simulation.reporting.test_mql_observations import (
|
||||||
TestMqlObservationCatalog,
|
TestMqlObservationCatalog,
|
||||||
build_test_mql_observation_catalog,
|
build_test_mql_observation_catalog,
|
||||||
)
|
)
|
||||||
from PythonModels.reporting.test_mql_output_schema import (
|
from app.simulation.reporting.test_mql_output_schema import (
|
||||||
TestMqlOutputSchema,
|
TestMqlOutputSchema,
|
||||||
build_test_mql_output_schema,
|
build_test_mql_output_schema,
|
||||||
)
|
)
|
||||||
from PythonModels.reporting.test_mql_output_validation import (
|
from app.simulation.reporting.test_mql_output_validation import (
|
||||||
TestMqlValidatedOutput,
|
TestMqlValidatedOutput,
|
||||||
compare_validated_test_mql_output,
|
compare_validated_test_mql_output,
|
||||||
validate_test_mql_output,
|
validate_test_mql_output,
|
||||||
)
|
)
|
||||||
from PythonModels.reporting.test_mql_variables import build_test_mql_variable_catalog
|
from app.simulation.reporting.test_mql_variables import build_test_mql_variable_catalog
|
||||||
from PythonModels.systems.test_mql_mechanical import (
|
from app.simulation.examples.test_mql.mechanical import (
|
||||||
TestMqlMechanicalAssembly,
|
TestMqlMechanicalAssembly,
|
||||||
build_test_mql_mechanical_assembly,
|
build_test_mql_mechanical_assembly,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_pneumatic import (
|
from app.simulation.examples.test_mql.pneumatic import (
|
||||||
TestMqlPneumaticAssembly,
|
TestMqlPneumaticAssembly,
|
||||||
build_test_mql_pneumatic_assembly,
|
build_test_mql_pneumatic_assembly,
|
||||||
)
|
)
|
||||||
+2
-2
@@ -6,8 +6,8 @@ from dataclasses import dataclass
|
|||||||
from math import isfinite
|
from math import isfinite
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||||
from PythonModels.systems.test_mql import COMPONENT_SPECS, GLOBAL_PARAMETERS
|
from app.simulation.examples.test_mql.system import COMPONENT_SPECS, GLOBAL_PARAMETERS
|
||||||
|
|
||||||
|
|
||||||
_BINARY_OPERATORS = {
|
_BINARY_OPERATORS = {
|
||||||
+2
-2
@@ -5,8 +5,8 @@ import tarfile
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PythonModels.systems.test_mql import CONNECTION_SPECS, GLOBAL_PARAMETERS
|
from app.simulation.examples.test_mql.system import CONNECTION_SPECS, GLOBAL_PARAMETERS
|
||||||
from PythonModels.systems.test_mql_config import resolve_numeric_expression
|
from app.simulation.examples.test_mql.config import resolve_numeric_expression
|
||||||
|
|
||||||
|
|
||||||
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
|
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
|
||||||
@@ -4,12 +4,12 @@ import re
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from PythonModels.reporting.amesim_results import AmesimResults
|
from app.simulation.reporting.amesim_results import AmesimResults
|
||||||
from PythonModels.reporting.test_mql_variables import (
|
from app.simulation.reporting.test_mql_variables import (
|
||||||
TestMqlVariableCatalog,
|
TestMqlVariableCatalog,
|
||||||
build_test_mql_variable_catalog,
|
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")
|
TEST_MQL_PNEUMATIC_LINE_SUBMODELS = ("PNL0001", "PNL0002", "PNL0003", "PNL00R")
|
||||||
+5
-5
@@ -2,19 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from PythonModels.components.amesim_mechanical import (
|
from app.simulation.examples.test_mql.primitives.mechanical import (
|
||||||
AmesimElasticEndstop,
|
AmesimElasticEndstop,
|
||||||
AmesimMassFrictionEndstops,
|
AmesimMassFrictionEndstops,
|
||||||
AmesimPistonGeometry,
|
AmesimPistonGeometry,
|
||||||
circular_area,
|
circular_area,
|
||||||
mm_to_m,
|
mm_to_m,
|
||||||
)
|
)
|
||||||
from PythonModels.reporting.amesim_results import AmesimResults
|
from app.simulation.reporting.amesim_results import AmesimResults
|
||||||
from PythonModels.reporting.test_mql_variables import (
|
from app.simulation.reporting.test_mql_variables import (
|
||||||
TestMqlVariableCatalog,
|
TestMqlVariableCatalog,
|
||||||
build_test_mql_variable_catalog,
|
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
|
MM_TO_M = 1.0e-3
|
||||||
@@ -433,7 +433,7 @@ def _build_mass(
|
|||||||
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
|
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
|
||||||
rest_coeff=component.parameter_value("restcoeff"),
|
rest_coeff=component.parameter_value("restcoeff"),
|
||||||
stribeck_constant_m_s=component.parameter_value("astrib"),
|
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")),
|
stop_type=int(component.parameter_value("stoptype")),
|
||||||
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
|
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
|
||||||
initial_displacement_m=_initial_value(amesim_results, f"x1@{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 dataclasses import dataclass
|
||||||
|
|
||||||
from PythonModels.systems.test_mql import COMPONENT_SPECS
|
from app.simulation.examples.test_mql.system import COMPONENT_SPECS
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
+3
-3
@@ -2,14 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from PythonModels.components.amesim_pneumatic import (
|
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||||
HELIUM_PNEUMATIC_GAS,
|
HELIUM_PNEUMATIC_GAS,
|
||||||
AmesimPneumaticGas,
|
AmesimPneumaticGas,
|
||||||
AmesimPneumaticOrifice,
|
AmesimPneumaticOrifice,
|
||||||
AmesimPneumaticVolume,
|
AmesimPneumaticVolume,
|
||||||
AmesimVariablePneumaticVolume,
|
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
|
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
|
||||||
@@ -162,7 +162,7 @@ def build_test_mql_pneumatic_assembly(
|
|||||||
def _build_variable_orifice_controls(
|
def _build_variable_orifice_controls(
|
||||||
config: TestMqlConfig,
|
config: TestMqlConfig,
|
||||||
) -> dict[str, TestMqlVariableOrificeControl]:
|
) -> 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}
|
components_by_alias = {component.alias: component for component in config.components}
|
||||||
variable_orifice_aliases = {
|
variable_orifice_aliases = {
|
||||||
+3
-3
@@ -3,17 +3,17 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PythonModels.components.amesim_pneumatic import (
|
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||||
HELIUM_PNEUMATIC_GAS,
|
HELIUM_PNEUMATIC_GAS,
|
||||||
AmesimPneumaticGas,
|
AmesimPneumaticGas,
|
||||||
)
|
)
|
||||||
from PythonModels.components.amesim_pneumatic_line import (
|
from app.simulation.examples.test_mql.primitives.pneumatic_lines import (
|
||||||
AmesimPnl0001Pipe,
|
AmesimPnl0001Pipe,
|
||||||
AmesimPnl0002Pipe,
|
AmesimPnl0002Pipe,
|
||||||
AmesimPnl0003Pipe,
|
AmesimPnl0003Pipe,
|
||||||
AmesimPnl00rPipe,
|
AmesimPnl00rPipe,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_line_parameters import (
|
from app.simulation.examples.test_mql.line_parameters import (
|
||||||
TestMqlPnl0001Spec,
|
TestMqlPnl0001Spec,
|
||||||
TestMqlPnl0002Spec,
|
TestMqlPnl0002Spec,
|
||||||
TestMqlPnl0003Spec,
|
TestMqlPnl0003Spec,
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PythonModels.systems.test_mql_closure import TestMqlPneumaticChamberSegmentSpec
|
from app.simulation.examples.test_mql.closure import TestMqlPneumaticChamberSegmentSpec
|
||||||
from PythonModels.systems.test_mql_topology import TestMqlCirTopology
|
from app.simulation.examples.test_mql.topology import TestMqlCirTopology
|
||||||
|
|
||||||
|
|
||||||
def discover_fixed_chamber_segments(
|
def discover_fixed_chamber_segments(
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Calibrated component primitives used only by the ``test_mql`` example."""
|
||||||
File renamed without changes.
+5
-5
@@ -3,11 +3,11 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import pi, sqrt
|
from math import pi, sqrt
|
||||||
|
|
||||||
from PythonModels.core.base import AlgebraicComponent, DynamicComponent
|
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
||||||
from PythonModels.core.medium import ThermodynamicProperties
|
from app.simulation.core.medium import ThermodynamicProperties
|
||||||
from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||||
from PythonModels.core.ports import PortState
|
from app.simulation.core.ports import PortState
|
||||||
from PythonModels.core.state import VolumeState
|
from app.simulation.core.state import VolumeState
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
+6
-6
@@ -3,16 +3,16 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import log10, pi, sqrt
|
from math import log10, pi, sqrt
|
||||||
|
|
||||||
from PythonModels.components.amesim_pneumatic import (
|
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||||
HELIUM_PNEUMATIC_GAS,
|
HELIUM_PNEUMATIC_GAS,
|
||||||
AmesimPneumaticGas,
|
AmesimPneumaticGas,
|
||||||
compressible_orifice_mass_flow,
|
compressible_orifice_mass_flow,
|
||||||
diameter_mm_to_area_m2,
|
diameter_mm_to_area_m2,
|
||||||
)
|
)
|
||||||
from PythonModels.core.base import AlgebraicComponent, DynamicComponent
|
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
||||||
from PythonModels.core.medium import ThermodynamicProperties
|
from app.simulation.core.medium import ThermodynamicProperties
|
||||||
from PythonModels.core.ports import PortState
|
from app.simulation.core.ports import PortState
|
||||||
from PythonModels.core.state import VolumeState
|
from app.simulation.core.state import VolumeState
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -165,7 +165,7 @@ class AmesimPnl0001Pipe(_DarcyPipeResistanceMixin, DynamicComponent):
|
|||||||
"""Physical first-pass implementation of AMESim ``PNL0001`` (C-R).
|
"""Physical first-pass implementation of AMESim ``PNL0001`` (C-R).
|
||||||
|
|
||||||
Port 2 owns the lumped gas storage. Port 1 is connected through a Darcy
|
Port 2 owns the lumped gas storage. Port 1 is connected through a Darcy
|
||||||
resistance. Both connection mass flows use the PythonModels convention:
|
resistance. Both connection mass flows use the simulation convention:
|
||||||
positive values enter the pipe storage.
|
positive values enter the pipe storage.
|
||||||
|
|
||||||
AMESim's proprietary ``pn2pipefr`` utility is represented by an
|
AMESim's proprietary ``pn2pipefr`` utility is represented by an
|
||||||
@@ -5,8 +5,7 @@ from datetime import UTC, datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR
|
from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR
|
||||||
from PythonModels.scripts.run_test_mql import format_test_mql_summary
|
from app.simulation.examples.test_mql.system import (
|
||||||
from PythonModels.systems.test_mql import (
|
|
||||||
TestMqlRunConfig,
|
TestMqlRunConfig,
|
||||||
TestMqlSimulationResult,
|
TestMqlSimulationResult,
|
||||||
TestMqlSystem,
|
TestMqlSystem,
|
||||||
@@ -29,6 +28,25 @@ class TestMqlRunResult:
|
|||||||
summary_path: Path
|
summary_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
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 _default_run_output_dir() -> Path:
|
def _default_run_output_dir() -> Path:
|
||||||
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
|
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
|
||||||
return SIMULATION_RUNS_DIR / timestamp
|
return SIMULATION_RUNS_DIR / timestamp
|
||||||
|
|||||||
+7
-4
@@ -4,16 +4,20 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PythonModels.systems.test_mql_baseline import (
|
from app.simulation.examples.test_mql.baseline import (
|
||||||
TestMqlBaselineRun,
|
TestMqlBaselineRun,
|
||||||
run_test_mql_baseline_passthrough,
|
run_test_mql_baseline_passthrough,
|
||||||
)
|
)
|
||||||
|
from app.simulation.paths import (
|
||||||
|
AMESIM_TEST_MQL_ARCHIVE_PATH,
|
||||||
|
SIMULATION_RUNS_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TestMqlBaselinePathConfig:
|
class TestMqlBaselinePathConfig:
|
||||||
archive_path: Path = field(
|
archive_path: Path = field(
|
||||||
default_factory=lambda: Path(__file__).resolve().parents[2] / "AmesimModels" / "test_mql.ame"
|
default_factory=lambda: AMESIM_TEST_MQL_ARCHIVE_PATH
|
||||||
)
|
)
|
||||||
output_dir: Path | None = None
|
output_dir: Path | None = None
|
||||||
|
|
||||||
@@ -31,9 +35,8 @@ class TestMqlBaselineScriptConfig:
|
|||||||
|
|
||||||
|
|
||||||
def _default_output_dir() -> Path:
|
def _default_output_dir() -> Path:
|
||||||
pythonmodels_root = Path(__file__).resolve().parents[1]
|
|
||||||
timestamp = datetime.now(UTC).strftime("test_mql_baseline_%Y%m%d_%H%M%S_%f")
|
timestamp = datetime.now(UTC).strftime("test_mql_baseline_%Y%m%d_%H%M%S_%f")
|
||||||
return pythonmodels_root / "runs" / timestamp
|
return SIMULATION_RUNS_DIR / timestamp
|
||||||
|
|
||||||
|
|
||||||
def format_test_mql_baseline_summary(run: TestMqlBaselineRun) -> str:
|
def format_test_mql_baseline_summary(run: TestMqlBaselineRun) -> str:
|
||||||
+14
-13
@@ -7,30 +7,34 @@ from datetime import UTC, datetime
|
|||||||
from math import nextafter, sqrt
|
from math import nextafter, sqrt
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PythonModels.components.amesim_pneumatic import AmesimPneumaticGas
|
from app.simulation.examples.test_mql.primitives.pneumatic import AmesimPneumaticGas
|
||||||
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
|
from app.simulation.examples.test_mql.solver import SolveIVPConfig, integrate_ode
|
||||||
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||||
from PythonModels.reporting.test_mql_comparison import (
|
from app.simulation.reporting.test_mql_comparison import (
|
||||||
TestMqlComparisonResult,
|
TestMqlComparisonResult,
|
||||||
interpolate_series_value,
|
interpolate_series_value,
|
||||||
write_test_mql_comparison_csv,
|
write_test_mql_comparison_csv,
|
||||||
)
|
)
|
||||||
from PythonModels.reporting.test_mql_output_schema import (
|
from app.simulation.reporting.test_mql_output_schema import (
|
||||||
TestMqlOutputSchema,
|
TestMqlOutputSchema,
|
||||||
build_test_mql_output_schema,
|
build_test_mql_output_schema,
|
||||||
)
|
)
|
||||||
from PythonModels.reporting.test_mql_output_validation import (
|
from app.simulation.reporting.test_mql_output_validation import (
|
||||||
TestMqlValidatedOutput,
|
TestMqlValidatedOutput,
|
||||||
compare_validated_test_mql_output,
|
compare_validated_test_mql_output,
|
||||||
validate_test_mql_output,
|
validate_test_mql_output,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql import (
|
from app.simulation.examples.test_mql.system import (
|
||||||
TestMqlPnl0001LineRhsDiagnostic,
|
TestMqlPnl0001LineRhsDiagnostic,
|
||||||
TestMqlSimulationResult,
|
TestMqlSimulationResult,
|
||||||
TestMqlSystem,
|
TestMqlSystem,
|
||||||
TestMqlVariableChamberRhsDiagnostic,
|
TestMqlVariableChamberRhsDiagnostic,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_pneumatic import AMESIM_REFERENCE_PRESSURE_PA
|
from app.simulation.examples.test_mql.pneumatic import AMESIM_REFERENCE_PRESSURE_PA
|
||||||
|
from app.simulation.paths import (
|
||||||
|
AMESIM_TEST_MQL_ARCHIVE_PATH,
|
||||||
|
SIMULATION_RUNS_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_FULL_STATE_COMPARISON_DATA_PATHS = (
|
DEFAULT_FULL_STATE_COMPARISON_DATA_PATHS = (
|
||||||
@@ -758,9 +762,7 @@ class TestMqlPnvoEventWindowCandidateComparisonDiagnostic:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TestMqlFullStateComparisonPathConfig:
|
class TestMqlFullStateComparisonPathConfig:
|
||||||
archive_path: Path = field(
|
archive_path: Path = field(
|
||||||
default_factory=lambda: Path(__file__).resolve().parents[2]
|
default_factory=lambda: AMESIM_TEST_MQL_ARCHIVE_PATH
|
||||||
/ "AmesimModels"
|
|
||||||
/ "test_mql.ame"
|
|
||||||
)
|
)
|
||||||
amesim_results_archive_path: Path | None = None
|
amesim_results_archive_path: Path | None = None
|
||||||
output_dir: Path | None = None
|
output_dir: Path | None = None
|
||||||
@@ -798,9 +800,8 @@ class TestMqlFullStateComparisonScriptConfig:
|
|||||||
|
|
||||||
|
|
||||||
def _default_output_dir() -> Path:
|
def _default_output_dir() -> Path:
|
||||||
pythonmodels_root = Path(__file__).resolve().parents[1]
|
|
||||||
timestamp = datetime.now(UTC).strftime("test_mql_full_state_%Y%m%d_%H%M%S_%f")
|
timestamp = datetime.now(UTC).strftime("test_mql_full_state_%Y%m%d_%H%M%S_%f")
|
||||||
return pythonmodels_root / "runs" / timestamp
|
return SIMULATION_RUNS_DIR / timestamp
|
||||||
|
|
||||||
|
|
||||||
def run_test_mql_full_state_comparison(
|
def run_test_mql_full_state_comparison(
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.simulation.solvers.solver import (
|
||||||
|
SolveIVPConfig as _BaseSolveIVPConfig,
|
||||||
|
integrate_ode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SolveIVPConfig(_BaseSolveIVPConfig):
|
||||||
|
"""Solver defaults used by the calibrated ``test_mql`` example."""
|
||||||
|
|
||||||
|
atol: float = 1e-10
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SolveIVPConfig", "integrate_ode"]
|
||||||
+1
-1
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from PythonModels.core.base import Component, DynamicComponent
|
from app.simulation.core.base import Component, DynamicComponent
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -4,13 +4,13 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from PythonModels.core.base import Component
|
from app.simulation.core.base import Component
|
||||||
from PythonModels.core.network import SimulationNetwork
|
from app.simulation.examples.test_mql.solver import SolveIVPConfig, integrate_ode
|
||||||
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
|
from app.simulation.examples.test_mql.structural_network import SimulationNetwork
|
||||||
|
from app.simulation.paths import AMESIM_TEST_MQL_ARCHIVE_PATH
|
||||||
|
|
||||||
|
|
||||||
MODEL_NAME = "test_mql"
|
MODEL_NAME = "test_mql"
|
||||||
AMESIM_ARCHIVE_RELATIVE_PATH = Path("AmesimModels/test_mql.ame")
|
|
||||||
|
|
||||||
# Extracted from AmesimModels/test_mql.ame on the model-development branch.
|
# Extracted from AmesimModels/test_mql.ame on the model-development branch.
|
||||||
# This is a structural Python port scaffold: it preserves the AMESim component
|
# This is a structural Python port scaffold: it preserves the AMESim component
|
||||||
@@ -4237,7 +4237,7 @@ class TestMqlFullStateClosure:
|
|||||||
state_vector: list[float],
|
state_vector: list[float],
|
||||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
from PythonModels.systems.test_mql_pneumatic import pressure_to_amesim_gauge_pa
|
from app.simulation.examples.test_mql.pneumatic import pressure_to_amesim_gauge_pa
|
||||||
|
|
||||||
selected_paths = tuple(data_paths) if data_paths is not None else _default_full_state_data_paths()
|
selected_paths = tuple(data_paths) if data_paths is not None else _default_full_state_data_paths()
|
||||||
snapshot = self.snapshot_at(time_s, state_vector)
|
snapshot = self.snapshot_at(time_s, state_vector)
|
||||||
@@ -4456,7 +4456,7 @@ class TestMqlFullStateClosure:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _piston_force_by_mass_alias(self, pneumatic_snapshot: object) -> dict[str, float]:
|
def _piston_force_by_mass_alias(self, pneumatic_snapshot: object) -> dict[str, float]:
|
||||||
from PythonModels.systems.test_mql_pneumatic import pressure_to_amesim_gauge_pa
|
from app.simulation.examples.test_mql.pneumatic import pressure_to_amesim_gauge_pa
|
||||||
|
|
||||||
force_by_mass_alias: dict[str, float] = {}
|
force_by_mass_alias: dict[str, float] = {}
|
||||||
for mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS:
|
for mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS:
|
||||||
@@ -4510,7 +4510,7 @@ class TestMqlSystem:
|
|||||||
"""Structural Python port scaffold for AMESim model ``test_mql``."""
|
"""Structural Python port scaffold for AMESim model ``test_mql``."""
|
||||||
|
|
||||||
def __init__(self, archive_path: Path | None = None) -> None:
|
def __init__(self, archive_path: Path | None = None) -> None:
|
||||||
self.archive_path = archive_path or Path(__file__).resolve().parents[2] / AMESIM_ARCHIVE_RELATIVE_PATH
|
self.archive_path = archive_path or AMESIM_TEST_MQL_ARCHIVE_PATH
|
||||||
self.network = SimulationNetwork(name=MODEL_NAME)
|
self.network = SimulationNetwork(name=MODEL_NAME)
|
||||||
self.pnl0001_assembly = self._build_pnl0001_assembly()
|
self.pnl0001_assembly = self._build_pnl0001_assembly()
|
||||||
self.pnl0002_assembly = self._build_pnl0002_assembly()
|
self.pnl0002_assembly = self._build_pnl0002_assembly()
|
||||||
@@ -4537,40 +4537,40 @@ class TestMqlSystem:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_pneumatic_assembly():
|
def _build_pneumatic_assembly():
|
||||||
# Local import avoids a module cycle: test_mql_config reads constants from this module.
|
# Local import avoids a module cycle: test_mql_config reads constants from this module.
|
||||||
from PythonModels.systems.test_mql_pneumatic import build_test_mql_pneumatic_assembly
|
from app.simulation.examples.test_mql.pneumatic import build_test_mql_pneumatic_assembly
|
||||||
|
|
||||||
return build_test_mql_pneumatic_assembly()
|
return build_test_mql_pneumatic_assembly()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_mechanical_assembly():
|
def _build_mechanical_assembly():
|
||||||
# Local import avoids a module cycle: test_mql_config reads constants from this module.
|
# Local import avoids a module cycle: test_mql_config reads constants from this module.
|
||||||
from PythonModels.systems.test_mql_mechanical import build_test_mql_mechanical_assembly
|
from app.simulation.examples.test_mql.mechanical import build_test_mql_mechanical_assembly
|
||||||
|
|
||||||
return build_test_mql_mechanical_assembly()
|
return build_test_mql_mechanical_assembly()
|
||||||
|
|
||||||
def _build_pnl0001_assembly(self):
|
def _build_pnl0001_assembly(self):
|
||||||
from PythonModels.systems.test_mql_pneumatic_lines import (
|
from app.simulation.examples.test_mql.pneumatic_lines import (
|
||||||
build_test_mql_pnl0001_assembly,
|
build_test_mql_pnl0001_assembly,
|
||||||
)
|
)
|
||||||
|
|
||||||
return build_test_mql_pnl0001_assembly(self.archive_path)
|
return build_test_mql_pnl0001_assembly(self.archive_path)
|
||||||
|
|
||||||
def _build_pnl0002_assembly(self):
|
def _build_pnl0002_assembly(self):
|
||||||
from PythonModels.systems.test_mql_pneumatic_lines import (
|
from app.simulation.examples.test_mql.pneumatic_lines import (
|
||||||
build_test_mql_pnl0002_assembly,
|
build_test_mql_pnl0002_assembly,
|
||||||
)
|
)
|
||||||
|
|
||||||
return build_test_mql_pnl0002_assembly(self.archive_path)
|
return build_test_mql_pnl0002_assembly(self.archive_path)
|
||||||
|
|
||||||
def _build_pnl0003_assembly(self):
|
def _build_pnl0003_assembly(self):
|
||||||
from PythonModels.systems.test_mql_pneumatic_lines import (
|
from app.simulation.examples.test_mql.pneumatic_lines import (
|
||||||
build_test_mql_pnl0003_assembly,
|
build_test_mql_pnl0003_assembly,
|
||||||
)
|
)
|
||||||
|
|
||||||
return build_test_mql_pnl0003_assembly(self.archive_path)
|
return build_test_mql_pnl0003_assembly(self.archive_path)
|
||||||
|
|
||||||
def _build_pnl00r_assembly(self):
|
def _build_pnl00r_assembly(self):
|
||||||
from PythonModels.systems.test_mql_pneumatic_lines import (
|
from app.simulation.examples.test_mql.pneumatic_lines import (
|
||||||
build_test_mql_pnl00r_assembly,
|
build_test_mql_pnl00r_assembly,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4578,13 +4578,13 @@ class TestMqlSystem:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_node3_assembly():
|
def _build_node3_assembly():
|
||||||
from PythonModels.systems.test_mql_nodes import build_test_mql_node3_assembly
|
from app.simulation.examples.test_mql.nodes import build_test_mql_node3_assembly
|
||||||
|
|
||||||
return build_test_mql_node3_assembly()
|
return build_test_mql_node3_assembly()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_node4_assembly():
|
def _build_node4_assembly():
|
||||||
from PythonModels.systems.test_mql_nodes import build_test_mql_node4_assembly
|
from app.simulation.examples.test_mql.nodes import build_test_mql_node4_assembly
|
||||||
|
|
||||||
return build_test_mql_node4_assembly()
|
return build_test_mql_node4_assembly()
|
||||||
|
|
||||||
@@ -4637,7 +4637,7 @@ class TestMqlSystem:
|
|||||||
return self.pnl0001_assembly.lines[spec.alias]
|
return self.pnl0001_assembly.lines[spec.alias]
|
||||||
|
|
||||||
def _pnl0002_connection_for_node_port(self, node_alias: str, port_name: str):
|
def _pnl0002_connection_for_node_port(self, node_alias: str, port_name: str):
|
||||||
from PythonModels.systems.test_mql_nodes import TestMqlP4NodePortConnection
|
from app.simulation.examples.test_mql.nodes import TestMqlP4NodePortConnection
|
||||||
|
|
||||||
spec = self._pnl0002_spec_for_node_port(node_alias, port_name)
|
spec = self._pnl0002_spec_for_node_port(node_alias, port_name)
|
||||||
if spec.source_component == node_alias and spec.source_port == port_name:
|
if spec.source_component == node_alias and spec.source_port == port_name:
|
||||||
@@ -4674,7 +4674,7 @@ class TestMqlSystem:
|
|||||||
return self.pnl0002_assembly.lines[spec.alias]
|
return self.pnl0002_assembly.lines[spec.alias]
|
||||||
|
|
||||||
def _p4_primary_connection_for_node(self, node_alias: str):
|
def _p4_primary_connection_for_node(self, node_alias: str):
|
||||||
from PythonModels.systems.test_mql_nodes import TestMqlP4NodePrimaryConnection
|
from app.simulation.examples.test_mql.nodes import TestMqlP4NodePrimaryConnection
|
||||||
|
|
||||||
spec = self._pnl0001_spec_for_node_port(node_alias, "port_2")
|
spec = self._pnl0001_spec_for_node_port(node_alias, "port_2")
|
||||||
if spec.source_component == node_alias and spec.source_port == "port_2":
|
if spec.source_component == node_alias and spec.source_port == "port_2":
|
||||||
@@ -4694,7 +4694,7 @@ class TestMqlSystem:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _p4_port4_orifice_connection_for_node(self, node_alias: str):
|
def _p4_port4_orifice_connection_for_node(self, node_alias: str):
|
||||||
from PythonModels.systems.test_mql_nodes import TestMqlP4NodeOrificeConnection
|
from app.simulation.examples.test_mql.nodes import TestMqlP4NodeOrificeConnection
|
||||||
|
|
||||||
for spec in CONNECTION_SPECS:
|
for spec in CONNECTION_SPECS:
|
||||||
if spec["submodel"] != "DIRECT":
|
if spec["submodel"] != "DIRECT":
|
||||||
@@ -4720,7 +4720,7 @@ class TestMqlSystem:
|
|||||||
raise KeyError(f"No PNVO orifice attached to {node_alias}.port_4")
|
raise KeyError(f"No PNVO orifice attached to {node_alias}.port_4")
|
||||||
|
|
||||||
def p4_node_neighborhood(self, node_alias: str):
|
def p4_node_neighborhood(self, node_alias: str):
|
||||||
from PythonModels.systems.test_mql_nodes import TestMqlP4NodeNeighborhood
|
from app.simulation.examples.test_mql.nodes import TestMqlP4NodeNeighborhood
|
||||||
|
|
||||||
if node_alias not in self.node4_assembly:
|
if node_alias not in self.node4_assembly:
|
||||||
raise KeyError(f"Unknown P4 node: {node_alias}")
|
raise KeyError(f"Unknown P4 node: {node_alias}")
|
||||||
@@ -4774,7 +4774,7 @@ class TestMqlSystem:
|
|||||||
return self.network.initial_state_vector()
|
return self.network.initial_state_vector()
|
||||||
|
|
||||||
def mechanical_mass_closure(self):
|
def mechanical_mass_closure(self):
|
||||||
from PythonModels.systems.test_mql_mechanical import TestMqlMechanicalMassClosure
|
from app.simulation.examples.test_mql.mechanical import TestMqlMechanicalMassClosure
|
||||||
|
|
||||||
return TestMqlMechanicalMassClosure(self.mechanical_assembly)
|
return TestMqlMechanicalMassClosure(self.mechanical_assembly)
|
||||||
|
|
||||||
@@ -4900,7 +4900,7 @@ class TestMqlSystem:
|
|||||||
downstream_volume_alias: str,
|
downstream_volume_alias: str,
|
||||||
source: str = "manual",
|
source: str = "manual",
|
||||||
):
|
):
|
||||||
from PythonModels.systems.test_mql_closure import TestMqlPneumaticBranchSpec
|
from app.simulation.examples.test_mql.closure import TestMqlPneumaticBranchSpec
|
||||||
|
|
||||||
return TestMqlPneumaticBranchSpec(
|
return TestMqlPneumaticBranchSpec(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -4911,14 +4911,14 @@ class TestMqlSystem:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def discover_pneumatic_branch_topology(self):
|
def discover_pneumatic_branch_topology(self):
|
||||||
from PythonModels.systems.test_mql_closure import (
|
from app.simulation.examples.test_mql.closure import (
|
||||||
TestMqlPneumaticTopologyCandidate,
|
TestMqlPneumaticTopologyCandidate,
|
||||||
TestMqlPneumaticTopologyDiscovery,
|
TestMqlPneumaticTopologyDiscovery,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_pneumatic_topology import (
|
from app.simulation.examples.test_mql.pneumatic_topology import (
|
||||||
discover_fixed_chamber_segments,
|
discover_fixed_chamber_segments,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_topology import load_test_mql_cir_topology
|
from app.simulation.examples.test_mql.topology import load_test_mql_cir_topology
|
||||||
|
|
||||||
topology = load_test_mql_cir_topology(self.archive_path)
|
topology = load_test_mql_cir_topology(self.archive_path)
|
||||||
chamber_segment_specs = discover_fixed_chamber_segments(
|
chamber_segment_specs = discover_fixed_chamber_segments(
|
||||||
@@ -4999,11 +4999,11 @@ class TestMqlSystem:
|
|||||||
|
|
||||||
Boundary values apply at the PNL0001/orifice interfaces, not at the nodes.
|
Boundary values apply at the PNL0001/orifice interfaces, not at the nodes.
|
||||||
"""
|
"""
|
||||||
from PythonModels.components.amesim_pneumatic import (
|
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||||
AmesimPneumaticOrifice,
|
AmesimPneumaticOrifice,
|
||||||
AmesimPneumaticVolume,
|
AmesimPneumaticVolume,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_closure import (
|
from app.simulation.examples.test_mql.closure import (
|
||||||
TestMqlPneumaticBoundaryCondition,
|
TestMqlPneumaticBoundaryCondition,
|
||||||
TestMqlPneumaticChamberSegmentClosure,
|
TestMqlPneumaticChamberSegmentClosure,
|
||||||
TestMqlPneumaticChamberSegmentComponents,
|
TestMqlPneumaticChamberSegmentComponents,
|
||||||
@@ -5076,11 +5076,11 @@ class TestMqlSystem:
|
|||||||
outlet_temperature_k: float = 293.15,
|
outlet_temperature_k: float = 293.15,
|
||||||
):
|
):
|
||||||
"""Insert the topology-derived inlet PNL0001 into a chamber segment."""
|
"""Insert the topology-derived inlet PNL0001 into a chamber segment."""
|
||||||
from PythonModels.components.amesim_pneumatic import (
|
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||||
AmesimPneumaticOrifice,
|
AmesimPneumaticOrifice,
|
||||||
AmesimPneumaticVolume,
|
AmesimPneumaticVolume,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_closure import (
|
from app.simulation.examples.test_mql.closure import (
|
||||||
TestMqlPneumaticBoundaryCondition,
|
TestMqlPneumaticBoundaryCondition,
|
||||||
TestMqlPnl0001ChamberSegmentClosure,
|
TestMqlPnl0001ChamberSegmentClosure,
|
||||||
TestMqlPnl0001ChamberSegmentComponents,
|
TestMqlPnl0001ChamberSegmentComponents,
|
||||||
@@ -5154,11 +5154,11 @@ class TestMqlSystem:
|
|||||||
outlet_node_temperature_k: float = 293.15,
|
outlet_node_temperature_k: float = 293.15,
|
||||||
):
|
):
|
||||||
"""Insert both topology-derived PNL0001 states into the segment."""
|
"""Insert both topology-derived PNL0001 states into the segment."""
|
||||||
from PythonModels.components.amesim_pneumatic import (
|
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||||
AmesimPneumaticOrifice,
|
AmesimPneumaticOrifice,
|
||||||
AmesimPneumaticVolume,
|
AmesimPneumaticVolume,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_closure import (
|
from app.simulation.examples.test_mql.closure import (
|
||||||
TestMqlPneumaticBoundaryCondition,
|
TestMqlPneumaticBoundaryCondition,
|
||||||
TestMqlPnl0001PairChamberSegmentClosure,
|
TestMqlPnl0001PairChamberSegmentClosure,
|
||||||
TestMqlPnl0001PairChamberSegmentComponents,
|
TestMqlPnl0001PairChamberSegmentComponents,
|
||||||
@@ -5241,11 +5241,11 @@ class TestMqlSystem:
|
|||||||
port-1 compliance. The PNL0003 port-2 side is connected through the
|
port-1 compliance. The PNL0003 port-2 side is connected through the
|
||||||
real PNVO001 orifice to a prescribed P4 pressure boundary.
|
real PNVO001 orifice to a prescribed P4 pressure boundary.
|
||||||
"""
|
"""
|
||||||
from PythonModels.components.amesim_pneumatic import (
|
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||||
AmesimPneumaticOrifice,
|
AmesimPneumaticOrifice,
|
||||||
AmesimPneumaticVolume,
|
AmesimPneumaticVolume,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_closure import (
|
from app.simulation.examples.test_mql.closure import (
|
||||||
TestMqlPneumaticBoundaryCondition,
|
TestMqlPneumaticBoundaryCondition,
|
||||||
TestMqlPn3NodeChamberSegmentClosure,
|
TestMqlPn3NodeChamberSegmentClosure,
|
||||||
TestMqlPn3NodeChamberSegmentComponents,
|
TestMqlPn3NodeChamberSegmentComponents,
|
||||||
@@ -5354,11 +5354,11 @@ class TestMqlSystem:
|
|||||||
PNVO upstream endpoints are closed through their adjacent PN3/PNL0003
|
PNVO upstream endpoints are closed through their adjacent PN3/PNL0003
|
||||||
subnets.
|
subnets.
|
||||||
"""
|
"""
|
||||||
from PythonModels.components.amesim_pneumatic import (
|
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||||
AmesimPneumaticOrifice,
|
AmesimPneumaticOrifice,
|
||||||
AmesimPneumaticVolume,
|
AmesimPneumaticVolume,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_closure import (
|
from app.simulation.examples.test_mql.closure import (
|
||||||
TestMqlPneumaticBoundaryCondition,
|
TestMqlPneumaticBoundaryCondition,
|
||||||
TestMqlPn3P4NodeChamberSegmentClosure,
|
TestMqlPn3P4NodeChamberSegmentClosure,
|
||||||
TestMqlPn3P4NodeChamberSegmentComponents,
|
TestMqlPn3P4NodeChamberSegmentComponents,
|
||||||
@@ -6140,11 +6140,11 @@ class TestMqlSystem:
|
|||||||
downstream_volume_alias: str,
|
downstream_volume_alias: str,
|
||||||
spec=None,
|
spec=None,
|
||||||
):
|
):
|
||||||
from PythonModels.components.amesim_pneumatic import (
|
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
||||||
AmesimPneumaticOrifice,
|
AmesimPneumaticOrifice,
|
||||||
AmesimPneumaticVolume,
|
AmesimPneumaticVolume,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_closure import (
|
from app.simulation.examples.test_mql.closure import (
|
||||||
TestMqlPneumaticBranchComponents,
|
TestMqlPneumaticBranchComponents,
|
||||||
TestMqlPneumaticBranchSpec,
|
TestMqlPneumaticBranchSpec,
|
||||||
TestMqlPneumaticClosure,
|
TestMqlPneumaticClosure,
|
||||||
File renamed without changes.
File diff suppressed because it is too large.
Load diff
@@ -15,7 +15,8 @@ DATA_DIR = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
|
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
|
||||||
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
|
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "data"
|
||||||
|
AMESIM_TEST_MQL_ARCHIVE_PATH = PROJECT_ROOT / "AmesimModels" / "test_mql.ame"
|
||||||
MODELICA_TESTMODEL_RESULT_PATH = (
|
MODELICA_TESTMODEL_RESULT_PATH = (
|
||||||
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
|
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
"""Low-overhead, run-local performance instrumentation for simulations.
|
||||||
|
|
||||||
|
The profiling mode is intentionally read once when this module is imported.
|
||||||
|
``standard`` records low-frequency pipeline stages, while ``audit`` also wraps
|
||||||
|
hot RHS/property operations and computes exact-input reuse metrics. With
|
||||||
|
profiling disabled, decorators return the original callable while classes are
|
||||||
|
being defined, so ordinary simulation calls do not pass through a wrapper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Generator, Mapping
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from functools import wraps
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
from time import perf_counter_ns
|
||||||
|
from typing import Any, Literal, ParamSpec, TypeVar, cast
|
||||||
|
|
||||||
|
|
||||||
|
ProfileMode = Literal["off", "standard", "audit"]
|
||||||
|
|
||||||
|
_P = ParamSpec("_P")
|
||||||
|
_R = TypeVar("_R")
|
||||||
|
_MODE_RANK: Mapping[ProfileMode, int] = {"off": 0, "standard": 1, "audit": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_startup_mode() -> ProfileMode:
|
||||||
|
raw_mode = os.getenv("SIMULATIONAPP_PROFILE", "off").strip().lower()
|
||||||
|
aliases: dict[str, ProfileMode] = {
|
||||||
|
"": "off",
|
||||||
|
"0": "off",
|
||||||
|
"false": "off",
|
||||||
|
"no": "off",
|
||||||
|
"off": "off",
|
||||||
|
"1": "standard",
|
||||||
|
"true": "standard",
|
||||||
|
"yes": "standard",
|
||||||
|
"on": "standard",
|
||||||
|
"standard": "standard",
|
||||||
|
"audit": "audit",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return aliases[raw_mode]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"SIMULATIONAPP_PROFILE must be one of: off, standard, audit."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE_MODE: ProfileMode = _read_startup_mode()
|
||||||
|
|
||||||
|
|
||||||
|
def _minimum_mode(value: str) -> ProfileMode:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized not in _MODE_RANK:
|
||||||
|
raise ValueError("minimum_mode must be one of: off, standard, audit.")
|
||||||
|
return cast(ProfileMode, normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _mode_enabled(minimum_mode: ProfileMode) -> bool:
|
||||||
|
# ``off`` is an unconditional zero-wrapper mode, even if a caller passes
|
||||||
|
# ``minimum_mode="off"`` by mistake.
|
||||||
|
return (
|
||||||
|
PROFILE_MODE != "off"
|
||||||
|
and _MODE_RANK[PROFILE_MODE] >= _MODE_RANK[minimum_mode]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _TimingStats:
|
||||||
|
calls: int = 0
|
||||||
|
inclusive_ns: int = 0
|
||||||
|
self_ns: int = 0
|
||||||
|
max_ns: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
|
||||||
|
def record(self, inclusive_ns: int, self_ns: int, error: bool) -> None:
|
||||||
|
self.calls += 1
|
||||||
|
self.inclusive_ns += inclusive_ns
|
||||||
|
self.self_ns += self_ns
|
||||||
|
self.max_ns = max(self.max_ns, inclusive_ns)
|
||||||
|
if error:
|
||||||
|
self.errors += 1
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"calls": self.calls,
|
||||||
|
"inclusiveNs": self.inclusive_ns,
|
||||||
|
"selfNs": self.self_ns,
|
||||||
|
"maxNs": self.max_ns,
|
||||||
|
"errors": self.errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _PropertyStats(_TimingStats):
|
||||||
|
operation: str = ""
|
||||||
|
layer: str = "semantic"
|
||||||
|
medium: str = "unknown"
|
||||||
|
exact_input_unique: int = 0
|
||||||
|
exact_input_repeats: int = 0
|
||||||
|
iteration_calls: int = 0
|
||||||
|
iteration_total: int = 0
|
||||||
|
iteration_max: int = 0
|
||||||
|
iteration_converged: int = 0
|
||||||
|
iteration_nonconverged: int = 0
|
||||||
|
cache_lookups: int = 0
|
||||||
|
cache_hits: int = 0
|
||||||
|
cache_misses: int = 0
|
||||||
|
|
||||||
|
def snapshot(self, *, audit: bool) -> dict[str, object]:
|
||||||
|
result: dict[str, object] = super().snapshot()
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"operation": self.operation,
|
||||||
|
"layer": self.layer,
|
||||||
|
"medium": self.medium,
|
||||||
|
"cacheLookups": self.cache_lookups,
|
||||||
|
"cacheHits": self.cache_hits,
|
||||||
|
"cacheMisses": self.cache_misses,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if audit:
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"exactInputUnique": self.exact_input_unique,
|
||||||
|
"exactInputRepeats": self.exact_input_repeats,
|
||||||
|
"iterationCalls": self.iteration_calls,
|
||||||
|
"iterationTotal": self.iteration_total,
|
||||||
|
"iterationMax": self.iteration_max,
|
||||||
|
"iterationConverged": self.iteration_converged,
|
||||||
|
"iterationNonconverged": self.iteration_nonconverged,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ActiveSpan:
|
||||||
|
trace: PerformanceTrace
|
||||||
|
name: str
|
||||||
|
started_ns: int
|
||||||
|
property_key: str | None = None
|
||||||
|
property_operation: str | None = None
|
||||||
|
property_outermost: bool = False
|
||||||
|
child_ns: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PerformanceTrace:
|
||||||
|
"""Mutable counters owned by exactly one :func:`profile_run` context."""
|
||||||
|
|
||||||
|
mode: ProfileMode
|
||||||
|
_phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False)
|
||||||
|
_properties: dict[str, _PropertyStats] = field(default_factory=dict, repr=False)
|
||||||
|
_property_outermost_ns: int = field(default=0, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self.mode != "off"
|
||||||
|
|
||||||
|
def _property_stats(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
layer: str,
|
||||||
|
medium: str,
|
||||||
|
) -> _PropertyStats:
|
||||||
|
stats = self._properties.get(key)
|
||||||
|
if stats is None:
|
||||||
|
stats = _PropertyStats(
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
)
|
||||||
|
self._properties[key] = stats
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def _record_span(self, frame: _ActiveSpan, elapsed_ns: int, error: bool) -> None:
|
||||||
|
self_ns = max(0, elapsed_ns - frame.child_ns)
|
||||||
|
if frame.property_key is None:
|
||||||
|
stats = self._phases.setdefault(frame.name, _TimingStats())
|
||||||
|
else:
|
||||||
|
layer, medium, operation = frame.property_key.split("|", 2)
|
||||||
|
stats = self._property_stats(
|
||||||
|
frame.property_key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
)
|
||||||
|
if frame.property_outermost:
|
||||||
|
self._property_outermost_ns += elapsed_ns
|
||||||
|
stats.record(elapsed_ns, self_ns, error)
|
||||||
|
|
||||||
|
def _record_exact_input(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
layer: str,
|
||||||
|
medium: str,
|
||||||
|
fingerprint: object,
|
||||||
|
) -> None:
|
||||||
|
stats = self._property_stats(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
)
|
||||||
|
shadow_key = (key, fingerprint)
|
||||||
|
shadow = _PROPERTY_SHADOW.get()
|
||||||
|
if shadow is None:
|
||||||
|
# A trace normally installs its own set in ``profile_run``. Keep
|
||||||
|
# the ContextVar default immutable so no task can accidentally
|
||||||
|
# share a process-global shadow set.
|
||||||
|
shadow = set()
|
||||||
|
_PROPERTY_SHADOW.set(shadow)
|
||||||
|
if shadow_key in shadow:
|
||||||
|
stats.exact_input_repeats += 1
|
||||||
|
else:
|
||||||
|
shadow.add(shadow_key)
|
||||||
|
stats.exact_input_unique += 1
|
||||||
|
|
||||||
|
def _record_iterations(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
layer: str,
|
||||||
|
medium: str,
|
||||||
|
iterations: int,
|
||||||
|
converged: bool,
|
||||||
|
) -> None:
|
||||||
|
stats = self._property_stats(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
)
|
||||||
|
iteration_count = max(0, int(iterations))
|
||||||
|
stats.iteration_calls += 1
|
||||||
|
stats.iteration_total += iteration_count
|
||||||
|
stats.iteration_max = max(stats.iteration_max, iteration_count)
|
||||||
|
if converged:
|
||||||
|
stats.iteration_converged += 1
|
||||||
|
else:
|
||||||
|
stats.iteration_nonconverged += 1
|
||||||
|
|
||||||
|
def _record_cache(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
layer: str,
|
||||||
|
medium: str,
|
||||||
|
hits: int,
|
||||||
|
misses: int,
|
||||||
|
) -> None:
|
||||||
|
stats = self._property_stats(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
)
|
||||||
|
hit_delta = max(0, hits)
|
||||||
|
miss_delta = max(0, misses)
|
||||||
|
stats.cache_hits += hit_delta
|
||||||
|
stats.cache_misses += miss_delta
|
||||||
|
stats.cache_lookups += hit_delta + miss_delta
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
"""Return a detached, JSON-serializable copy of all counters."""
|
||||||
|
|
||||||
|
audit = self.mode == "audit"
|
||||||
|
return {
|
||||||
|
"mode": self.mode,
|
||||||
|
"phases": {
|
||||||
|
name: self._phases[name].snapshot()
|
||||||
|
for name in sorted(self._phases)
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
_public_property_key(key): self._properties[key].snapshot(audit=audit)
|
||||||
|
for key in sorted(self._properties)
|
||||||
|
},
|
||||||
|
"propertyOutermostNs": self._property_outermost_ns,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_CURRENT_TRACE: ContextVar[PerformanceTrace | None] = ContextVar(
|
||||||
|
"simulation_performance_trace",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
_ACTIVE_SPANS: ContextVar[tuple[_ActiveSpan, ...]] = ContextVar(
|
||||||
|
"simulation_performance_spans",
|
||||||
|
default=(),
|
||||||
|
)
|
||||||
|
_PROPERTY_SHADOW: ContextVar[set[object] | None] = ContextVar(
|
||||||
|
"simulation_property_shadow",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_property_key(key: str) -> str:
|
||||||
|
layer, medium, operation = key.split("|", 2)
|
||||||
|
return f"{layer}.{medium}.{operation}"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _tracked_span(
|
||||||
|
trace: PerformanceTrace,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
property_key: str | None = None,
|
||||||
|
property_operation: str | None = None,
|
||||||
|
reset_property_shadow: bool = False,
|
||||||
|
) -> Generator[None, None, None]:
|
||||||
|
stack = _ACTIVE_SPANS.get()
|
||||||
|
property_outermost = property_key is not None and not any(
|
||||||
|
item.property_key is not None for item in stack
|
||||||
|
)
|
||||||
|
frame = _ActiveSpan(
|
||||||
|
trace=trace,
|
||||||
|
name=name,
|
||||||
|
started_ns=perf_counter_ns(),
|
||||||
|
property_key=property_key,
|
||||||
|
property_operation=property_operation,
|
||||||
|
property_outermost=property_outermost,
|
||||||
|
)
|
||||||
|
stack_token = _ACTIVE_SPANS.set((*stack, frame))
|
||||||
|
shadow_token: Token[set[object] | None] | None = None
|
||||||
|
if reset_property_shadow and trace.mode == "audit":
|
||||||
|
shadow_token = _PROPERTY_SHADOW.set(set())
|
||||||
|
error = False
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except BaseException:
|
||||||
|
error = True
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
elapsed_ns = max(0, perf_counter_ns() - frame.started_ns)
|
||||||
|
_ACTIVE_SPANS.reset(stack_token)
|
||||||
|
if shadow_token is not None:
|
||||||
|
_PROPERTY_SHADOW.reset(shadow_token)
|
||||||
|
if stack:
|
||||||
|
stack[-1].child_ns += elapsed_ns
|
||||||
|
trace._record_span(frame, elapsed_ns, error)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def profile_run() -> Generator[PerformanceTrace, None, None]:
|
||||||
|
"""Create and bind an isolated trace for one simulation run.
|
||||||
|
|
||||||
|
The yielded trace remains usable after the context exits, which lets the
|
||||||
|
caller attach ``trace.snapshot()`` to a result without exposing live state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
trace = PerformanceTrace(mode=PROFILE_MODE)
|
||||||
|
trace_token = _CURRENT_TRACE.set(trace)
|
||||||
|
spans_token = _ACTIVE_SPANS.set(())
|
||||||
|
shadow_token = _PROPERTY_SHADOW.set(set())
|
||||||
|
try:
|
||||||
|
if trace.enabled:
|
||||||
|
with _tracked_span(trace, "simulation.total"):
|
||||||
|
yield trace
|
||||||
|
else:
|
||||||
|
yield trace
|
||||||
|
finally:
|
||||||
|
_PROPERTY_SHADOW.reset(shadow_token)
|
||||||
|
_ACTIVE_SPANS.reset(spans_token)
|
||||||
|
_CURRENT_TRACE.reset(trace_token)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def performance_span(
|
||||||
|
name: str,
|
||||||
|
minimum_mode: str = "standard",
|
||||||
|
reset_property_shadow: bool = False,
|
||||||
|
) -> Generator[None, None, None]:
|
||||||
|
"""Time a block in the current run, or act as a no-op outside one."""
|
||||||
|
|
||||||
|
minimum = _minimum_mode(minimum_mode)
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
if trace is None or not _mode_enabled(minimum):
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
with _tracked_span(
|
||||||
|
trace,
|
||||||
|
name,
|
||||||
|
reset_property_shadow=reset_property_shadow,
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def profile_phase(
|
||||||
|
name: str,
|
||||||
|
minimum_mode: str = "standard",
|
||||||
|
reset_property_shadow: bool = False,
|
||||||
|
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||||
|
"""Decorate a simulation phase while preserving the off-mode callable."""
|
||||||
|
|
||||||
|
minimum = _minimum_mode(minimum_mode)
|
||||||
|
|
||||||
|
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||||
|
if not _mode_enabled(minimum):
|
||||||
|
return function
|
||||||
|
|
||||||
|
if inspect.iscoroutinefunction(function):
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
if trace is None:
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
with _tracked_span(
|
||||||
|
trace,
|
||||||
|
name,
|
||||||
|
reset_property_shadow=reset_property_shadow,
|
||||||
|
):
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
|
||||||
|
return cast(Callable[_P, _R], async_wrapper)
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
if trace is None:
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
with _tracked_span(
|
||||||
|
trace,
|
||||||
|
name,
|
||||||
|
reset_property_shadow=reset_property_shadow,
|
||||||
|
):
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
def _medium_name(args: tuple[object, ...]) -> str:
|
||||||
|
if not args:
|
||||||
|
return "unknown"
|
||||||
|
owner = args[0]
|
||||||
|
configured_name = getattr(owner, "name", None)
|
||||||
|
if isinstance(configured_name, str) and configured_name:
|
||||||
|
return configured_name
|
||||||
|
return type(owner).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(value: object) -> object:
|
||||||
|
"""Build a hashable, bit-exact token without retaining arbitrary objects."""
|
||||||
|
|
||||||
|
if value is None or isinstance(value, (bool, int, str, bytes)):
|
||||||
|
return (type(value).__name__, value)
|
||||||
|
if isinstance(value, float):
|
||||||
|
return ("float64", struct.pack("!d", value))
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return ("tuple", tuple(_fingerprint(item) for item in value))
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ("list", tuple(_fingerprint(item) for item in value))
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
items = [(_fingerprint(key), _fingerprint(item)) for key, item in value.items()]
|
||||||
|
items.sort(key=repr)
|
||||||
|
return ("mapping", tuple(items))
|
||||||
|
return (
|
||||||
|
"object",
|
||||||
|
type(value).__module__,
|
||||||
|
type(value).__qualname__,
|
||||||
|
id(value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _input_fingerprint(
|
||||||
|
signature: inspect.Signature | None,
|
||||||
|
args: tuple[object, ...],
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
) -> object:
|
||||||
|
if signature is not None:
|
||||||
|
try:
|
||||||
|
bound = signature.bind(*args, **kwargs)
|
||||||
|
bound.apply_defaults()
|
||||||
|
return tuple(
|
||||||
|
(name, _fingerprint(value))
|
||||||
|
for name, value in bound.arguments.items()
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
return (
|
||||||
|
_fingerprint(args),
|
||||||
|
tuple(sorted((name, _fingerprint(value)) for name, value in kwargs.items())),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_counts(function: Callable[..., object]) -> tuple[int, int] | None:
|
||||||
|
cache_info = getattr(function, "cache_info", None)
|
||||||
|
if not callable(cache_info):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
info = cache_info()
|
||||||
|
return int(info.hits), int(info.misses)
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_cache_api(source: Callable[..., object], target: Callable[..., object]) -> None:
|
||||||
|
for attribute in ("cache_clear", "cache_info", "cache_parameters"):
|
||||||
|
value = getattr(source, attribute, None)
|
||||||
|
if value is not None:
|
||||||
|
setattr(target, attribute, value)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_property(
|
||||||
|
operation: str,
|
||||||
|
layer: str = "semantic",
|
||||||
|
minimum_mode: str = "audit",
|
||||||
|
capture_inputs: bool = True,
|
||||||
|
track_cache: bool = False,
|
||||||
|
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||||
|
"""Decorate one thermodynamic property operation."""
|
||||||
|
|
||||||
|
minimum = _minimum_mode(minimum_mode)
|
||||||
|
|
||||||
|
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||||
|
if not _mode_enabled(minimum):
|
||||||
|
return function
|
||||||
|
try:
|
||||||
|
signature: inspect.Signature | None = inspect.signature(function)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
signature = None
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
args: tuple[object, ...],
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
) -> tuple[PerformanceTrace | None, str, str, tuple[int, int] | None]:
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
medium = _medium_name(args)
|
||||||
|
key = f"{layer}|{medium}|{operation}"
|
||||||
|
if trace is not None and trace.mode == "audit" and capture_inputs:
|
||||||
|
trace._record_exact_input(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
fingerprint=_input_fingerprint(signature, args, kwargs),
|
||||||
|
)
|
||||||
|
before = _cache_counts(function) if trace is not None and track_cache else None
|
||||||
|
return trace, medium, key, before
|
||||||
|
|
||||||
|
def finish_cache(
|
||||||
|
trace: PerformanceTrace | None,
|
||||||
|
medium: str,
|
||||||
|
key: str,
|
||||||
|
before: tuple[int, int] | None,
|
||||||
|
) -> None:
|
||||||
|
if trace is None or before is None:
|
||||||
|
return
|
||||||
|
after = _cache_counts(function)
|
||||||
|
if after is None:
|
||||||
|
return
|
||||||
|
trace._record_cache(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
hits=after[0] - before[0],
|
||||||
|
misses=after[1] - before[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
if inspect.iscoroutinefunction(function):
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
|
||||||
|
object_args = cast(tuple[object, ...], args)
|
||||||
|
object_kwargs = cast(dict[str, object], kwargs)
|
||||||
|
trace, medium, key, before = prepare(object_args, object_kwargs)
|
||||||
|
try:
|
||||||
|
if trace is None:
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
with _tracked_span(
|
||||||
|
trace,
|
||||||
|
f"property.{_public_property_key(key)}",
|
||||||
|
property_key=key,
|
||||||
|
property_operation=operation,
|
||||||
|
):
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
finish_cache(trace, medium, key, before)
|
||||||
|
|
||||||
|
_copy_cache_api(function, async_wrapper)
|
||||||
|
return cast(Callable[_P, _R], async_wrapper)
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||||
|
object_args = cast(tuple[object, ...], args)
|
||||||
|
object_kwargs = cast(dict[str, object], kwargs)
|
||||||
|
trace, medium, key, before = prepare(object_args, object_kwargs)
|
||||||
|
try:
|
||||||
|
if trace is None:
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
with _tracked_span(
|
||||||
|
trace,
|
||||||
|
f"property.{_public_property_key(key)}",
|
||||||
|
property_key=key,
|
||||||
|
property_operation=operation,
|
||||||
|
):
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
finish_cache(trace, medium, key, before)
|
||||||
|
|
||||||
|
_copy_cache_api(function, wrapper)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
def record_property_iterations(
|
||||||
|
operation: str,
|
||||||
|
iterations: int,
|
||||||
|
converged: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Record inverse-property solver iterations in audit mode."""
|
||||||
|
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
if trace is None or trace.mode != "audit":
|
||||||
|
return
|
||||||
|
for frame in reversed(_ACTIVE_SPANS.get()):
|
||||||
|
if (
|
||||||
|
frame.property_key is not None
|
||||||
|
and frame.property_operation == operation
|
||||||
|
):
|
||||||
|
layer, medium, _unused_operation = frame.property_key.split("|", 2)
|
||||||
|
trace._record_iterations(
|
||||||
|
frame.property_key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
iterations=iterations,
|
||||||
|
converged=converged,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
key = f"semantic|unknown|{operation}"
|
||||||
|
trace._record_iterations(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer="semantic",
|
||||||
|
medium="unknown",
|
||||||
|
iterations=iterations,
|
||||||
|
converged=converged,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_property_cache(operation: str, *, hit: bool) -> None:
|
||||||
|
"""Record one run-local property-cache lookup in audit mode."""
|
||||||
|
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
if trace is None or trace.mode != "audit":
|
||||||
|
return
|
||||||
|
for frame in reversed(_ACTIVE_SPANS.get()):
|
||||||
|
if (
|
||||||
|
frame.property_key is not None
|
||||||
|
and frame.property_operation == operation
|
||||||
|
):
|
||||||
|
layer, medium, _unused_operation = frame.property_key.split("|", 2)
|
||||||
|
trace._record_cache(
|
||||||
|
frame.property_key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
hits=1 if hit else 0,
|
||||||
|
misses=0 if hit else 1,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PROFILE_MODE",
|
||||||
|
"PerformanceTrace",
|
||||||
|
"performance_span",
|
||||||
|
"profile_phase",
|
||||||
|
"profile_property",
|
||||||
|
"profile_run",
|
||||||
|
"record_property_cache",
|
||||||
|
"record_property_iterations",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,234 @@
|
|||||||
|
"""Run-local, exact-key cache for expensive thermodynamic calculations.
|
||||||
|
|
||||||
|
The cache is deliberately bound to one simulation through ``ContextVar``.
|
||||||
|
That keeps concurrent runs isolated and releases all cached states when the
|
||||||
|
run finishes. Keys use the original Python values with no rounding or
|
||||||
|
tolerance-based reuse that could flatten numerical residuals seen by ODE and
|
||||||
|
nonlinear solvers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache, wraps
|
||||||
|
import os
|
||||||
|
from typing import ParamSpec, TypeVar
|
||||||
|
|
||||||
|
from app.simulation.performance import PROFILE_MODE, record_property_cache
|
||||||
|
|
||||||
|
|
||||||
|
_P = ParamSpec("_P")
|
||||||
|
_R = TypeVar("_R")
|
||||||
|
DEFAULT_PROPERTY_CACHE_MAX_ENTRIES = 8192
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cache_enabled() -> bool:
|
||||||
|
raw_value = os.getenv("SIMULATIONAPP_PROPERTY_CACHE", "on").strip().lower()
|
||||||
|
if raw_value in {"", "1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if raw_value in {"0", "false", "no", "off"}:
|
||||||
|
return False
|
||||||
|
raise ValueError(
|
||||||
|
"SIMULATIONAPP_PROPERTY_CACHE must be one of: on, off, true, false, 1, 0."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROPERTY_CACHE_ENABLED = _read_cache_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PropertyCacheInfo:
|
||||||
|
hits: int
|
||||||
|
misses: int
|
||||||
|
max_entries_per_cache: int
|
||||||
|
cache_count: int
|
||||||
|
current_entries: int
|
||||||
|
evictions: int
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationPropertyCache:
|
||||||
|
"""Bounded C-level LRUs owned by one simulation run."""
|
||||||
|
|
||||||
|
def __init__(self, max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES) -> None:
|
||||||
|
if max_entries <= 0:
|
||||||
|
raise ValueError("Property cache max_entries must be positive.")
|
||||||
|
self.max_entries_per_cache = int(max_entries)
|
||||||
|
self._functions: dict[
|
||||||
|
tuple[str, int, Callable[..., object]],
|
||||||
|
Callable[..., object],
|
||||||
|
] = {}
|
||||||
|
self._failed_misses: dict[
|
||||||
|
tuple[str, int, Callable[..., object]],
|
||||||
|
int,
|
||||||
|
] = {}
|
||||||
|
self._owners: dict[int, tuple[object, int]] = {}
|
||||||
|
self._next_owner_token = 0
|
||||||
|
|
||||||
|
def owner_token(self, owner: object) -> int:
|
||||||
|
"""Return a stable identity token and retain its owner for this run."""
|
||||||
|
|
||||||
|
identity = id(owner)
|
||||||
|
existing = self._owners.get(identity)
|
||||||
|
if existing is not None and existing[0] is owner:
|
||||||
|
return existing[1]
|
||||||
|
self._next_owner_token += 1
|
||||||
|
self._owners[identity] = (owner, self._next_owner_token)
|
||||||
|
return self._next_owner_token
|
||||||
|
|
||||||
|
def get_or_compute(
|
||||||
|
self,
|
||||||
|
operation: str,
|
||||||
|
owner: object,
|
||||||
|
function: Callable[..., _R],
|
||||||
|
args: tuple[object, ...],
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
) -> _R:
|
||||||
|
cache_key = (operation, self.owner_token(owner), function)
|
||||||
|
cached_function = self._functions.get(cache_key)
|
||||||
|
if cached_function is None:
|
||||||
|
|
||||||
|
@lru_cache(maxsize=self.max_entries_per_cache, typed=True)
|
||||||
|
def invoke(*cached_args: object, **cached_kwargs: object) -> _R:
|
||||||
|
return function(owner, *cached_args, **cached_kwargs)
|
||||||
|
|
||||||
|
cached_function = invoke
|
||||||
|
self._functions[cache_key] = cached_function
|
||||||
|
|
||||||
|
if PROFILE_MODE != "audit":
|
||||||
|
try:
|
||||||
|
return cached_function(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
self._failed_misses[cache_key] = (
|
||||||
|
self._failed_misses.get(cache_key, 0) + 1
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
before = cached_function.cache_info() # type: ignore[attr-defined]
|
||||||
|
try:
|
||||||
|
value = cached_function(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
self._failed_misses[cache_key] = (
|
||||||
|
self._failed_misses.get(cache_key, 0) + 1
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
after = cached_function.cache_info() # type: ignore[attr-defined]
|
||||||
|
hit = after.hits > before.hits
|
||||||
|
record_property_cache(operation, hit=hit)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def info(self) -> PropertyCacheInfo:
|
||||||
|
cache_infos = {
|
||||||
|
key: cached.cache_info() # type: ignore[attr-defined]
|
||||||
|
for key, cached in self._functions.items()
|
||||||
|
}
|
||||||
|
return PropertyCacheInfo(
|
||||||
|
hits=sum(info.hits for info in cache_infos.values()),
|
||||||
|
misses=sum(info.misses for info in cache_infos.values()),
|
||||||
|
max_entries_per_cache=self.max_entries_per_cache,
|
||||||
|
cache_count=len(self._functions),
|
||||||
|
current_entries=sum(info.currsize for info in cache_infos.values()),
|
||||||
|
evictions=sum(
|
||||||
|
max(
|
||||||
|
0,
|
||||||
|
info.misses
|
||||||
|
- self._failed_misses.get(key, 0)
|
||||||
|
- info.currsize,
|
||||||
|
)
|
||||||
|
for key, info in cache_infos.items()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CURRENT_PROPERTY_CACHE: ContextVar[SimulationPropertyCache | None] = ContextVar(
|
||||||
|
"simulation_property_cache",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def current_property_cache() -> SimulationPropertyCache | None:
|
||||||
|
return _CURRENT_PROPERTY_CACHE.get()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def property_cache_run(
|
||||||
|
*,
|
||||||
|
max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES,
|
||||||
|
) -> Generator[SimulationPropertyCache | None, None, None]:
|
||||||
|
"""Bind a fresh cache to one top-level simulation run.
|
||||||
|
|
||||||
|
Nested uses reuse the existing cache so lower-level simulation helpers can
|
||||||
|
safely opt in without replacing the cache created by the API entry point.
|
||||||
|
"""
|
||||||
|
|
||||||
|
existing = _CURRENT_PROPERTY_CACHE.get()
|
||||||
|
if existing is not None:
|
||||||
|
yield existing
|
||||||
|
return
|
||||||
|
if not PROPERTY_CACHE_ENABLED:
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
|
||||||
|
cache = SimulationPropertyCache(max_entries=max_entries)
|
||||||
|
token = _CURRENT_PROPERTY_CACHE.set(cache)
|
||||||
|
try:
|
||||||
|
yield cache
|
||||||
|
finally:
|
||||||
|
_CURRENT_PROPERTY_CACHE.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_property_calculation(
|
||||||
|
operation: str,
|
||||||
|
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||||
|
"""Cache one pure property calculation with hashable arguments per run."""
|
||||||
|
|
||||||
|
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||||
|
if not PROPERTY_CACHE_ENABLED:
|
||||||
|
return function
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||||
|
cache = _CURRENT_PROPERTY_CACHE.get()
|
||||||
|
if cache is None:
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
owner = args[0] if args else function
|
||||||
|
return cache.get_or_compute(
|
||||||
|
operation,
|
||||||
|
owner,
|
||||||
|
function,
|
||||||
|
tuple(args[1:] if args else ()),
|
||||||
|
dict(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
def with_property_cache(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||||
|
"""Ensure a simulation entry point has a run-local cache."""
|
||||||
|
|
||||||
|
if not PROPERTY_CACHE_ENABLED:
|
||||||
|
return function
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||||
|
with property_cache_run():
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_PROPERTY_CACHE_MAX_ENTRIES",
|
||||||
|
"PROPERTY_CACHE_ENABLED",
|
||||||
|
"PropertyCacheInfo",
|
||||||
|
"SimulationPropertyCache",
|
||||||
|
"cache_property_calculation",
|
||||||
|
"current_property_cache",
|
||||||
|
"property_cache_run",
|
||||||
|
"with_property_cache",
|
||||||
|
]
|
||||||
+329
-3
@@ -12,14 +12,17 @@ from app.simulation.core.catalog import (
|
|||||||
ComponentCategorySpec,
|
ComponentCategorySpec,
|
||||||
ComponentDisplaySpec,
|
ComponentDisplaySpec,
|
||||||
ComponentLibrarySpec,
|
ComponentLibrarySpec,
|
||||||
|
ParameterGroupDisplaySpec,
|
||||||
PortDisplaySpec,
|
PortDisplaySpec,
|
||||||
)
|
)
|
||||||
from app.simulation.core.metadata import (
|
from app.simulation.core.metadata import (
|
||||||
SI_UNIT_BY_QUANTITY,
|
SI_UNIT_BY_QUANTITY,
|
||||||
|
ParameterCondition,
|
||||||
ParameterDefinition,
|
ParameterDefinition,
|
||||||
|
ParameterOption,
|
||||||
ResultVariableDefinition,
|
ResultVariableDefinition,
|
||||||
)
|
)
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import GasMedium, IdealGasMedium
|
||||||
from app.simulation.core.ports import PortDefinition, PortVariableDefinition
|
from app.simulation.core.ports import PortDefinition, PortVariableDefinition
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +73,16 @@ class ComponentModelSpec:
|
|||||||
def parameter_by_name(self) -> dict[str, ParameterDefinition]:
|
def parameter_by_name(self) -> dict[str, ParameterDefinition]:
|
||||||
return {parameter.name: parameter for parameter in self.parameters}
|
return {parameter.name: parameter for parameter in self.parameters}
|
||||||
|
|
||||||
|
def active_ports(
|
||||||
|
self,
|
||||||
|
values: Mapping[str, float],
|
||||||
|
) -> tuple[PortDefinition, ...]:
|
||||||
|
resolved = {
|
||||||
|
parameter.name: values.get(parameter.name, parameter.default)
|
||||||
|
for parameter in self.parameters
|
||||||
|
}
|
||||||
|
return self.component_class.active_port_definitions_for_parameters(resolved)
|
||||||
|
|
||||||
def as_catalog_dict(self) -> dict[str, object]:
|
def as_catalog_dict(self) -> dict[str, object]:
|
||||||
category = self.library.category_by_id[self.display.category_id]
|
category = self.library.category_by_id[self.display.category_id]
|
||||||
display_ports = self.display.port_by_name
|
display_ports = self.display.port_by_name
|
||||||
@@ -81,7 +94,7 @@ class ComponentModelSpec:
|
|||||||
ports.append(payload)
|
ports.append(payload)
|
||||||
ports.sort(key=lambda item: (int(item["order"]), str(item["name"])))
|
ports.sort(key=lambda item: (int(item["order"]), str(item["name"])))
|
||||||
|
|
||||||
return {
|
payload: dict[str, object] = {
|
||||||
"type": self.model_type,
|
"type": self.model_type,
|
||||||
"modelType": self.model_type,
|
"modelType": self.model_type,
|
||||||
"modelVersion": self.model_version,
|
"modelVersion": self.model_version,
|
||||||
@@ -94,11 +107,22 @@ class ComponentModelSpec:
|
|||||||
parameter.as_interface_dict() for parameter in self.parameters
|
parameter.as_interface_dict() for parameter in self.parameters
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
if self.display.role is not None:
|
||||||
|
payload["role"] = self.display.role
|
||||||
|
if self.display.parameter_groups:
|
||||||
|
payload["parameterGroups"] = [
|
||||||
|
group.as_catalog_dict()
|
||||||
|
for group in sorted(
|
||||||
|
self.display.parameter_groups,
|
||||||
|
key=lambda item: (item.order, item.id),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return payload
|
||||||
|
|
||||||
def create(
|
def create(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
medium: IdealGasMedium,
|
medium: GasMedium,
|
||||||
values: Mapping[str, float],
|
values: Mapping[str, float],
|
||||||
) -> Component:
|
) -> Component:
|
||||||
unknown = sorted(set(values) - set(self.parameter_by_name))
|
unknown = sorted(set(values) - set(self.parameter_by_name))
|
||||||
@@ -276,6 +300,81 @@ def _validate_category(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_parameter_groups(
|
||||||
|
parameter_groups: object,
|
||||||
|
*,
|
||||||
|
model_type: str,
|
||||||
|
parameter_names: Sequence[str],
|
||||||
|
) -> None:
|
||||||
|
field = f"Component '{model_type}' display parameter groups"
|
||||||
|
if not isinstance(parameter_groups, tuple):
|
||||||
|
raise ValueError(f"{field} must be a tuple.")
|
||||||
|
|
||||||
|
group_ids: list[str] = []
|
||||||
|
grouped_parameter_names: list[str] = []
|
||||||
|
known_parameter_names = set(parameter_names)
|
||||||
|
for group in parameter_groups:
|
||||||
|
if not isinstance(group, ParameterGroupDisplaySpec):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} must use ParameterGroupDisplaySpec."
|
||||||
|
)
|
||||||
|
group_id = _validate_machine_id(
|
||||||
|
group.id,
|
||||||
|
field=f"{field} id",
|
||||||
|
)
|
||||||
|
_validate_label(
|
||||||
|
group.label,
|
||||||
|
field=f"{field} '{group_id}' label",
|
||||||
|
)
|
||||||
|
_validate_order(
|
||||||
|
group.order,
|
||||||
|
field=f"{field} '{group_id}' order",
|
||||||
|
)
|
||||||
|
if type(group.default_expanded) is not bool:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{group_id}' default_expanded must be a boolean."
|
||||||
|
)
|
||||||
|
if not isinstance(group.parameters, tuple) or not group.parameters:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{group_id}' parameters must use a non-empty tuple."
|
||||||
|
)
|
||||||
|
for parameter_name in group.parameters:
|
||||||
|
_validate_member_id(
|
||||||
|
parameter_name,
|
||||||
|
field=f"{field} '{group_id}' parameter name",
|
||||||
|
)
|
||||||
|
_validate_unique_names(
|
||||||
|
list(group.parameters),
|
||||||
|
field=f"{field} '{group_id}' parameters",
|
||||||
|
)
|
||||||
|
unknown_parameters = sorted(
|
||||||
|
set(group.parameters) - known_parameter_names
|
||||||
|
)
|
||||||
|
if unknown_parameters:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{group_id}' references unknown parameters: "
|
||||||
|
+ ", ".join(unknown_parameters)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
group_ids.append(group_id)
|
||||||
|
grouped_parameter_names.extend(group.parameters)
|
||||||
|
|
||||||
|
_validate_unique_names(group_ids, field=field)
|
||||||
|
duplicate_memberships = sorted(
|
||||||
|
{
|
||||||
|
name
|
||||||
|
for name in grouped_parameter_names
|
||||||
|
if grouped_parameter_names.count(name) > 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if duplicate_memberships:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} assign parameters to multiple groups: "
|
||||||
|
+ ", ".join(duplicate_memberships)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_component_library(library: ComponentLibrarySpec) -> None:
|
def validate_component_library(library: ComponentLibrarySpec) -> None:
|
||||||
if not isinstance(library, ComponentLibrarySpec):
|
if not isinstance(library, ComponentLibrarySpec):
|
||||||
raise ValueError("Enabled component libraries must use ComponentLibrarySpec.")
|
raise ValueError("Enabled component libraries must use ComponentLibrarySpec.")
|
||||||
@@ -410,6 +509,14 @@ def _validate_parameter(
|
|||||||
raise ValueError(f"{field} declarations must use ParameterDefinition.")
|
raise ValueError(f"{field} declarations must use ParameterDefinition.")
|
||||||
_validate_member_id(parameter.name, field=f"{field} name")
|
_validate_member_id(parameter.name, field=f"{field} name")
|
||||||
_validate_label(parameter.label, field=f"{field} '{parameter.name}' label")
|
_validate_label(parameter.label, field=f"{field} '{parameter.name}' label")
|
||||||
|
if not isinstance(parameter.description, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' description must be a string."
|
||||||
|
)
|
||||||
|
if parameter.description and not parameter.description.strip():
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' description must not be blank."
|
||||||
|
)
|
||||||
_validate_quantity_unit(
|
_validate_quantity_unit(
|
||||||
parameter.quantity,
|
parameter.quantity,
|
||||||
parameter.unit,
|
parameter.unit,
|
||||||
@@ -433,6 +540,131 @@ def _validate_parameter(
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{field} '{parameter.name}' minimum_exclusive must be a boolean."
|
f"{field} '{parameter.name}' minimum_exclusive must be a boolean."
|
||||||
)
|
)
|
||||||
|
if parameter.editor not in {
|
||||||
|
None,
|
||||||
|
"amesimGasReference",
|
||||||
|
"amesimGasPropertyModel",
|
||||||
|
"choice",
|
||||||
|
}:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' uses unsupported editor "
|
||||||
|
f"'{parameter.editor}'."
|
||||||
|
)
|
||||||
|
if not isinstance(parameter.options, tuple):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' options must use a tuple."
|
||||||
|
)
|
||||||
|
if parameter.editor == "amesimGasPropertyModel" and not parameter.options:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' property-model editor must declare options."
|
||||||
|
)
|
||||||
|
if parameter.editor == "choice" and not parameter.options:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' choice editor must declare options."
|
||||||
|
)
|
||||||
|
if parameter.options and parameter.editor not in {
|
||||||
|
"amesimGasPropertyModel",
|
||||||
|
"choice",
|
||||||
|
}:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' options require the "
|
||||||
|
"'choice' or 'amesimGasPropertyModel' editor."
|
||||||
|
)
|
||||||
|
option_values: list[float] = []
|
||||||
|
for option in parameter.options:
|
||||||
|
if not isinstance(option, ParameterOption):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' options must use ParameterOption."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
isinstance(option.value, bool)
|
||||||
|
or not isinstance(option.value, (int, float))
|
||||||
|
or not isfinite(option.value)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' option values must be finite numbers."
|
||||||
|
)
|
||||||
|
_validate_label(
|
||||||
|
option.label,
|
||||||
|
field=(
|
||||||
|
f"{field} '{parameter.name}' option "
|
||||||
|
f"'{option.value:g}' label"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
numeric_option = float(option.value)
|
||||||
|
if (
|
||||||
|
parameter.editor == "amesimGasPropertyModel"
|
||||||
|
and not numeric_option.is_integer()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' property-model option values "
|
||||||
|
"must be integers."
|
||||||
|
)
|
||||||
|
option_message = parameter.validation_message(numeric_option)
|
||||||
|
if option_message is not None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' option {numeric_option:g} "
|
||||||
|
f"{option_message}."
|
||||||
|
)
|
||||||
|
option_values.append(numeric_option)
|
||||||
|
duplicate_option_values = sorted(
|
||||||
|
{value for value in option_values if option_values.count(value) > 1}
|
||||||
|
)
|
||||||
|
if duplicate_option_values:
|
||||||
|
duplicates = ", ".join(f"{value:g}" for value in duplicate_option_values)
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' contains duplicate option values: "
|
||||||
|
f"{duplicates}."
|
||||||
|
)
|
||||||
|
if not isinstance(parameter.visible_when, tuple):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' visible_when must use a tuple."
|
||||||
|
)
|
||||||
|
for condition in parameter.visible_when:
|
||||||
|
if not isinstance(condition, ParameterCondition):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' visible_when entries must use "
|
||||||
|
"ParameterCondition."
|
||||||
|
)
|
||||||
|
_validate_member_id(
|
||||||
|
condition.parameter,
|
||||||
|
field=(
|
||||||
|
f"{field} '{parameter.name}' visibility condition parameter"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not isinstance(condition.values, tuple) or not condition.values:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' visibility condition values "
|
||||||
|
"must use a non-empty tuple."
|
||||||
|
)
|
||||||
|
numeric_condition_values: list[float] = []
|
||||||
|
for value in condition.values:
|
||||||
|
if (
|
||||||
|
isinstance(value, bool)
|
||||||
|
or not isinstance(value, (int, float))
|
||||||
|
or not isfinite(value)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' visibility condition values "
|
||||||
|
"must be finite numbers."
|
||||||
|
)
|
||||||
|
numeric_condition_values.append(float(value))
|
||||||
|
duplicate_condition_values = sorted(
|
||||||
|
{
|
||||||
|
value
|
||||||
|
for value in numeric_condition_values
|
||||||
|
if numeric_condition_values.count(value) > 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if duplicate_condition_values:
|
||||||
|
duplicates = ", ".join(
|
||||||
|
f"{value:g}" for value in duplicate_condition_values
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' visibility condition for "
|
||||||
|
f"'{condition.parameter}' contains duplicate values: "
|
||||||
|
f"{duplicates}."
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
parameter.minimum is not None
|
parameter.minimum is not None
|
||||||
and parameter.maximum is not None
|
and parameter.maximum is not None
|
||||||
@@ -448,6 +680,89 @@ def _validate_parameter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_parameter_visibility(
|
||||||
|
parameters: tuple[ParameterDefinition, ...],
|
||||||
|
*,
|
||||||
|
model_type: str,
|
||||||
|
) -> None:
|
||||||
|
"""Validate cross-parameter references used by catalog visibility rules."""
|
||||||
|
|
||||||
|
parameter_by_name = {parameter.name: parameter for parameter in parameters}
|
||||||
|
dependencies: dict[str, tuple[str, ...]] = {}
|
||||||
|
|
||||||
|
for parameter in parameters:
|
||||||
|
controllers: list[str] = []
|
||||||
|
for condition in parameter.visible_when:
|
||||||
|
controller_name = condition.parameter
|
||||||
|
controller = parameter_by_name.get(controller_name)
|
||||||
|
if controller is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' parameter '{parameter.name}' "
|
||||||
|
"visibility condition references unknown parameter "
|
||||||
|
f"'{controller_name}'."
|
||||||
|
)
|
||||||
|
if controller_name == parameter.name:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' parameter '{parameter.name}' "
|
||||||
|
"visibility condition cannot reference itself."
|
||||||
|
)
|
||||||
|
if controller_name in controllers:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' parameter '{parameter.name}' "
|
||||||
|
"contains duplicate visibility controller "
|
||||||
|
f"'{controller_name}'."
|
||||||
|
)
|
||||||
|
if not controller.options:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' parameter '{parameter.name}' "
|
||||||
|
f"visibility controller '{controller_name}' must declare "
|
||||||
|
"options."
|
||||||
|
)
|
||||||
|
controller_values = {
|
||||||
|
float(option.value) for option in controller.options
|
||||||
|
}
|
||||||
|
unsupported_values = sorted(
|
||||||
|
{
|
||||||
|
float(value)
|
||||||
|
for value in condition.values
|
||||||
|
if float(value) not in controller_values
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if unsupported_values:
|
||||||
|
values = ", ".join(f"{value:g}" for value in unsupported_values)
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' parameter '{parameter.name}' "
|
||||||
|
f"visibility condition values for '{controller_name}' "
|
||||||
|
f"must belong to its options; unsupported: {values}."
|
||||||
|
)
|
||||||
|
controllers.append(controller_name)
|
||||||
|
dependencies[parameter.name] = tuple(controllers)
|
||||||
|
|
||||||
|
states: dict[str, int] = {}
|
||||||
|
stack: list[str] = []
|
||||||
|
|
||||||
|
def visit(parameter_name: str) -> None:
|
||||||
|
state = states.get(parameter_name, 0)
|
||||||
|
if state == 2:
|
||||||
|
return
|
||||||
|
if state == 1:
|
||||||
|
cycle_start = stack.index(parameter_name)
|
||||||
|
cycle = stack[cycle_start:] + [parameter_name]
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' parameter visibility dependencies "
|
||||||
|
f"contain a cycle: {' -> '.join(cycle)}."
|
||||||
|
)
|
||||||
|
states[parameter_name] = 1
|
||||||
|
stack.append(parameter_name)
|
||||||
|
for controller_name in dependencies[parameter_name]:
|
||||||
|
visit(controller_name)
|
||||||
|
stack.pop()
|
||||||
|
states[parameter_name] = 2
|
||||||
|
|
||||||
|
for parameter in parameters:
|
||||||
|
visit(parameter.name)
|
||||||
|
|
||||||
|
|
||||||
def _validate_result_variable(
|
def _validate_result_variable(
|
||||||
variable: ResultVariableDefinition,
|
variable: ResultVariableDefinition,
|
||||||
*,
|
*,
|
||||||
@@ -532,6 +847,11 @@ def validate_component_model_class(
|
|||||||
field=f"Component '{model_type}' display symbol",
|
field=f"Component '{model_type}' display symbol",
|
||||||
)
|
)
|
||||||
_validate_order(display.order, field=f"Component '{model_type}' display order")
|
_validate_order(display.order, field=f"Component '{model_type}' display order")
|
||||||
|
if display.role not in {None, "amesimGasMediumDefinition"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' DISPLAY uses unsupported role "
|
||||||
|
f"'{display.role}'."
|
||||||
|
)
|
||||||
if not isinstance(display.ports, tuple):
|
if not isinstance(display.ports, tuple):
|
||||||
raise ValueError(f"Component '{model_type}' DISPLAY ports must be a tuple.")
|
raise ValueError(f"Component '{model_type}' DISPLAY ports must be a tuple.")
|
||||||
if display.library_id != library.id:
|
if display.library_id != library.id:
|
||||||
@@ -591,6 +911,12 @@ def validate_component_model_class(
|
|||||||
parameter_names,
|
parameter_names,
|
||||||
field=f"Component '{model_type}' parameters",
|
field=f"Component '{model_type}' parameters",
|
||||||
)
|
)
|
||||||
|
_validate_parameter_groups(
|
||||||
|
display.parameter_groups,
|
||||||
|
model_type=model_type,
|
||||||
|
parameter_names=parameter_names,
|
||||||
|
)
|
||||||
|
_validate_parameter_visibility(parameters, model_type=model_type)
|
||||||
_validate_unique_names(
|
_validate_unique_names(
|
||||||
result_names,
|
result_names,
|
||||||
field=f"Component '{model_type}' result variables",
|
field=f"Component '{model_type}' result variables",
|
||||||
|
|||||||
File renamed without changes.
@@ -0,0 +1,263 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
|
||||||
|
from app.simulation.reporting.amesim_results import (
|
||||||
|
AmesimResults,
|
||||||
|
load_test_mql_amesim_results,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Pnl0002ReplayPaths:
|
||||||
|
"""AMESim data paths needed to replay one PNL0002 resistance."""
|
||||||
|
|
||||||
|
center_pressure: str
|
||||||
|
center_temperature: str
|
||||||
|
port_1_pressure: str
|
||||||
|
port_1_temperature: str
|
||||||
|
port_1_mass_flow: str
|
||||||
|
port_2_pressure: str
|
||||||
|
port_2_temperature: str
|
||||||
|
port_2_mass_flow: str
|
||||||
|
reynolds: str
|
||||||
|
friction_factor: str
|
||||||
|
|
||||||
|
|
||||||
|
PNL83_REPLAY_PATHS = Pnl0002ReplayPaths(
|
||||||
|
center_pressure="pctr@pneumatic_83",
|
||||||
|
center_temperature="tctr@pneumatic_83",
|
||||||
|
port_1_pressure="press1@pnnode4_16",
|
||||||
|
port_1_temperature="temp1@pnnode4_16",
|
||||||
|
port_1_mass_flow="dm1@pneumatic_83",
|
||||||
|
port_2_pressure="press3@pnnode4_17",
|
||||||
|
port_2_temperature="temp3@pnnode4_17",
|
||||||
|
port_2_mass_flow="dm2@pneumatic_83",
|
||||||
|
reynolds="re@pneumatic_83",
|
||||||
|
friction_factor="ff@pneumatic_83",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_series(
|
||||||
|
results: AmesimResults,
|
||||||
|
path: str,
|
||||||
|
) -> tuple[float, ...]:
|
||||||
|
try:
|
||||||
|
values = results.series(path)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"AMESim replay variable is not saved: {path}") from exc
|
||||||
|
if len(values) != len(results.times):
|
||||||
|
raise ValueError(f"AMESim replay variable has an invalid length: {path}")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _metric_summary(
|
||||||
|
rows: list[dict[str, float]],
|
||||||
|
key: str,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
values = [float(row[key]) for row in rows]
|
||||||
|
max_index = max(range(len(values)), key=lambda index: abs(values[index]))
|
||||||
|
return {
|
||||||
|
"maxAbs": abs(values[max_index]),
|
||||||
|
"maxAbsTime": rows[max_index]["time"],
|
||||||
|
"finalSigned": values[-1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def replay_pnl0002_amesim_states(
|
||||||
|
pipe: AmesimPnl0002,
|
||||||
|
results: AmesimResults,
|
||||||
|
paths: Pnl0002ReplayPaths,
|
||||||
|
*,
|
||||||
|
amesim_mass_flow_scale: float = -1.0e-3,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Replay saved AMESim states through current PNL0002 flow functions.
|
||||||
|
|
||||||
|
This is a calibration-only, no-integration calculation. It does not write
|
||||||
|
states into the pipe or alter the production simulation path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
series_by_field = {
|
||||||
|
field: _required_series(results, getattr(paths, field))
|
||||||
|
for field in paths.__dataclass_fields__
|
||||||
|
}
|
||||||
|
rows: list[dict[str, float]] = []
|
||||||
|
for index, time_s in enumerate(results.times):
|
||||||
|
center_pressure = series_by_field["center_pressure"][index]
|
||||||
|
center_temperature = series_by_field["center_temperature"][index]
|
||||||
|
port_1_pressure = series_by_field["port_1_pressure"][index]
|
||||||
|
port_2_pressure = series_by_field["port_2_pressure"][index]
|
||||||
|
observed_flow_1 = (
|
||||||
|
amesim_mass_flow_scale
|
||||||
|
* series_by_field["port_1_mass_flow"][index]
|
||||||
|
)
|
||||||
|
observed_flow_2 = (
|
||||||
|
amesim_mass_flow_scale
|
||||||
|
* series_by_field["port_2_mass_flow"][index]
|
||||||
|
)
|
||||||
|
upstream_temperature_1 = (
|
||||||
|
series_by_field["port_1_temperature"][index]
|
||||||
|
if observed_flow_1 >= 0.0
|
||||||
|
else center_temperature
|
||||||
|
)
|
||||||
|
upstream_temperature_2 = (
|
||||||
|
series_by_field["port_2_temperature"][index]
|
||||||
|
if observed_flow_2 >= 0.0
|
||||||
|
else center_temperature
|
||||||
|
)
|
||||||
|
predicted_flow_1 = pipe.mass_flow(
|
||||||
|
port_1_pressure,
|
||||||
|
center_pressure,
|
||||||
|
upstream_temperature_1,
|
||||||
|
)
|
||||||
|
predicted_flow_2 = pipe.mass_flow(
|
||||||
|
port_2_pressure,
|
||||||
|
center_pressure,
|
||||||
|
upstream_temperature_2,
|
||||||
|
)
|
||||||
|
reynolds_1 = pipe.reynolds_number(
|
||||||
|
observed_flow_1,
|
||||||
|
upstream_temperature_1,
|
||||||
|
)
|
||||||
|
reynolds_2 = pipe.reynolds_number(
|
||||||
|
observed_flow_2,
|
||||||
|
upstream_temperature_2,
|
||||||
|
)
|
||||||
|
friction_1 = pipe.friction_factor(reynolds_1)
|
||||||
|
friction_2 = pipe.friction_factor(reynolds_2)
|
||||||
|
replay_reynolds = 0.5 * (reynolds_1 + reynolds_2)
|
||||||
|
replay_friction = 0.5 * (friction_1 + friction_2)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"time": float(time_s),
|
||||||
|
"centerPressure": center_pressure,
|
||||||
|
"centerTemperature": center_temperature,
|
||||||
|
"port1Pressure": port_1_pressure,
|
||||||
|
"port2Pressure": port_2_pressure,
|
||||||
|
"observedPort1MassFlow": observed_flow_1,
|
||||||
|
"observedPort2MassFlow": observed_flow_2,
|
||||||
|
"predictedPort1MassFlow": predicted_flow_1,
|
||||||
|
"predictedPort2MassFlow": predicted_flow_2,
|
||||||
|
"port1MassFlowError": predicted_flow_1 - observed_flow_1,
|
||||||
|
"port2MassFlowError": predicted_flow_2 - observed_flow_2,
|
||||||
|
"amesimReynolds": series_by_field["reynolds"][index],
|
||||||
|
"replayReynolds": replay_reynolds,
|
||||||
|
"reynoldsError": (
|
||||||
|
replay_reynolds - series_by_field["reynolds"][index]
|
||||||
|
),
|
||||||
|
"amesimFrictionFactor": series_by_field["friction_factor"][index],
|
||||||
|
"replayFrictionFactor": replay_friction,
|
||||||
|
"frictionFactorError": (
|
||||||
|
replay_friction
|
||||||
|
- series_by_field["friction_factor"][index]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
metric_keys = (
|
||||||
|
"port1MassFlowError",
|
||||||
|
"port2MassFlowError",
|
||||||
|
"reynoldsError",
|
||||||
|
"frictionFactorError",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mode": "amesim-state-replay-no-integration",
|
||||||
|
"component": pipe.name,
|
||||||
|
"pointCount": len(rows),
|
||||||
|
"massFlowScale": amesim_mass_flow_scale,
|
||||||
|
"paths": {
|
||||||
|
field: getattr(paths, field)
|
||||||
|
for field in paths.__dataclass_fields__
|
||||||
|
},
|
||||||
|
"parameters": dict(pipe.parameter_values),
|
||||||
|
"summary": {
|
||||||
|
key: _metric_summary(rows, key)
|
||||||
|
for key in metric_keys
|
||||||
|
},
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_project_pipe(
|
||||||
|
project_path: Path,
|
||||||
|
component_name: str,
|
||||||
|
) -> AmesimPnl0002:
|
||||||
|
from app.main import (
|
||||||
|
ReactFlowProjectPayload,
|
||||||
|
_compile_xml_document_or_422,
|
||||||
|
_validated_xml_document_or_422,
|
||||||
|
build_reactflow_system_xml,
|
||||||
|
validate_system_xml_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = ReactFlowProjectPayload.model_validate_json(
|
||||||
|
project_path.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
xml_bytes = build_reactflow_system_xml(payload)
|
||||||
|
document = _validated_xml_document_or_422(
|
||||||
|
validate_system_xml_document(xml_bytes)
|
||||||
|
)
|
||||||
|
_project, network = _compile_xml_document_or_422(document)
|
||||||
|
try:
|
||||||
|
component = network.components[component_name]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"Project component does not exist: {component_name}") from exc
|
||||||
|
if not isinstance(component, AmesimPnl0002):
|
||||||
|
raise ValueError(f"Project component is not PNL0002: {component_name}")
|
||||||
|
return component
|
||||||
|
|
||||||
|
|
||||||
|
def _paths_from_arguments(arguments: argparse.Namespace) -> Pnl0002ReplayPaths:
|
||||||
|
values: Mapping[str, str] = {
|
||||||
|
field: getattr(arguments, field)
|
||||||
|
for field in PNL83_REPLAY_PATHS.__dataclass_fields__
|
||||||
|
}
|
||||||
|
return Pnl0002ReplayPaths(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Replay saved AMESim p/T/m_flow through a PNL0002 model without integration."
|
||||||
|
)
|
||||||
|
parser.add_argument("project", type=Path)
|
||||||
|
parser.add_argument("amesim_archive", type=Path)
|
||||||
|
parser.add_argument("output", type=Path)
|
||||||
|
parser.add_argument("--component", default="pneumatic_83")
|
||||||
|
parser.add_argument("--mass-flow-scale", type=float, default=-1.0e-3)
|
||||||
|
for field in PNL83_REPLAY_PATHS.__dataclass_fields__:
|
||||||
|
parser.add_argument(
|
||||||
|
"--" + field.replace("_", "-"),
|
||||||
|
dest=field,
|
||||||
|
default=getattr(PNL83_REPLAY_PATHS, field),
|
||||||
|
)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
|
||||||
|
pipe = _compile_project_pipe(arguments.project, arguments.component)
|
||||||
|
results = load_test_mql_amesim_results(arguments.amesim_archive)
|
||||||
|
report = replay_pnl0002_amesim_states(
|
||||||
|
pipe,
|
||||||
|
results,
|
||||||
|
_paths_from_arguments(arguments),
|
||||||
|
amesim_mass_flow_scale=arguments.mass_flow_scale,
|
||||||
|
)
|
||||||
|
arguments.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
arguments.output.write_text(
|
||||||
|
json.dumps(report, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(json.dumps({key: report[key] for key in (
|
||||||
|
"mode",
|
||||||
|
"component",
|
||||||
|
"pointCount",
|
||||||
|
"parameters",
|
||||||
|
"summary",
|
||||||
|
)}, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+3
-3
@@ -2,13 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from PythonModels.reporting.amesim_results import AmesimResults
|
from app.simulation.reporting.amesim_results import AmesimResults
|
||||||
from PythonModels.reporting.test_mql_variables import (
|
from app.simulation.reporting.test_mql_variables import (
|
||||||
TestMqlVariableBinding,
|
TestMqlVariableBinding,
|
||||||
TestMqlVariableCatalog,
|
TestMqlVariableCatalog,
|
||||||
build_test_mql_variable_catalog,
|
build_test_mql_variable_catalog,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_pneumatic import (
|
from app.simulation.examples.test_mql.pneumatic import (
|
||||||
TestMqlPneumaticAssembly,
|
TestMqlPneumaticAssembly,
|
||||||
build_test_mql_pneumatic_assembly,
|
build_test_mql_pneumatic_assembly,
|
||||||
)
|
)
|
||||||
+41
-4
@@ -5,7 +5,7 @@ import csv
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PythonModels.reporting.amesim_results import AmesimResults
|
from app.simulation.reporting.amesim_results import AmesimResults
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TEST_MQL_ALIGNMENT_PATHS = (
|
DEFAULT_TEST_MQL_ALIGNMENT_PATHS = (
|
||||||
@@ -23,6 +23,8 @@ class TestMqlComparisonMetric:
|
|||||||
max_abs_error: float
|
max_abs_error: float
|
||||||
mean_abs_error: float
|
mean_abs_error: float
|
||||||
max_rel_error: float
|
max_rel_error: float
|
||||||
|
undefined_rel_error_count: int
|
||||||
|
near_zero_baseline_count: int
|
||||||
final_abs_error: float
|
final_abs_error: float
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +46,10 @@ class TestMqlComparisonResult:
|
|||||||
def max_rel_error(self) -> float:
|
def max_rel_error(self) -> float:
|
||||||
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
|
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def undefined_rel_error_count(self) -> int:
|
||||||
|
return sum(metric.undefined_rel_error_count for metric in self.metrics)
|
||||||
|
|
||||||
|
|
||||||
class TestMqlComparisonError(ValueError):
|
class TestMqlComparisonError(ValueError):
|
||||||
"""Raised when Python and AMESim series cannot be aligned."""
|
"""Raised when Python and AMESim series cannot be aligned."""
|
||||||
@@ -57,6 +63,16 @@ def compare_test_mql_series(
|
|||||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||||
relative_floor: float = 1.0e-12,
|
relative_floor: float = 1.0e-12,
|
||||||
) -> TestMqlComparisonResult:
|
) -> TestMqlComparisonResult:
|
||||||
|
"""Compare current values directly with AMESim simulation values.
|
||||||
|
|
||||||
|
``relative_floor`` only identifies near-zero baselines for reporting. It is
|
||||||
|
never substituted into the relative-error denominator. An exact zero
|
||||||
|
AMESim baseline has undefined relative error and is counted separately;
|
||||||
|
absolute error remains available for judgement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if relative_floor < 0.0:
|
||||||
|
raise TestMqlComparisonError("relative_floor cannot be negative.")
|
||||||
_validate_time_axis(python_times)
|
_validate_time_axis(python_times)
|
||||||
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
|
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
|
||||||
metrics = []
|
metrics = []
|
||||||
@@ -70,11 +86,18 @@ def compare_test_mql_series(
|
|||||||
amesim_values = amesim_results.series(data_path)
|
amesim_values = amesim_results.series(data_path)
|
||||||
abs_errors = []
|
abs_errors = []
|
||||||
rel_errors = []
|
rel_errors = []
|
||||||
|
undefined_rel_error_count = 0
|
||||||
|
near_zero_baseline_count = 0
|
||||||
for time_value, python_value in zip(python_times, python_values):
|
for time_value, python_value in zip(python_times, python_values):
|
||||||
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
|
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
|
||||||
abs_error = abs(python_value - amesim_value)
|
abs_error = abs(python_value - amesim_value)
|
||||||
abs_errors.append(abs_error)
|
abs_errors.append(abs_error)
|
||||||
rel_errors.append(abs_error / max(abs(amesim_value), relative_floor))
|
if abs(amesim_value) <= relative_floor:
|
||||||
|
near_zero_baseline_count += 1
|
||||||
|
if amesim_value == 0.0:
|
||||||
|
undefined_rel_error_count += 1
|
||||||
|
else:
|
||||||
|
rel_errors.append(abs_error / abs(amesim_value))
|
||||||
final_amesim_value = interpolate_series_value(
|
final_amesim_value = interpolate_series_value(
|
||||||
amesim_results.times,
|
amesim_results.times,
|
||||||
amesim_values,
|
amesim_values,
|
||||||
@@ -87,6 +110,8 @@ def compare_test_mql_series(
|
|||||||
max_abs_error=max(abs_errors, default=0.0),
|
max_abs_error=max(abs_errors, default=0.0),
|
||||||
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
|
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
|
||||||
max_rel_error=max(rel_errors, default=0.0),
|
max_rel_error=max(rel_errors, default=0.0),
|
||||||
|
undefined_rel_error_count=undefined_rel_error_count,
|
||||||
|
near_zero_baseline_count=near_zero_baseline_count,
|
||||||
final_abs_error=abs(python_values[-1] - final_amesim_value),
|
final_abs_error=abs(python_values[-1] - final_amesim_value),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -153,8 +178,19 @@ def write_test_mql_comparison_csv(
|
|||||||
float(time_value),
|
float(time_value),
|
||||||
)
|
)
|
||||||
abs_error = abs(python_value - amesim_value)
|
abs_error = abs(python_value - amesim_value)
|
||||||
rel_error = abs_error / max(abs(amesim_value), 1.0e-12)
|
rel_error = (
|
||||||
row.extend([python_value, amesim_value, abs_error, rel_error])
|
None
|
||||||
|
if amesim_value == 0.0
|
||||||
|
else abs_error / abs(amesim_value)
|
||||||
|
)
|
||||||
|
row.extend(
|
||||||
|
[
|
||||||
|
python_value,
|
||||||
|
amesim_value,
|
||||||
|
abs_error,
|
||||||
|
"" if rel_error is None else rel_error,
|
||||||
|
]
|
||||||
|
)
|
||||||
writer.writerow(row)
|
writer.writerow(row)
|
||||||
|
|
||||||
summary_lines = [
|
summary_lines = [
|
||||||
@@ -163,6 +199,7 @@ def write_test_mql_comparison_csv(
|
|||||||
f"max_abs_error={metric.max_abs_error:.12g}, "
|
f"max_abs_error={metric.max_abs_error:.12g}, "
|
||||||
f"mean_abs_error={metric.mean_abs_error:.12g}, "
|
f"mean_abs_error={metric.mean_abs_error:.12g}, "
|
||||||
f"max_rel_error={metric.max_rel_error:.12%}, "
|
f"max_rel_error={metric.max_rel_error:.12%}, "
|
||||||
|
f"undefined_rel_error_count={metric.undefined_rel_error_count}, "
|
||||||
f"final_abs_error={metric.final_abs_error:.12g}"
|
f"final_abs_error={metric.final_abs_error:.12g}"
|
||||||
)
|
)
|
||||||
for metric in comparison.metrics
|
for metric in comparison.metrics
|
||||||
+3
-3
@@ -2,13 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from PythonModels.reporting.amesim_results import AmesimResults
|
from app.simulation.reporting.amesim_results import AmesimResults
|
||||||
from PythonModels.reporting.test_mql_variables import (
|
from app.simulation.reporting.test_mql_variables import (
|
||||||
TestMqlVariableBinding,
|
TestMqlVariableBinding,
|
||||||
TestMqlVariableCatalog,
|
TestMqlVariableCatalog,
|
||||||
build_test_mql_variable_catalog,
|
build_test_mql_variable_catalog,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.test_mql_lines import (
|
from app.simulation.examples.test_mql.lines import (
|
||||||
TestMqlLineAssembly,
|
TestMqlLineAssembly,
|
||||||
build_test_mql_line_assembly,
|
build_test_mql_line_assembly,
|
||||||
)
|
)
|
||||||
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user