Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f3f7d00ec | ||
|
|
7611f13208 | ||
|
|
1aac220084 | ||
|
|
2b07d996cf | ||
|
|
6fc9afe41d | ||
|
|
b6c22a54f8 | ||
|
|
44b6ea74ab | ||
|
|
22579e51c9 | ||
|
|
151e6e4b97 | ||
|
|
aa4951b14e | ||
|
|
3bc4be3c06 | ||
|
|
808c484f5b | ||
|
|
5d5a2e1843 | ||
|
|
91bd9fb252 | ||
|
|
0dcb465d84 | ||
|
|
743663e3a6 | ||
|
|
3b38f73fe0 | ||
|
|
48da6be21c | ||
|
|
ce353d2dd2 | ||
|
|
03b86f52ba | ||
|
|
ce62079335 | ||
|
|
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 |
No files matched your search
@@ -0,0 +1,14 @@
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.sh text eol=lf
|
||||
|
||||
# Regression manifests hash these files as raw bytes. Keep their checkout
|
||||
# representation identical on Windows and Linux so hashes remain portable.
|
||||
tests/baselines/simulation/test_mql_full_branches/sources/test_mql-full-branches-01-04.xml text eol=lf
|
||||
tests/baselines/simulation/**/*.json text eol=lf
|
||||
|
||||
tests/baselines/simulation/**/sources/* text eol=lf
|
||||
|
||||
# Browser projects and relocated native references.
|
||||
tests/data/*.json text eol=lf
|
||||
tests/baselines/native/*.json text eol=lf
|
||||
@@ -0,0 +1,139 @@
|
||||
name: Native backend regression
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "app/**"
|
||||
- "native/**"
|
||||
- "schemas/**"
|
||||
- "tests/**"
|
||||
- "requirements*.txt"
|
||||
- "constraints/**"
|
||||
- ".python-version"
|
||||
- ".gitattributes"
|
||||
- ".github/workflows/solver-regression.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "app/**"
|
||||
- "native/**"
|
||||
- "schemas/**"
|
||||
- "tests/**"
|
||||
- "requirements*.txt"
|
||||
- "constraints/**"
|
||||
- ".python-version"
|
||||
- ".gitattributes"
|
||||
- ".github/workflows/solver-regression.yml"
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: solver-regression-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
contracts:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: .python-version
|
||||
cache: pip
|
||||
cache-dependency-path: constraints/python312-linux-x86_64.lock
|
||||
- name: Install backend runtime without numerical Python packages
|
||||
run: |
|
||||
python -m pip install -r constraints/python312-linux-x86_64.lock
|
||||
python -m pip check
|
||||
- name: Validate portable metadata and XML contracts
|
||||
env:
|
||||
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
|
||||
run: |
|
||||
python -W error::ResourceWarning -m unittest \
|
||||
tests.test_dependency_constraints \
|
||||
tests.test_regression_fixture_line_endings \
|
||||
tests.test_component_catalog \
|
||||
tests.test_component_metadata \
|
||||
tests.test_component_registry \
|
||||
tests.test_port_computation \
|
||||
tests.test_native_schedule.DependencyGraphTests \
|
||||
tests.test_medium_reference_contract \
|
||||
tests.test_system_xml_v3 \
|
||||
tests.test_native_only_backend
|
||||
|
||||
native-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: .python-version
|
||||
- name: Install native dependencies with the existing Linux setup
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/python -m pip install -r requirements-test.txt
|
||||
bash bat/setup-native-linux.sh
|
||||
- name: Check startup diagnostics, native cache and solver control on Linux
|
||||
env:
|
||||
SIMULATION_NATIVE_REQUIRE_TOOLCHAIN: "1"
|
||||
run: |
|
||||
.venv/bin/python -W error::ResourceWarning -m unittest \
|
||||
tests.test_simulation_warmup tests.test_native_cache_platform \
|
||||
tests.test_native_solver_control tests.test_native_worker_control \
|
||||
tests.test_result_storage tests.test_native_sample_storage tests.test_native_result_transport -v
|
||||
|
||||
native-windows:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
python-version: "3.12"
|
||||
activate-environment: simulation-native
|
||||
auto-activate-base: false
|
||||
- name: Install native compiler and SUNDIALS
|
||||
run: |
|
||||
conda install --yes -c conda-forge sundials=7.4.0 m2w64-gcc
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
"SUNDIALS_ROOT=$env:CONDA_PREFIX\Library" >> $env:GITHUB_ENV
|
||||
"SIMULATION_NATIVE_CC=$env:CONDA_PREFIX\Library\mingw-w64\bin\gcc.exe" >> $env:GITHUB_ENV
|
||||
python -m pip install -r requirements-test.txt
|
||||
python -m pip check
|
||||
- name: Verify native cache on the Windows runtime
|
||||
env:
|
||||
SIMULATION_NATIVE_REQUIRE_TOOLCHAIN: "1"
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force test/ci-native-cache | Out-Null
|
||||
python -W error::ResourceWarning -m unittest tests.test_native_cache_storage tests.test_native_cache_platform -v > test/ci-native-cache/regression.log 2>&1
|
||||
$cacheRegressionExit = $LASTEXITCODE
|
||||
Get-Content test/ci-native-cache/regression.log
|
||||
if ($cacheRegressionExit -ne 0) { exit $cacheRegressionExit }
|
||||
- name: Run catalog, numerical, API and schema regression
|
||||
run: |
|
||||
python -c "from app.simulation.native_codegen.build import toolchain; print(toolchain())"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
python -W error::ResourceWarning -m unittest discover -s tests
|
||||
- name: Complete the 10 second skill fixture in native RK45
|
||||
run: |
|
||||
python -m app.simulation.native_codegen tests/fixtures/native-skill-test.xml --output-dir test/ci-skill --method RK45 --max-step 0.001 --rtol 1e-7 --runs 1 --solve-only
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: native-skill-regression
|
||||
path: test/ci-skill/summary.json
|
||||
if-no-files-found: warn
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: native-windows-cache-regression
|
||||
path: test/ci-native-cache/regression.log
|
||||
if-no-files-found: warn
|
||||
@@ -12,7 +12,16 @@ htmlcov/
|
||||
# Local virtual environments
|
||||
.venv/
|
||||
.venv-win/
|
||||
|
||||
# Local Linux toolchain (downloaded for the startup scripts)
|
||||
.tools/node-*-linux-x64/
|
||||
.tools/node-*-win-x64/
|
||||
|
||||
# Local benchmark archives, generated executables and comparison outputs
|
||||
/test/
|
||||
|
||||
app/data/
|
||||
/simresults/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
@@ -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,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,168 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import pi
|
||||
|
||||
|
||||
MM_TO_M = 1.0e-3
|
||||
M_TO_MM = 1.0e3
|
||||
M3_TO_CM3 = 1.0e6
|
||||
M3_PER_S_TO_L_PER_MIN = 60_000.0
|
||||
|
||||
|
||||
def circular_area(diameter_m: float) -> float:
|
||||
if diameter_m < 0.0:
|
||||
raise ValueError("diameter_m must be non-negative.")
|
||||
return pi * diameter_m * diameter_m / 4.0
|
||||
|
||||
|
||||
def mm_to_m(value: float) -> float:
|
||||
return value * MM_TO_M
|
||||
|
||||
|
||||
def m_to_mm(value: float) -> float:
|
||||
return value * M_TO_MM
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimPistonGeometry:
|
||||
"""Geometry relations used by AMESim PNRP17 pneumatic piston variables."""
|
||||
|
||||
piston_diameter_m: float
|
||||
rod_diameter_m: float = 0.0
|
||||
zero_length_m: float = 0.0
|
||||
|
||||
@property
|
||||
def piston_area_m2(self) -> float:
|
||||
return circular_area(self.piston_diameter_m)
|
||||
|
||||
@property
|
||||
def rod_area_m2(self) -> float:
|
||||
return circular_area(self.rod_diameter_m)
|
||||
|
||||
@property
|
||||
def annulus_area_m2(self) -> float:
|
||||
return self.piston_area_m2 - self.rod_area_m2
|
||||
|
||||
def chamber_length_m(self, port4_displacement_m: float, port5_displacement_m: float) -> float:
|
||||
return self.zero_length_m + port5_displacement_m - port4_displacement_m
|
||||
|
||||
def chamber_length_mm(self, port4_displacement_m: float, port5_displacement_m: float) -> float:
|
||||
return m_to_mm(self.chamber_length_m(port4_displacement_m, port5_displacement_m))
|
||||
|
||||
@property
|
||||
def chamber_area_m2(self) -> float:
|
||||
return self.annulus_area_m2
|
||||
|
||||
def chamber_volume_m3(self, port4_displacement_m: float, port5_displacement_m: float) -> float:
|
||||
return self.chamber_area_m2 * self.chamber_length_m(
|
||||
port4_displacement_m,
|
||||
port5_displacement_m,
|
||||
)
|
||||
|
||||
def chamber_volume_cm3(self, port4_displacement_m: float, port5_displacement_m: float) -> float:
|
||||
return self.chamber_volume_m3(port4_displacement_m, port5_displacement_m) * M3_TO_CM3
|
||||
|
||||
def chamber_volume_rate_m3_s(self, port4_velocity_m_s: float, port5_velocity_m_s: float) -> float:
|
||||
return self.chamber_area_m2 * (port5_velocity_m_s - port4_velocity_m_s)
|
||||
|
||||
def chamber_volume_rate_l_min(self, port4_velocity_m_s: float, port5_velocity_m_s: float) -> float:
|
||||
return self.chamber_volume_rate_m3_s(
|
||||
port4_velocity_m_s,
|
||||
port5_velocity_m_s,
|
||||
) * M3_PER_S_TO_L_PER_MIN
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimElasticEndstop:
|
||||
"""Contact force part of AMESim LSTP00A elastic endstop."""
|
||||
|
||||
contact_stiffness_n_per_m: float
|
||||
contact_damping_n_per_m_per_s: float = 0.0
|
||||
gap0_m: float = 0.0
|
||||
|
||||
def penetration_m_from_gap_mm(self, gap_mm: float) -> float:
|
||||
return max(-(mm_to_m(gap_mm) - self.gap0_m), 0.0)
|
||||
|
||||
def static_contact_force(self, gap_mm: float) -> float:
|
||||
return self.contact_stiffness_n_per_m * self.penetration_m_from_gap_mm(gap_mm)
|
||||
|
||||
def contact_force(self, gap_mm: float, penetration_velocity_m_s: float = 0.0) -> float:
|
||||
if self.penetration_m_from_gap_mm(gap_mm) <= 0.0:
|
||||
return 0.0
|
||||
damping_force = self.contact_damping_n_per_m_per_s * penetration_velocity_m_s
|
||||
return max(self.static_contact_force(gap_mm) + damping_force, 0.0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimMassFrictionEndstops:
|
||||
"""Parameter and observable helpers for AMESim MECMAS21 translation masses."""
|
||||
|
||||
mass_kg: float
|
||||
lower_limit_m: float
|
||||
upper_limit_m: float
|
||||
lower_stiffness_n_per_m: float
|
||||
upper_stiffness_n_per_m: float
|
||||
lower_damping_n_per_m_per_s: float = 0.0
|
||||
upper_damping_n_per_m_per_s: float = 0.0
|
||||
viscous_friction_n_per_m_per_s: float = 0.0
|
||||
coulomb_friction_n: float = 0.0
|
||||
stiction_force_n: float = 0.0
|
||||
windage_n_per_m2_per_s2: float = 0.0
|
||||
|
||||
def lower_penetration_m(self, displacement_m: float) -> float:
|
||||
return max(self.lower_limit_m - displacement_m, 0.0)
|
||||
|
||||
def upper_penetration_m(self, displacement_m: float) -> float:
|
||||
return max(displacement_m - self.upper_limit_m, 0.0)
|
||||
|
||||
def lower_static_force_magnitude(self, displacement_m: float) -> float:
|
||||
return self.lower_stiffness_n_per_m * self.lower_penetration_m(displacement_m)
|
||||
|
||||
def upper_static_force_magnitude(self, displacement_m: float) -> float:
|
||||
return self.upper_stiffness_n_per_m * self.upper_penetration_m(displacement_m)
|
||||
|
||||
def viscous_friction_force(self, velocity_m_s: float) -> float:
|
||||
return -self.viscous_friction_n_per_m_per_s * velocity_m_s
|
||||
|
||||
def windage_force(self, velocity_m_s: float) -> float:
|
||||
return -self.windage_n_per_m2_per_s2 * velocity_m_s * abs(velocity_m_s)
|
||||
|
||||
def dry_friction_force(self, velocity_m_s: float) -> float:
|
||||
if velocity_m_s > 0.0:
|
||||
return -self.coulomb_friction_n
|
||||
if velocity_m_s < 0.0:
|
||||
return self.coulomb_friction_n
|
||||
return 0.0
|
||||
|
||||
def limit_contact_force(self, displacement_m: float, velocity_m_s: float) -> float:
|
||||
lower_force = self.lower_static_force_magnitude(displacement_m)
|
||||
if lower_force > 0.0:
|
||||
lower_force += max(-self.lower_damping_n_per_m_per_s * velocity_m_s, 0.0)
|
||||
|
||||
upper_force = self.upper_static_force_magnitude(displacement_m)
|
||||
if upper_force > 0.0:
|
||||
upper_force += max(self.upper_damping_n_per_m_per_s * velocity_m_s, 0.0)
|
||||
|
||||
return lower_force - upper_force
|
||||
|
||||
def derivatives(
|
||||
self,
|
||||
*,
|
||||
velocity_m_s: float,
|
||||
displacement_m: float,
|
||||
port_1_force_n: float = 0.0,
|
||||
port_2_force_n: float = 0.0,
|
||||
external_force_n: float = 0.0,
|
||||
) -> tuple[float, float]:
|
||||
total_force = (
|
||||
port_1_force_n
|
||||
+ port_2_force_n
|
||||
+ external_force_n
|
||||
+ self.viscous_friction_force(velocity_m_s)
|
||||
+ self.windage_force(velocity_m_s)
|
||||
+ self.dry_friction_force(velocity_m_s)
|
||||
+ self.limit_contact_force(displacement_m, velocity_m_s)
|
||||
)
|
||||
return total_force / self.mass_kg, velocity_m_s
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import pi, sqrt
|
||||
|
||||
from PythonModels.core.base import AlgebraicComponent, DynamicComponent
|
||||
from PythonModels.core.medium import ThermodynamicProperties
|
||||
from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||
from PythonModels.core.ports import PortState
|
||||
from PythonModels.core.state import VolumeState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimPneumaticGas:
|
||||
"""Caloric constants plus Peng-Robinson EOS for AMESim pneumatic components."""
|
||||
|
||||
fluid: PengRobinsonFluid = HELIUM_PR
|
||||
cp: float = 5193.0
|
||||
cv: float = 3116.0
|
||||
|
||||
@property
|
||||
def gamma(self) -> float:
|
||||
return self.cp / self.cv
|
||||
|
||||
@property
|
||||
def R_gas(self) -> float:
|
||||
return self.fluid.specific_gas_constant
|
||||
|
||||
def density(self, pressure: float, temperature: float) -> float:
|
||||
return self.fluid.density(pressure, temperature)
|
||||
|
||||
def pressure(self, density: float, temperature: float) -> float:
|
||||
return self.fluid.pressure_from_density(temperature, density)
|
||||
|
||||
def specific_internal_energy(self, temperature: float) -> float:
|
||||
return self.cv * temperature
|
||||
|
||||
def specific_enthalpy(self, temperature: float) -> float:
|
||||
return self.cp * temperature
|
||||
|
||||
def specific_reference_enthalpy(
|
||||
self,
|
||||
temperature: float,
|
||||
reference_temperature: float = 298.15,
|
||||
) -> float:
|
||||
return self.cp * (temperature - reference_temperature)
|
||||
|
||||
def reference_temperature_from_specific_enthalpy(
|
||||
self,
|
||||
specific_enthalpy: float,
|
||||
reference_temperature: float = 298.15,
|
||||
) -> float:
|
||||
if self.cp <= 0.0:
|
||||
raise ValueError("cp must be positive.")
|
||||
return reference_temperature + specific_enthalpy / self.cp
|
||||
|
||||
def pressure_reference_enthalpy(
|
||||
self,
|
||||
pressure: float,
|
||||
temperature: float,
|
||||
reference_pressure: float = 101_300.0,
|
||||
reference_temperature: float = 298.15,
|
||||
) -> float:
|
||||
return (
|
||||
self.specific_reference_enthalpy(temperature, reference_temperature)
|
||||
+ self.fluid.residual_specific_enthalpy(pressure, temperature)
|
||||
- self.fluid.residual_specific_enthalpy(
|
||||
reference_pressure,
|
||||
reference_temperature,
|
||||
)
|
||||
)
|
||||
|
||||
def pressure_transport_enthalpy(
|
||||
self,
|
||||
pressure: float,
|
||||
temperature: float,
|
||||
reference_pressure: float = 101_300.0,
|
||||
reference_temperature: float = 298.15,
|
||||
) -> float:
|
||||
"""Convert AMESim reference enthalpy to the absolute-energy state basis."""
|
||||
return (
|
||||
self.pressure_reference_enthalpy(
|
||||
pressure,
|
||||
temperature,
|
||||
reference_pressure,
|
||||
reference_temperature,
|
||||
)
|
||||
+ self.cp * reference_temperature
|
||||
)
|
||||
|
||||
def temperature_from_internal_energy(self, specific_internal_energy: float) -> float:
|
||||
if self.cv <= 0.0:
|
||||
raise ValueError("cv must be positive.")
|
||||
return specific_internal_energy / self.cv
|
||||
|
||||
|
||||
HELIUM_PNEUMATIC_GAS = AmesimPneumaticGas()
|
||||
|
||||
|
||||
def liters_to_m3(value: float) -> float:
|
||||
return value * 1.0e-3
|
||||
|
||||
|
||||
def m3_to_cm3(value: float) -> float:
|
||||
return value * 1.0e6
|
||||
|
||||
|
||||
def cm3_to_m3(value: float) -> float:
|
||||
return value * 1.0e-6
|
||||
|
||||
|
||||
def kg_to_g(value: float) -> float:
|
||||
return value * 1.0e3
|
||||
|
||||
|
||||
def mm2_to_m2(value: float) -> float:
|
||||
return value * 1.0e-6
|
||||
|
||||
|
||||
def diameter_mm_to_area_m2(diameter_mm: float) -> float:
|
||||
diameter_m = diameter_mm * 1.0e-3
|
||||
return pi * diameter_m * diameter_m / 4.0
|
||||
|
||||
|
||||
class AmesimPneumaticVolume(DynamicComponent):
|
||||
"""First-pass AMESim pneumatic control volume using helium PR pressure closure."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
volume: float,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
p0: float = 101_325.0,
|
||||
T0: float = 293.15,
|
||||
heat_transfer_coefficient: float = 0.0,
|
||||
heat_transfer_area: float = 0.0,
|
||||
external_temperature_k: float = 293.15,
|
||||
) -> None:
|
||||
if volume <= 0.0:
|
||||
raise ValueError("volume must be positive.")
|
||||
if heat_transfer_coefficient < 0.0:
|
||||
raise ValueError("heat_transfer_coefficient must be non-negative.")
|
||||
if heat_transfer_area < 0.0:
|
||||
raise ValueError("heat_transfer_area must be non-negative.")
|
||||
if external_temperature_k <= 0.0:
|
||||
raise ValueError("external_temperature_k must be positive.")
|
||||
super().__init__(name=name)
|
||||
self.volume = volume
|
||||
self.gas = gas
|
||||
self.heat_transfer_coefficient = heat_transfer_coefficient
|
||||
self.heat_transfer_area = heat_transfer_area
|
||||
self.external_temperature = external_temperature_k
|
||||
rho0 = gas.density(p0, T0)
|
||||
m0 = rho0 * volume
|
||||
U0 = m0 * gas.specific_internal_energy(T0)
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
self.port_a = PortState()
|
||||
self.port_b = PortState()
|
||||
|
||||
@classmethod
|
||||
def from_liters(
|
||||
cls,
|
||||
name: str,
|
||||
volume_liters: float,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
p0: float = 101_325.0,
|
||||
T0: float = 293.15,
|
||||
heat_transfer_coefficient: float = 0.0,
|
||||
heat_transfer_area: float = 0.0,
|
||||
external_temperature_k: float = 293.15,
|
||||
) -> "AmesimPneumaticVolume":
|
||||
return cls(
|
||||
name=name,
|
||||
volume=liters_to_m3(volume_liters),
|
||||
gas=gas,
|
||||
p0=p0,
|
||||
T0=T0,
|
||||
heat_transfer_coefficient=heat_transfer_coefficient,
|
||||
heat_transfer_area=heat_transfer_area,
|
||||
external_temperature_k=external_temperature_k,
|
||||
)
|
||||
|
||||
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 volume_cm3(self) -> float:
|
||||
return m3_to_cm3(self.volume)
|
||||
|
||||
def volume_rate_m3_s(self) -> float:
|
||||
return 0.0
|
||||
|
||||
def thermal_energy_flow_w(self, temperature_k: float | None = None) -> float:
|
||||
temperature = self.properties().T if temperature_k is None else temperature_k
|
||||
return (
|
||||
self.heat_transfer_coefficient
|
||||
* self.heat_transfer_area
|
||||
* (self.external_temperature - temperature)
|
||||
)
|
||||
|
||||
def gas_mass_g(self) -> float:
|
||||
return kg_to_g(self.state.m)
|
||||
|
||||
def pressure_gauge_pa(self, reference_pressure_pa: float = 101_300.0) -> float:
|
||||
return self.properties().p - reference_pressure_pa
|
||||
|
||||
def properties(self) -> ThermodynamicProperties:
|
||||
if self.state.m <= 0.0:
|
||||
raise ValueError("volume mass must stay positive.")
|
||||
T = self.gas.temperature_from_internal_energy(self.state.U / self.state.m)
|
||||
rho = self.state.m / self.volume
|
||||
p = self.gas.pressure(rho, T)
|
||||
u = self.state.U / self.state.m
|
||||
h = self.gas.specific_enthalpy(T)
|
||||
self.port_a.p = p
|
||||
self.port_a.h_outflow = h
|
||||
self.port_b.p = p
|
||||
self.port_b.h_outflow = h
|
||||
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
|
||||
|
||||
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
|
||||
return VolumeState(
|
||||
m=m_flow,
|
||||
U=m_flow * inlet_h + self.thermal_energy_flow_w(),
|
||||
)
|
||||
|
||||
def derivatives_from_two_connections(
|
||||
self,
|
||||
*,
|
||||
port_a_m_flow: float,
|
||||
connected_h_a: float,
|
||||
port_b_m_flow: float,
|
||||
connected_h_b: float,
|
||||
internal_h: float,
|
||||
volume_rate_m3_s: float | None = None,
|
||||
) -> VolumeState:
|
||||
properties = self.properties()
|
||||
inlet_h_a = self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_a_m_flow,
|
||||
connected_h=connected_h_a,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
inlet_h_b = self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_b_m_flow,
|
||||
connected_h=connected_h_b,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
return VolumeState(
|
||||
m=port_a_m_flow + port_b_m_flow,
|
||||
U=(
|
||||
port_a_m_flow * inlet_h_a
|
||||
+ port_b_m_flow * inlet_h_b
|
||||
+ self.thermal_energy_flow_w(properties.T)
|
||||
- properties.p * (
|
||||
self.volume_rate_m3_s()
|
||||
if volume_rate_m3_s is None
|
||||
else volume_rate_m3_s
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AmesimVariablePneumaticVolume(AmesimPneumaticVolume):
|
||||
"""PNCH012-style volume with a dead volume plus an external moving volume."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
dead_volume: float,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
p0: float = 101_325.0,
|
||||
T0: float = 293.15,
|
||||
external_volume: float = 0.0,
|
||||
heat_transfer_coefficient: float = 0.0,
|
||||
heat_transfer_area: float = 0.0,
|
||||
external_temperature_k: float = 293.15,
|
||||
) -> None:
|
||||
if dead_volume <= 0.0:
|
||||
raise ValueError("dead_volume must be positive.")
|
||||
if dead_volume + external_volume <= 0.0:
|
||||
raise ValueError("total volume must be positive.")
|
||||
self.dead_volume = dead_volume
|
||||
self.external_volume = external_volume
|
||||
self.external_volume_rate = 0.0
|
||||
super().__init__(
|
||||
name=name,
|
||||
volume=dead_volume + external_volume,
|
||||
gas=gas,
|
||||
p0=p0,
|
||||
T0=T0,
|
||||
heat_transfer_coefficient=heat_transfer_coefficient,
|
||||
heat_transfer_area=heat_transfer_area,
|
||||
external_temperature_k=external_temperature_k,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_liters(
|
||||
cls,
|
||||
name: str,
|
||||
dead_volume_liters: float,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
p0: float = 101_325.0,
|
||||
T0: float = 293.15,
|
||||
external_volume_liters: float = 0.0,
|
||||
heat_transfer_coefficient: float = 0.0,
|
||||
heat_transfer_area: float = 0.0,
|
||||
external_temperature_k: float = 293.15,
|
||||
) -> "AmesimVariablePneumaticVolume":
|
||||
return cls(
|
||||
name=name,
|
||||
dead_volume=liters_to_m3(dead_volume_liters),
|
||||
gas=gas,
|
||||
p0=p0,
|
||||
T0=T0,
|
||||
external_volume=liters_to_m3(external_volume_liters),
|
||||
heat_transfer_coefficient=heat_transfer_coefficient,
|
||||
heat_transfer_area=heat_transfer_area,
|
||||
external_temperature_k=external_temperature_k,
|
||||
)
|
||||
|
||||
def volume_rate_m3_s(self) -> float:
|
||||
return self.external_volume_rate
|
||||
|
||||
def set_external_volume_m3(
|
||||
self,
|
||||
external_volume: float,
|
||||
external_volume_rate_m3_s: float = 0.0,
|
||||
) -> None:
|
||||
if self.dead_volume + external_volume <= 0.0:
|
||||
raise ValueError("total volume must be positive.")
|
||||
self.external_volume = external_volume
|
||||
self.external_volume_rate = external_volume_rate_m3_s
|
||||
self.volume = self.dead_volume + self.external_volume
|
||||
|
||||
|
||||
class AmesimPneumaticOrifice(AlgebraicComponent):
|
||||
"""First-pass PNOR001/PNVO001-style compressible helium orifice.
|
||||
|
||||
This is a calibrated placeholder boundary for the Python port. It preserves
|
||||
AMESim-style area and coefficient inputs, but final parity must be checked
|
||||
against AMESim CSV results before treating it as numerically equivalent.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
area: float,
|
||||
flow_coefficient: float = 1.0,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
opening: float = 1.0,
|
||||
) -> None:
|
||||
if area < 0.0:
|
||||
raise ValueError("area must be non-negative.")
|
||||
if flow_coefficient < 0.0:
|
||||
raise ValueError("flow_coefficient must be non-negative.")
|
||||
super().__init__(name=name)
|
||||
self.area = area
|
||||
self.flow_coefficient = flow_coefficient
|
||||
self.gas = gas
|
||||
self.opening = opening
|
||||
self.port_a = PortState()
|
||||
self.port_b = PortState()
|
||||
|
||||
@classmethod
|
||||
def from_mm2(
|
||||
cls,
|
||||
name: str,
|
||||
area_mm2: float,
|
||||
flow_coefficient: float = 1.0,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
opening: float = 1.0,
|
||||
) -> "AmesimPneumaticOrifice":
|
||||
return cls(
|
||||
name=name,
|
||||
area=mm2_to_m2(area_mm2),
|
||||
flow_coefficient=flow_coefficient,
|
||||
gas=gas,
|
||||
opening=opening,
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_area(self) -> float:
|
||||
opening = min(max(self.opening, 0.0), 1.0)
|
||||
return self.area * opening
|
||||
|
||||
def mass_flow(self, p_a: float, p_b: float, upstream_temperature: float) -> float:
|
||||
if p_a == p_b or self.effective_area == 0.0 or self.flow_coefficient == 0.0:
|
||||
return 0.0
|
||||
if p_a > p_b:
|
||||
return compressible_orifice_mass_flow(
|
||||
upstream_pressure=p_a,
|
||||
downstream_pressure=p_b,
|
||||
upstream_temperature=upstream_temperature,
|
||||
area=self.effective_area,
|
||||
flow_coefficient=self.flow_coefficient,
|
||||
gas=self.gas,
|
||||
)
|
||||
return -compressible_orifice_mass_flow(
|
||||
upstream_pressure=p_b,
|
||||
downstream_pressure=p_a,
|
||||
upstream_temperature=upstream_temperature,
|
||||
area=self.effective_area,
|
||||
flow_coefficient=self.flow_coefficient,
|
||||
gas=self.gas,
|
||||
)
|
||||
|
||||
|
||||
def compressible_orifice_mass_flow(
|
||||
*,
|
||||
upstream_pressure: float,
|
||||
downstream_pressure: float,
|
||||
upstream_temperature: float,
|
||||
area: float,
|
||||
flow_coefficient: float,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
) -> float:
|
||||
if upstream_pressure <= 0.0 or downstream_pressure < 0.0:
|
||||
raise ValueError("pressures must be non-negative and upstream pressure must be positive.")
|
||||
if upstream_temperature <= 0.0:
|
||||
raise ValueError("upstream_temperature must be positive.")
|
||||
if area < 0.0 or flow_coefficient < 0.0:
|
||||
raise ValueError("area and flow_coefficient must be non-negative.")
|
||||
if downstream_pressure >= upstream_pressure or area == 0.0 or flow_coefficient == 0.0:
|
||||
return 0.0
|
||||
|
||||
gamma = gas.gamma
|
||||
pressure_ratio = max(downstream_pressure / upstream_pressure, 0.0)
|
||||
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
|
||||
coefficient = flow_coefficient * area * upstream_pressure / sqrt(gas.R_gas * upstream_temperature)
|
||||
if pressure_ratio <= critical_ratio:
|
||||
flow_function = sqrt(gamma) * (2.0 / (gamma + 1.0)) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
|
||||
else:
|
||||
term = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** ((gamma + 1.0) / gamma)
|
||||
flow_function = sqrt((2.0 * gamma / (gamma - 1.0)) * max(term, 0.0))
|
||||
return coefficient * flow_function
|
||||
@@ -1,881 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import log10, pi, sqrt
|
||||
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
HELIUM_PNEUMATIC_GAS,
|
||||
AmesimPneumaticGas,
|
||||
compressible_orifice_mass_flow,
|
||||
diameter_mm_to_area_m2,
|
||||
)
|
||||
from PythonModels.core.base import AlgebraicComponent, DynamicComponent
|
||||
from PythonModels.core.medium import ThermodynamicProperties
|
||||
from PythonModels.core.ports import PortState
|
||||
from PythonModels.core.state import VolumeState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimPnl0001Diagnostics:
|
||||
mass_flow_kg_s: float
|
||||
reynolds_number: float
|
||||
gas_velocity_m_s: float
|
||||
friction_factor: float
|
||||
pressure_drop_pa: float
|
||||
|
||||
|
||||
class _DarcyPipeResistanceMixin:
|
||||
diameter: float
|
||||
length: float
|
||||
relative_roughness: float
|
||||
area: float
|
||||
|
||||
def _mass_flow_for_pressure_drop(
|
||||
self,
|
||||
pressure_drop_pa: float,
|
||||
*,
|
||||
density: float,
|
||||
temperature: float,
|
||||
) -> float:
|
||||
if pressure_drop_pa <= 0.0:
|
||||
return 0.0
|
||||
upper = 1.0e-9
|
||||
while self._darcy_pressure_drop(
|
||||
upper,
|
||||
density=density,
|
||||
temperature=temperature,
|
||||
) < pressure_drop_pa:
|
||||
upper *= 10.0
|
||||
if upper > 1.0e3:
|
||||
raise ValueError("unable to bracket pneumatic pipe resistance flow")
|
||||
lower = 0.0
|
||||
for _ in range(48):
|
||||
middle = 0.5 * (lower + upper)
|
||||
if self._darcy_pressure_drop(
|
||||
middle,
|
||||
density=density,
|
||||
temperature=temperature,
|
||||
) < pressure_drop_pa:
|
||||
lower = middle
|
||||
else:
|
||||
upper = middle
|
||||
return 0.5 * (lower + upper)
|
||||
|
||||
def pn2pipefr_mass_flow(
|
||||
self,
|
||||
*,
|
||||
port_1_pressure_pa: float,
|
||||
port_1_temperature_k: float,
|
||||
port_2_pressure_pa: float,
|
||||
port_2_temperature_k: float,
|
||||
length: float | None = None,
|
||||
) -> float:
|
||||
pressure_difference = port_1_pressure_pa - port_2_pressure_pa
|
||||
if pressure_difference == 0.0:
|
||||
return 0.0
|
||||
upstream_pressure = max(port_1_pressure_pa, port_2_pressure_pa)
|
||||
downstream_pressure = min(port_1_pressure_pa, port_2_pressure_pa)
|
||||
upstream_temperature = (
|
||||
port_1_temperature_k
|
||||
if pressure_difference > 0.0
|
||||
else port_2_temperature_k
|
||||
)
|
||||
resistance_length = self.length if length is None else length
|
||||
if resistance_length <= 0.0:
|
||||
raise ValueError("length must be positive")
|
||||
|
||||
def target_flow(mass_flow_kg_s: float) -> float:
|
||||
reynolds = self._reynolds_number(mass_flow_kg_s, upstream_temperature)
|
||||
friction_factor = self._friction_factor(reynolds)
|
||||
flow_coefficient = sqrt(
|
||||
self.diameter / (resistance_length * friction_factor)
|
||||
)
|
||||
return compressible_orifice_mass_flow(
|
||||
upstream_pressure=upstream_pressure,
|
||||
downstream_pressure=downstream_pressure,
|
||||
upstream_temperature=upstream_temperature,
|
||||
area=self.area,
|
||||
flow_coefficient=flow_coefficient,
|
||||
gas=self.gas,
|
||||
)
|
||||
|
||||
flow_coefficient = sqrt(self.diameter / (resistance_length * 0.02))
|
||||
magnitude = compressible_orifice_mass_flow(
|
||||
upstream_pressure=upstream_pressure,
|
||||
downstream_pressure=downstream_pressure,
|
||||
upstream_temperature=upstream_temperature,
|
||||
area=self.area,
|
||||
flow_coefficient=flow_coefficient,
|
||||
gas=self.gas,
|
||||
)
|
||||
for _ in range(12):
|
||||
next_magnitude = target_flow(magnitude)
|
||||
if abs(next_magnitude - magnitude) <= max(1.0e-12, abs(magnitude) * 1.0e-9):
|
||||
magnitude = next_magnitude
|
||||
break
|
||||
magnitude = 0.5 * (magnitude + next_magnitude)
|
||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
||||
|
||||
def _darcy_pressure_drop(
|
||||
self,
|
||||
mass_flow_kg_s: float,
|
||||
*,
|
||||
density: float,
|
||||
temperature: float,
|
||||
) -> float:
|
||||
if mass_flow_kg_s == 0.0:
|
||||
return 0.0
|
||||
reynolds = self._reynolds_number(mass_flow_kg_s, temperature)
|
||||
friction_factor = self._friction_factor(reynolds)
|
||||
velocity = mass_flow_kg_s / (density * self.area)
|
||||
magnitude = (
|
||||
friction_factor
|
||||
* (self.length / self.diameter)
|
||||
* density
|
||||
* velocity
|
||||
* velocity
|
||||
/ 2.0
|
||||
)
|
||||
return magnitude if mass_flow_kg_s > 0.0 else -magnitude
|
||||
|
||||
def _reynolds_number(self, mass_flow_kg_s: float, temperature: float) -> float:
|
||||
viscosity = helium_dynamic_viscosity(temperature)
|
||||
return 4.0 * abs(mass_flow_kg_s) / (pi * self.diameter * viscosity)
|
||||
|
||||
def _friction_factor(self, reynolds_number: float) -> float:
|
||||
if reynolds_number <= 0.0:
|
||||
return 64_000_000.0
|
||||
laminar = 64.0 / reynolds_number
|
||||
if reynolds_number <= 2_300.0:
|
||||
return laminar
|
||||
turbulent = 1.0 / (
|
||||
-1.8
|
||||
* log10(
|
||||
(self.relative_roughness / 3.7) ** 1.11
|
||||
+ 6.9 / reynolds_number
|
||||
)
|
||||
) ** 2
|
||||
if reynolds_number >= 4_000.0:
|
||||
return turbulent
|
||||
fraction = (reynolds_number - 2_300.0) / 1_700.0
|
||||
return laminar + fraction * (turbulent - laminar)
|
||||
|
||||
|
||||
class AmesimPnl0001Pipe(_DarcyPipeResistanceMixin, DynamicComponent):
|
||||
"""Physical first-pass implementation of AMESim ``PNL0001`` (C-R).
|
||||
|
||||
Port 2 owns the lumped gas storage. Port 1 is connected through a Darcy
|
||||
resistance. Both connection mass flows use the PythonModels convention:
|
||||
positive values enter the pipe storage.
|
||||
|
||||
AMESim's proprietary ``pn2pipefr`` utility is represented by an
|
||||
optional calibrated linear conductance when a model-specific baseline
|
||||
supports it; otherwise the component falls back to an auditable
|
||||
Darcy-Weisbach law. Both paths preserve the real geometry, state count,
|
||||
mass/energy balance, heat-transfer parameter, and observable diagnostics.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
diameter_mm: float,
|
||||
length_m: float,
|
||||
relative_roughness: float,
|
||||
polytropic_constant: float = 1.35,
|
||||
heat_transfer_coefficient: float = 0.0,
|
||||
external_temperature_k: float = 293.15,
|
||||
calibrated_linear_conductance: float | None = None,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
p0: float = 101_325.0,
|
||||
T0: float = 293.15,
|
||||
) -> None:
|
||||
if diameter_mm <= 0.0:
|
||||
raise ValueError("diameter_mm must be positive")
|
||||
if length_m <= 0.0:
|
||||
raise ValueError("length_m must be positive")
|
||||
if relative_roughness < 0.0:
|
||||
raise ValueError("relative_roughness must be non-negative")
|
||||
if polytropic_constant <= 0.0:
|
||||
raise ValueError("polytropic_constant must be positive")
|
||||
if heat_transfer_coefficient < 0.0:
|
||||
raise ValueError("heat_transfer_coefficient must be non-negative")
|
||||
if external_temperature_k <= 0.0:
|
||||
raise ValueError("external_temperature_k must be positive")
|
||||
if (
|
||||
calibrated_linear_conductance is not None
|
||||
and calibrated_linear_conductance <= 0.0
|
||||
):
|
||||
raise ValueError("calibrated_linear_conductance must be positive")
|
||||
|
||||
super().__init__(name=name)
|
||||
self.diameter = diameter_mm * 1.0e-3
|
||||
self.length = length_m
|
||||
self.relative_roughness = relative_roughness
|
||||
self.polytropic_constant = polytropic_constant
|
||||
self.heat_transfer_coefficient = heat_transfer_coefficient
|
||||
self.external_temperature = external_temperature_k
|
||||
self.calibrated_linear_conductance = calibrated_linear_conductance
|
||||
self.gas = gas
|
||||
self.area = diameter_mm_to_area_m2(diameter_mm)
|
||||
self.volume = self.area * self.length
|
||||
self.heat_transfer_area = pi * self.diameter * self.length
|
||||
|
||||
rho0 = gas.density(p0, T0)
|
||||
mass0 = rho0 * self.volume
|
||||
self.state = VolumeState(
|
||||
m=mass0,
|
||||
U=mass0 * gas.specific_internal_energy(T0),
|
||||
)
|
||||
self.port_1 = PortState()
|
||||
self.port_2 = 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:
|
||||
if self.state.m <= 0.0:
|
||||
raise ValueError("pipe mass must stay positive")
|
||||
temperature = self.gas.temperature_from_internal_energy(
|
||||
self.state.U / self.state.m
|
||||
)
|
||||
density = self.state.m / self.volume
|
||||
pressure = self.gas.pressure(density, temperature)
|
||||
properties = ThermodynamicProperties(
|
||||
p=pressure,
|
||||
T=temperature,
|
||||
rho=density,
|
||||
u=self.state.U / self.state.m,
|
||||
h=self.gas.specific_enthalpy(temperature),
|
||||
)
|
||||
self.port_2.p = pressure
|
||||
self.port_2.h_outflow = properties.h
|
||||
return properties
|
||||
|
||||
def gas_mass_g(self) -> float:
|
||||
return self.state.m * 1.0e3
|
||||
|
||||
def resistance_mass_flow(
|
||||
self,
|
||||
*,
|
||||
port_1_pressure_pa: float,
|
||||
port_1_temperature_k: float,
|
||||
) -> float:
|
||||
"""Return mass flow from port 1 into the port-2 storage in kg/s."""
|
||||
if port_1_pressure_pa <= 0.0:
|
||||
raise ValueError("port_1_pressure_pa must be positive")
|
||||
if port_1_temperature_k <= 0.0:
|
||||
raise ValueError("port_1_temperature_k must be positive")
|
||||
|
||||
internal = self.properties()
|
||||
pressure_difference = port_1_pressure_pa - internal.p
|
||||
if pressure_difference == 0.0:
|
||||
return 0.0
|
||||
if self.calibrated_linear_conductance is not None:
|
||||
return (
|
||||
self.calibrated_linear_conductance
|
||||
* pressure_difference
|
||||
/ sqrt(internal.T)
|
||||
)
|
||||
upstream_pressure = max(port_1_pressure_pa, internal.p)
|
||||
upstream_temperature = (
|
||||
port_1_temperature_k if pressure_difference > 0.0 else internal.T
|
||||
)
|
||||
density = self.gas.density(upstream_pressure, upstream_temperature)
|
||||
magnitude = self._mass_flow_for_pressure_drop(
|
||||
abs(pressure_difference),
|
||||
density=density,
|
||||
temperature=upstream_temperature,
|
||||
)
|
||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
||||
|
||||
def diagnostics(
|
||||
self,
|
||||
*,
|
||||
mass_flow_kg_s: float,
|
||||
temperature_k: float | None = None,
|
||||
) -> AmesimPnl0001Diagnostics:
|
||||
properties = self.properties()
|
||||
temperature = temperature_k or properties.T
|
||||
reynolds = self._reynolds_number(mass_flow_kg_s, temperature)
|
||||
friction_factor = self._friction_factor(reynolds)
|
||||
velocity = mass_flow_kg_s / (properties.rho * self.area)
|
||||
pressure_drop = self._darcy_pressure_drop(
|
||||
mass_flow_kg_s,
|
||||
density=properties.rho,
|
||||
temperature=temperature,
|
||||
)
|
||||
return AmesimPnl0001Diagnostics(
|
||||
mass_flow_kg_s=mass_flow_kg_s,
|
||||
reynolds_number=reynolds,
|
||||
gas_velocity_m_s=velocity,
|
||||
friction_factor=friction_factor,
|
||||
pressure_drop_pa=pressure_drop,
|
||||
)
|
||||
|
||||
def darcy_pressure_drop_for_state(
|
||||
self,
|
||||
*,
|
||||
mass_flow_kg_s: float,
|
||||
pressure_pa: float,
|
||||
temperature_k: float,
|
||||
) -> float:
|
||||
if pressure_pa <= 0.0:
|
||||
raise ValueError("pressure_pa must be positive")
|
||||
if temperature_k <= 0.0:
|
||||
raise ValueError("temperature_k must be positive")
|
||||
density = self.gas.density(pressure_pa, temperature_k)
|
||||
return self._darcy_pressure_drop(
|
||||
mass_flow_kg_s,
|
||||
density=density,
|
||||
temperature=temperature_k,
|
||||
)
|
||||
|
||||
def derivatives_from_connections(
|
||||
self,
|
||||
*,
|
||||
port_1_m_flow: float,
|
||||
connected_h_1: float,
|
||||
port_2_m_flow: float,
|
||||
connected_h_2: float,
|
||||
) -> VolumeState:
|
||||
internal = self.properties()
|
||||
# Default first-pass PNL0001 behavior uses the historical internal-energy
|
||||
# approximation. AMESim-specific transport-enthalpy corrections are kept
|
||||
# behind derivatives_from_transport_enthalpy_connections so they can be
|
||||
# applied only where validated against baseline data.
|
||||
inlet_u_1 = (
|
||||
connected_h_1 / self.gas.gamma
|
||||
if port_1_m_flow > 0.0
|
||||
else internal.u
|
||||
)
|
||||
inlet_u_2 = (
|
||||
connected_h_2 / self.gas.gamma
|
||||
if port_2_m_flow > 0.0
|
||||
else internal.u
|
||||
)
|
||||
heat_flow = (
|
||||
self.heat_transfer_coefficient
|
||||
* self.heat_transfer_area
|
||||
* (self.external_temperature - internal.T)
|
||||
)
|
||||
return VolumeState(
|
||||
m=port_1_m_flow + port_2_m_flow,
|
||||
U=port_1_m_flow * inlet_u_1 + port_2_m_flow * inlet_u_2 + heat_flow,
|
||||
)
|
||||
|
||||
def derivatives_from_transport_enthalpy_connections(
|
||||
self,
|
||||
*,
|
||||
port_1_m_flow: float,
|
||||
connected_h_1: float,
|
||||
port_2_m_flow: float,
|
||||
connected_h_2: float,
|
||||
) -> VolumeState:
|
||||
internal = self.properties()
|
||||
inlet_h_1 = connected_h_1 if port_1_m_flow > 0.0 else internal.h
|
||||
inlet_h_2 = connected_h_2 if port_2_m_flow > 0.0 else internal.h
|
||||
heat_flow = (
|
||||
self.heat_transfer_coefficient
|
||||
* self.heat_transfer_area
|
||||
* (self.external_temperature - internal.T)
|
||||
)
|
||||
return VolumeState(
|
||||
m=port_1_m_flow + port_2_m_flow,
|
||||
U=port_1_m_flow * inlet_h_1 + port_2_m_flow * inlet_h_2 + heat_flow,
|
||||
)
|
||||
|
||||
|
||||
class AmesimPnl0003Pipe(_DarcyPipeResistanceMixin, DynamicComponent):
|
||||
"""First-pass AMESim ``PNL0003`` (C-R-C) pipe.
|
||||
|
||||
The two pipe-end compliances are represented as equal half-volume gas
|
||||
stores connected by the same auditable Darcy resistance used for PNL0001.
|
||||
Center flow is positive from port 1 storage to port 2 storage.
|
||||
"""
|
||||
|
||||
state_size = 4
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
diameter_mm: float,
|
||||
length_m: float,
|
||||
relative_roughness: float,
|
||||
polytropic_constant: float = 1.35,
|
||||
heat_transfer_coefficient: float = 0.0,
|
||||
external_temperature_k: float = 293.15,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
p1_0: float = 101_325.0,
|
||||
T1_0: float = 293.15,
|
||||
p2_0: float = 101_325.0,
|
||||
T2_0: float = 293.15,
|
||||
) -> None:
|
||||
if diameter_mm <= 0.0:
|
||||
raise ValueError("diameter_mm must be positive")
|
||||
if length_m <= 0.0:
|
||||
raise ValueError("length_m must be positive")
|
||||
if relative_roughness < 0.0:
|
||||
raise ValueError("relative_roughness must be non-negative")
|
||||
if polytropic_constant <= 0.0:
|
||||
raise ValueError("polytropic_constant must be positive")
|
||||
if heat_transfer_coefficient < 0.0:
|
||||
raise ValueError("heat_transfer_coefficient must be non-negative")
|
||||
if external_temperature_k <= 0.0:
|
||||
raise ValueError("external_temperature_k must be positive")
|
||||
|
||||
super().__init__(name=name)
|
||||
self.diameter = diameter_mm * 1.0e-3
|
||||
self.length = length_m
|
||||
self.relative_roughness = relative_roughness
|
||||
self.polytropic_constant = polytropic_constant
|
||||
self.heat_transfer_coefficient = heat_transfer_coefficient
|
||||
self.external_temperature = external_temperature_k
|
||||
self.gas = gas
|
||||
self.area = diameter_mm_to_area_m2(diameter_mm)
|
||||
self.volume = self.area * self.length
|
||||
self.compliance_volume = self.volume / 2.0
|
||||
self.heat_transfer_area = pi * self.diameter * self.length
|
||||
|
||||
self.state_1 = self._initial_state(p1_0, T1_0)
|
||||
self.state_2 = self._initial_state(p2_0, T2_0)
|
||||
self.port_1 = PortState()
|
||||
self.port_2 = PortState()
|
||||
|
||||
def _initial_state(self, pressure: float, temperature: float) -> VolumeState:
|
||||
rho = self.gas.density(pressure, temperature)
|
||||
mass = rho * self.compliance_volume
|
||||
return VolumeState(
|
||||
m=mass,
|
||||
U=mass * self.gas.specific_internal_energy(temperature),
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return [*self.state_1.as_vector(), *self.state_2.as_vector()]
|
||||
|
||||
def set_state_vector(self, values: list[float]) -> None:
|
||||
if len(values) != 4:
|
||||
raise ValueError("PNL0003 state vector requires four values")
|
||||
self.state_1 = VolumeState.from_vector(values[:2])
|
||||
self.state_2 = VolumeState.from_vector(values[2:])
|
||||
|
||||
def properties_1(self) -> ThermodynamicProperties:
|
||||
properties = self._properties(self.state_1)
|
||||
self.port_1.p = properties.p
|
||||
self.port_1.h_outflow = properties.h
|
||||
return properties
|
||||
|
||||
def properties_2(self) -> ThermodynamicProperties:
|
||||
properties = self._properties(self.state_2)
|
||||
self.port_2.p = properties.p
|
||||
self.port_2.h_outflow = properties.h
|
||||
return properties
|
||||
|
||||
def _properties(self, state: VolumeState) -> ThermodynamicProperties:
|
||||
if state.m <= 0.0:
|
||||
raise ValueError("pipe mass must stay positive")
|
||||
temperature = self.gas.temperature_from_internal_energy(state.U / state.m)
|
||||
density = state.m / self.compliance_volume
|
||||
pressure = self.gas.pressure(density, temperature)
|
||||
return ThermodynamicProperties(
|
||||
p=pressure,
|
||||
T=temperature,
|
||||
rho=density,
|
||||
u=state.U / state.m,
|
||||
h=self.gas.specific_enthalpy(temperature),
|
||||
)
|
||||
|
||||
def gas_mass_g(self) -> float:
|
||||
return (self.state_1.m + self.state_2.m) * 1.0e3
|
||||
|
||||
def resistance_mass_flow(self) -> float:
|
||||
"""Return center mass flow from port 1 storage to port 2 storage."""
|
||||
port_1 = self.properties_1()
|
||||
port_2 = self.properties_2()
|
||||
pressure_difference = port_1.p - port_2.p
|
||||
if pressure_difference == 0.0:
|
||||
return 0.0
|
||||
upstream = port_1 if pressure_difference > 0.0 else port_2
|
||||
magnitude = self._mass_flow_for_pressure_drop(
|
||||
abs(pressure_difference),
|
||||
density=upstream.rho,
|
||||
temperature=upstream.T,
|
||||
)
|
||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
||||
|
||||
def diagnostics(
|
||||
self,
|
||||
*,
|
||||
mass_flow_kg_s: float,
|
||||
temperature_k: float | None = None,
|
||||
) -> AmesimPnl0001Diagnostics:
|
||||
port_1 = self.properties_1()
|
||||
port_2 = self.properties_2()
|
||||
temperature = temperature_k or (port_1.T if mass_flow_kg_s >= 0.0 else port_2.T)
|
||||
density = port_1.rho if mass_flow_kg_s >= 0.0 else port_2.rho
|
||||
reynolds = self._reynolds_number(mass_flow_kg_s, temperature)
|
||||
friction_factor = self._friction_factor(reynolds)
|
||||
velocity = mass_flow_kg_s / (density * self.area)
|
||||
pressure_drop = self._darcy_pressure_drop(
|
||||
mass_flow_kg_s,
|
||||
density=density,
|
||||
temperature=temperature,
|
||||
)
|
||||
return AmesimPnl0001Diagnostics(
|
||||
mass_flow_kg_s=mass_flow_kg_s,
|
||||
reynolds_number=reynolds,
|
||||
gas_velocity_m_s=velocity,
|
||||
friction_factor=friction_factor,
|
||||
pressure_drop_pa=pressure_drop,
|
||||
)
|
||||
|
||||
def derivatives_from_connections(
|
||||
self,
|
||||
*,
|
||||
port_1_m_flow: float,
|
||||
connected_h_1: float,
|
||||
port_2_m_flow: float,
|
||||
connected_h_2: float,
|
||||
) -> tuple[VolumeState, VolumeState]:
|
||||
port_1 = self.properties_1()
|
||||
port_2 = self.properties_2()
|
||||
center_flow = self.resistance_mass_flow()
|
||||
heat_flow_each = (
|
||||
self.heat_transfer_coefficient
|
||||
* self.heat_transfer_area
|
||||
* (self.external_temperature - 0.5 * (port_1.T + port_2.T))
|
||||
/ 2.0
|
||||
)
|
||||
port_1_external_h = self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_1_m_flow,
|
||||
connected_h=connected_h_1,
|
||||
internal_h=port_1.h,
|
||||
)
|
||||
port_2_external_h = self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_2_m_flow,
|
||||
connected_h=connected_h_2,
|
||||
internal_h=port_2.h,
|
||||
)
|
||||
port_1_center_h = self.connection_inlet_enthalpy(
|
||||
port_m_flow=-center_flow,
|
||||
connected_h=port_2.h,
|
||||
internal_h=port_1.h,
|
||||
)
|
||||
port_2_center_h = self.connection_inlet_enthalpy(
|
||||
port_m_flow=center_flow,
|
||||
connected_h=port_1.h,
|
||||
internal_h=port_2.h,
|
||||
)
|
||||
return (
|
||||
VolumeState(
|
||||
m=port_1_m_flow - center_flow,
|
||||
U=(
|
||||
port_1_m_flow * port_1_external_h
|
||||
- center_flow * port_1_center_h
|
||||
+ heat_flow_each
|
||||
),
|
||||
),
|
||||
VolumeState(
|
||||
m=port_2_m_flow + center_flow,
|
||||
U=(
|
||||
port_2_m_flow * port_2_external_h
|
||||
+ center_flow * port_2_center_h
|
||||
+ heat_flow_each
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AmesimPnl0002Pipe(_DarcyPipeResistanceMixin, DynamicComponent):
|
||||
"""First-pass AMESim ``PNL0002`` (R-C-R) pipe.
|
||||
|
||||
The center compliance owns the gas state. Positive connection mass flows
|
||||
enter that center storage from each external port.
|
||||
"""
|
||||
|
||||
state_size = 2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
diameter_mm: float,
|
||||
length_m: float,
|
||||
relative_roughness: float,
|
||||
polytropic_constant: float = 1.35,
|
||||
heat_transfer_coefficient: float = 0.0,
|
||||
external_temperature_k: float = 293.15,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
pctr_0: float = 101_325.0,
|
||||
Tctr_0: float = 293.15,
|
||||
) -> None:
|
||||
if diameter_mm <= 0.0:
|
||||
raise ValueError("diameter_mm must be positive")
|
||||
if length_m <= 0.0:
|
||||
raise ValueError("length_m must be positive")
|
||||
if relative_roughness < 0.0:
|
||||
raise ValueError("relative_roughness must be non-negative")
|
||||
if polytropic_constant <= 0.0:
|
||||
raise ValueError("polytropic_constant must be positive")
|
||||
if heat_transfer_coefficient < 0.0:
|
||||
raise ValueError("heat_transfer_coefficient must be non-negative")
|
||||
if external_temperature_k <= 0.0:
|
||||
raise ValueError("external_temperature_k must be positive")
|
||||
|
||||
super().__init__(name=name)
|
||||
self.diameter = diameter_mm * 1.0e-3
|
||||
self.length = length_m
|
||||
self.relative_roughness = relative_roughness
|
||||
self.polytropic_constant = polytropic_constant
|
||||
self.heat_transfer_coefficient = heat_transfer_coefficient
|
||||
self.external_temperature = external_temperature_k
|
||||
self.gas = gas
|
||||
self.area = diameter_mm_to_area_m2(diameter_mm)
|
||||
self.volume = self.area * self.length
|
||||
self.heat_transfer_area = pi * self.diameter * self.length
|
||||
self._resistance_length = self.length / 2.0
|
||||
|
||||
rho0 = gas.density(pctr_0, Tctr_0)
|
||||
mass0 = rho0 * self.volume
|
||||
self.state = VolumeState(
|
||||
m=mass0,
|
||||
U=mass0 * gas.specific_internal_energy(Tctr_0),
|
||||
)
|
||||
self.port_1 = PortState()
|
||||
self.port_2 = 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:
|
||||
if self.state.m <= 0.0:
|
||||
raise ValueError("pipe mass must stay positive")
|
||||
temperature = self.gas.temperature_from_internal_energy(
|
||||
self.state.U / self.state.m
|
||||
)
|
||||
density = self.state.m / self.volume
|
||||
pressure = self.gas.pressure(density, temperature)
|
||||
properties = ThermodynamicProperties(
|
||||
p=pressure,
|
||||
T=temperature,
|
||||
rho=density,
|
||||
u=self.state.U / self.state.m,
|
||||
h=self.gas.specific_enthalpy(temperature),
|
||||
)
|
||||
self.port_1.p = pressure
|
||||
self.port_1.h_outflow = properties.h
|
||||
self.port_2.p = pressure
|
||||
self.port_2.h_outflow = properties.h
|
||||
return properties
|
||||
|
||||
def gas_mass_g(self) -> float:
|
||||
return self.state.m * 1.0e3
|
||||
|
||||
def port_mass_flow(
|
||||
self,
|
||||
*,
|
||||
port_pressure_pa: float,
|
||||
port_temperature_k: float,
|
||||
) -> float:
|
||||
"""Return mass flow from an external port into the center storage."""
|
||||
if port_pressure_pa <= 0.0:
|
||||
raise ValueError("port_pressure_pa must be positive")
|
||||
if port_temperature_k <= 0.0:
|
||||
raise ValueError("port_temperature_k must be positive")
|
||||
|
||||
center = self.properties()
|
||||
pressure_difference = port_pressure_pa - center.p
|
||||
if pressure_difference == 0.0:
|
||||
return 0.0
|
||||
upstream_pressure = max(port_pressure_pa, center.p)
|
||||
upstream_temperature = (
|
||||
port_temperature_k if pressure_difference > 0.0 else center.T
|
||||
)
|
||||
density = self.gas.density(upstream_pressure, upstream_temperature)
|
||||
magnitude = self._mass_flow_for_resistance_pressure_drop(
|
||||
abs(pressure_difference),
|
||||
density=density,
|
||||
temperature=upstream_temperature,
|
||||
)
|
||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
||||
|
||||
def _mass_flow_for_resistance_pressure_drop(
|
||||
self,
|
||||
pressure_drop_pa: float,
|
||||
*,
|
||||
density: float,
|
||||
temperature: float,
|
||||
) -> float:
|
||||
original_length = self.length
|
||||
self.length = self._resistance_length
|
||||
try:
|
||||
return self._mass_flow_for_pressure_drop(
|
||||
pressure_drop_pa,
|
||||
density=density,
|
||||
temperature=temperature,
|
||||
)
|
||||
finally:
|
||||
self.length = original_length
|
||||
|
||||
def diagnostics(
|
||||
self,
|
||||
*,
|
||||
mass_flow_kg_s: float,
|
||||
temperature_k: float | None = None,
|
||||
) -> AmesimPnl0001Diagnostics:
|
||||
properties = self.properties()
|
||||
temperature = temperature_k or properties.T
|
||||
reynolds = self._reynolds_number(mass_flow_kg_s, temperature)
|
||||
friction_factor = self._friction_factor(reynolds)
|
||||
velocity = mass_flow_kg_s / (properties.rho * self.area)
|
||||
original_length = self.length
|
||||
self.length = self._resistance_length
|
||||
try:
|
||||
pressure_drop = self._darcy_pressure_drop(
|
||||
mass_flow_kg_s,
|
||||
density=properties.rho,
|
||||
temperature=temperature,
|
||||
)
|
||||
finally:
|
||||
self.length = original_length
|
||||
return AmesimPnl0001Diagnostics(
|
||||
mass_flow_kg_s=mass_flow_kg_s,
|
||||
reynolds_number=reynolds,
|
||||
gas_velocity_m_s=velocity,
|
||||
friction_factor=friction_factor,
|
||||
pressure_drop_pa=pressure_drop,
|
||||
)
|
||||
|
||||
def derivatives_from_connections(
|
||||
self,
|
||||
*,
|
||||
port_1_m_flow: float,
|
||||
connected_h_1: float,
|
||||
port_2_m_flow: float,
|
||||
connected_h_2: float,
|
||||
) -> VolumeState:
|
||||
center = self.properties()
|
||||
inlet_h_1 = self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_1_m_flow,
|
||||
connected_h=connected_h_1,
|
||||
internal_h=center.h,
|
||||
)
|
||||
inlet_h_2 = self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_2_m_flow,
|
||||
connected_h=connected_h_2,
|
||||
internal_h=center.h,
|
||||
)
|
||||
heat_flow = (
|
||||
self.heat_transfer_coefficient
|
||||
* self.heat_transfer_area
|
||||
* (self.external_temperature - center.T)
|
||||
)
|
||||
return VolumeState(
|
||||
m=port_1_m_flow + port_2_m_flow,
|
||||
U=port_1_m_flow * inlet_h_1 + port_2_m_flow * inlet_h_2 + heat_flow,
|
||||
)
|
||||
|
||||
|
||||
class AmesimPnl00rPipe(_DarcyPipeResistanceMixin, AlgebraicComponent):
|
||||
"""First-pass AMESim ``PNL00R`` (R) pipe resistance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
diameter_mm: float,
|
||||
length_m: float,
|
||||
relative_roughness: float,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
) -> None:
|
||||
if diameter_mm <= 0.0:
|
||||
raise ValueError("diameter_mm must be positive")
|
||||
if length_m <= 0.0:
|
||||
raise ValueError("length_m must be positive")
|
||||
if relative_roughness < 0.0:
|
||||
raise ValueError("relative_roughness must be non-negative")
|
||||
|
||||
super().__init__(name=name)
|
||||
self.diameter = diameter_mm * 1.0e-3
|
||||
self.length = length_m
|
||||
self.relative_roughness = relative_roughness
|
||||
self.gas = gas
|
||||
self.area = diameter_mm_to_area_m2(diameter_mm)
|
||||
self.port_1 = PortState()
|
||||
self.port_2 = PortState()
|
||||
|
||||
def mass_flow(
|
||||
self,
|
||||
*,
|
||||
port_1_pressure_pa: float,
|
||||
port_1_temperature_k: float,
|
||||
port_2_pressure_pa: float,
|
||||
port_2_temperature_k: float,
|
||||
) -> float:
|
||||
"""Return mass flow from port 1 to port 2 in kg/s."""
|
||||
if port_1_pressure_pa <= 0.0 or port_2_pressure_pa <= 0.0:
|
||||
raise ValueError("port pressures must be positive")
|
||||
if port_1_temperature_k <= 0.0 or port_2_temperature_k <= 0.0:
|
||||
raise ValueError("port temperatures must be positive")
|
||||
pressure_difference = port_1_pressure_pa - port_2_pressure_pa
|
||||
if pressure_difference == 0.0:
|
||||
return 0.0
|
||||
upstream_pressure = max(port_1_pressure_pa, port_2_pressure_pa)
|
||||
upstream_temperature = (
|
||||
port_1_temperature_k
|
||||
if pressure_difference > 0.0
|
||||
else port_2_temperature_k
|
||||
)
|
||||
density = self.gas.density(upstream_pressure, upstream_temperature)
|
||||
magnitude = self._mass_flow_for_pressure_drop(
|
||||
abs(pressure_difference),
|
||||
density=density,
|
||||
temperature=upstream_temperature,
|
||||
)
|
||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
||||
|
||||
def diagnostics(
|
||||
self,
|
||||
*,
|
||||
mass_flow_kg_s: float,
|
||||
pressure_pa: float,
|
||||
temperature_k: float,
|
||||
) -> AmesimPnl0001Diagnostics:
|
||||
density = self.gas.density(pressure_pa, temperature_k)
|
||||
reynolds = self._reynolds_number(mass_flow_kg_s, temperature_k)
|
||||
friction_factor = self._friction_factor(reynolds)
|
||||
velocity = mass_flow_kg_s / (density * self.area)
|
||||
pressure_drop = self._darcy_pressure_drop(
|
||||
mass_flow_kg_s,
|
||||
density=density,
|
||||
temperature=temperature_k,
|
||||
)
|
||||
return AmesimPnl0001Diagnostics(
|
||||
mass_flow_kg_s=mass_flow_kg_s,
|
||||
reynolds_number=reynolds,
|
||||
gas_velocity_m_s=velocity,
|
||||
friction_factor=friction_factor,
|
||||
pressure_drop_pa=pressure_drop,
|
||||
)
|
||||
|
||||
|
||||
def helium_dynamic_viscosity(temperature_k: float) -> float:
|
||||
"""Sutherland approximation centered on the test_mql initial condition."""
|
||||
if temperature_k <= 0.0:
|
||||
raise ValueError("temperature_k must be positive")
|
||||
reference_temperature = 293.15
|
||||
reference_viscosity = 2.0e-5
|
||||
sutherland_constant = 79.4
|
||||
return (
|
||||
reference_viscosity
|
||||
* (temperature_k / reference_temperature) ** 1.5
|
||||
* (reference_temperature + sutherland_constant)
|
||||
/ (temperature_k + sutherland_constant)
|
||||
)
|
||||
@@ -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,78 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.core.base import Component, DynamicComponent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Connection:
|
||||
source_component: str
|
||||
source_port: str
|
||||
target_component: str
|
||||
target_port: str
|
||||
|
||||
|
||||
class SimulationNetwork:
|
||||
"""Container for components, topology, and state-vector bookkeeping."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.components: dict[str, Component] = {}
|
||||
self.connections: list[Connection] = []
|
||||
|
||||
def add_component(self, component: Component) -> None:
|
||||
if component.name in self.components:
|
||||
raise ValueError(f"Duplicate component name: {component.name}")
|
||||
self.components[component.name] = component
|
||||
|
||||
def connect(
|
||||
self,
|
||||
source_component: str,
|
||||
source_port: str,
|
||||
target_component: str,
|
||||
target_port: str,
|
||||
) -> None:
|
||||
self.connections.append(
|
||||
Connection(
|
||||
source_component=source_component,
|
||||
source_port=source_port,
|
||||
target_component=target_component,
|
||||
target_port=target_port,
|
||||
)
|
||||
)
|
||||
|
||||
def dynamic_components(self) -> list[DynamicComponent]:
|
||||
return [
|
||||
component
|
||||
for component in self.components.values()
|
||||
if isinstance(component, DynamicComponent)
|
||||
]
|
||||
|
||||
def initial_state_vector(self) -> list[float]:
|
||||
values: list[float] = []
|
||||
for component in self.dynamic_components():
|
||||
values.extend(component.get_state_vector())
|
||||
return values
|
||||
|
||||
def apply_state_vector(self, values: list[float]) -> None:
|
||||
cursor = 0
|
||||
for component in self.dynamic_components():
|
||||
next_cursor = cursor + component.state_size
|
||||
component.set_state_vector(values[cursor:next_cursor])
|
||||
cursor = next_cursor
|
||||
if cursor != len(values):
|
||||
raise ValueError("State vector length does not match dynamic components.")
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [f"Network: {self.name}", "Components:"]
|
||||
for name, component in self.components.items():
|
||||
lines.append(f" - {name}: {component.__class__.__name__}")
|
||||
lines.append("Connections:")
|
||||
for conn in self.connections:
|
||||
lines.append(
|
||||
f" - {conn.source_component}.{conn.source_port}"
|
||||
f" -> {conn.target_component}.{conn.target_port}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import acos, cos, isfinite, log, pi, sqrt
|
||||
|
||||
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PengRobinsonFluid:
|
||||
"""Pure-fluid Peng-Robinson equation-of-state helper.
|
||||
|
||||
The class covers the equation-of-state layer plus the enthalpy departure
|
||||
needed to compare AMESim pneumatic ``pn2hpti`` reference enthalpy flows.
|
||||
"""
|
||||
|
||||
name: str
|
||||
molar_mass: float
|
||||
critical_temperature: float
|
||||
critical_pressure: float
|
||||
acentric_factor: float
|
||||
|
||||
@property
|
||||
def specific_gas_constant(self) -> float:
|
||||
return UNIVERSAL_GAS_CONSTANT / self.molar_mass
|
||||
|
||||
@property
|
||||
def a_parameter(self) -> float:
|
||||
return (
|
||||
0.45724
|
||||
* UNIVERSAL_GAS_CONSTANT
|
||||
* UNIVERSAL_GAS_CONSTANT
|
||||
* self.critical_temperature
|
||||
* self.critical_temperature
|
||||
/ self.critical_pressure
|
||||
)
|
||||
|
||||
@property
|
||||
def b_parameter(self) -> float:
|
||||
return 0.07780 * UNIVERSAL_GAS_CONSTANT * self.critical_temperature / self.critical_pressure
|
||||
|
||||
@property
|
||||
def kappa(self) -> float:
|
||||
omega = self.acentric_factor
|
||||
return 0.37464 + 1.54226 * omega - 0.26992 * omega * omega
|
||||
|
||||
def alpha(self, temperature: float) -> float:
|
||||
self._validate_temperature(temperature)
|
||||
reduced_temperature = temperature / self.critical_temperature
|
||||
return (1.0 + self.kappa * (1.0 - sqrt(reduced_temperature))) ** 2.0
|
||||
|
||||
def alpha_temperature_derivative(self, temperature: float) -> float:
|
||||
self._validate_temperature(temperature)
|
||||
reduced_temperature = temperature / self.critical_temperature
|
||||
sqrt_reduced_temperature = sqrt(reduced_temperature)
|
||||
alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature)
|
||||
return -(
|
||||
alpha_base
|
||||
* self.kappa
|
||||
/ (self.critical_temperature * sqrt_reduced_temperature)
|
||||
)
|
||||
|
||||
def attractive_parameter(self, temperature: float) -> float:
|
||||
return self.a_parameter * self.alpha(temperature)
|
||||
|
||||
def attractive_parameter_temperature_derivative(self, temperature: float) -> float:
|
||||
return self.a_parameter * self.alpha_temperature_derivative(temperature)
|
||||
|
||||
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
|
||||
self._validate_temperature(temperature)
|
||||
if molar_volume <= self.b_parameter:
|
||||
raise ValueError("Molar volume must be larger than Peng-Robinson b parameter.")
|
||||
a_alpha = self.attractive_parameter(temperature)
|
||||
b = self.b_parameter
|
||||
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
|
||||
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
|
||||
return repulsive - attractive
|
||||
|
||||
def pressure_from_density(self, temperature: float, density: float) -> float:
|
||||
if density <= 0.0:
|
||||
raise ValueError("Density must be positive.")
|
||||
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
|
||||
|
||||
def reduced_parameters(self, pressure: float, temperature: float) -> tuple[float, float]:
|
||||
self._validate_pressure_temperature(pressure, temperature)
|
||||
a_alpha = self.attractive_parameter(temperature)
|
||||
b = self.b_parameter
|
||||
A = a_alpha * pressure / (UNIVERSAL_GAS_CONSTANT * UNIVERSAL_GAS_CONSTANT * temperature * temperature)
|
||||
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
|
||||
return A, B
|
||||
|
||||
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
|
||||
A, B = self.reduced_parameters(pressure, temperature)
|
||||
coefficients = (
|
||||
-(1.0 - B),
|
||||
A - 3.0 * B * B - 2.0 * B,
|
||||
-(A * B - B * B - B * B * B),
|
||||
)
|
||||
roots = _real_cubic_roots(*coefficients)
|
||||
physical_roots = tuple(sorted(root for root in roots if root > B and isfinite(root)))
|
||||
if not physical_roots:
|
||||
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
|
||||
return physical_roots
|
||||
|
||||
def compressibility_factor(
|
||||
self,
|
||||
pressure: float,
|
||||
temperature: float,
|
||||
phase: str = "vapor",
|
||||
) -> float:
|
||||
roots = self.compressibility_roots(pressure, temperature)
|
||||
if phase == "vapor":
|
||||
return roots[-1]
|
||||
if phase == "liquid":
|
||||
return roots[0]
|
||||
if phase == "stable-single-root":
|
||||
return roots[-1]
|
||||
raise ValueError(f"Unsupported phase selector: {phase!r}")
|
||||
|
||||
def molar_volume(
|
||||
self,
|
||||
pressure: float,
|
||||
temperature: float,
|
||||
phase: str = "vapor",
|
||||
) -> float:
|
||||
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
||||
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
|
||||
|
||||
def density(
|
||||
self,
|
||||
pressure: float,
|
||||
temperature: float,
|
||||
phase: str = "vapor",
|
||||
) -> float:
|
||||
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
|
||||
|
||||
def residual_specific_enthalpy(
|
||||
self,
|
||||
pressure: float,
|
||||
temperature: float,
|
||||
phase: str = "vapor",
|
||||
) -> float:
|
||||
"""Return Peng-Robinson enthalpy departure from ideal gas, J/kg."""
|
||||
self._validate_pressure_temperature(pressure, temperature)
|
||||
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
||||
_, B = self.reduced_parameters(pressure, temperature)
|
||||
b = self.b_parameter
|
||||
attractive = self.attractive_parameter(temperature)
|
||||
d_attractive_d_temperature = (
|
||||
self.attractive_parameter_temperature_derivative(temperature)
|
||||
)
|
||||
log_argument = (z + (1.0 + sqrt(2.0)) * B) / (
|
||||
z + (1.0 - sqrt(2.0)) * B
|
||||
)
|
||||
residual_molar_enthalpy = (
|
||||
UNIVERSAL_GAS_CONSTANT * temperature * (z - 1.0)
|
||||
+ (
|
||||
temperature * d_attractive_d_temperature
|
||||
- attractive
|
||||
)
|
||||
* log(log_argument)
|
||||
/ (2.0 * sqrt(2.0) * b)
|
||||
)
|
||||
return residual_molar_enthalpy / self.molar_mass
|
||||
|
||||
@staticmethod
|
||||
def _validate_temperature(temperature: float) -> None:
|
||||
if temperature <= 0.0:
|
||||
raise ValueError("Temperature must be positive.")
|
||||
|
||||
@classmethod
|
||||
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
|
||||
if pressure <= 0.0:
|
||||
raise ValueError("Pressure must be positive.")
|
||||
cls._validate_temperature(temperature)
|
||||
|
||||
HELIUM_PR = PengRobinsonFluid(
|
||||
name="helium",
|
||||
molar_mass=0.004002602,
|
||||
critical_temperature=5.1953,
|
||||
critical_pressure=227_460.0,
|
||||
acentric_factor=-0.385,
|
||||
)
|
||||
|
||||
NITROGEN_PR = PengRobinsonFluid(
|
||||
name="nitrogen",
|
||||
molar_mass=0.0280134,
|
||||
critical_temperature=126.192,
|
||||
critical_pressure=3.3958e6,
|
||||
acentric_factor=0.0372,
|
||||
)
|
||||
|
||||
AIR_PR = PengRobinsonFluid(
|
||||
name="air",
|
||||
molar_mass=0.02896513,
|
||||
critical_temperature=132.5306,
|
||||
critical_pressure=3.786e6,
|
||||
acentric_factor=0.0335,
|
||||
)
|
||||
|
||||
|
||||
def _real_cubic_roots(a: float, b: float, c: float) -> tuple[float, ...]:
|
||||
"""Return real roots for x**3 + a*x**2 + b*x + c = 0."""
|
||||
|
||||
depressed_p = b - a * a / 3.0
|
||||
depressed_q = 2.0 * a * a * a / 27.0 - a * b / 3.0 + c
|
||||
discriminant = (depressed_q / 2.0) ** 2.0 + (depressed_p / 3.0) ** 3.0
|
||||
offset = -a / 3.0
|
||||
tolerance = 1e-14
|
||||
|
||||
if discriminant > tolerance:
|
||||
sqrt_discriminant = sqrt(discriminant)
|
||||
u = _real_cube_root(-depressed_q / 2.0 + sqrt_discriminant)
|
||||
v = _real_cube_root(-depressed_q / 2.0 - sqrt_discriminant)
|
||||
return (u + v + offset,)
|
||||
|
||||
if abs(discriminant) <= tolerance:
|
||||
u = _real_cube_root(-depressed_q / 2.0)
|
||||
return tuple(sorted({2.0 * u + offset, -u + offset}))
|
||||
|
||||
if depressed_p >= 0.0:
|
||||
raise ValueError("Unexpected cubic state with three real roots and non-negative p.")
|
||||
radius = 2.0 * sqrt(-depressed_p / 3.0)
|
||||
argument = (3.0 * depressed_q / (2.0 * depressed_p)) * sqrt(-3.0 / depressed_p)
|
||||
argument = max(-1.0, min(1.0, argument))
|
||||
theta = acos(argument) / 3.0
|
||||
roots = [
|
||||
radius * cos(theta - 2.0 * pi * index / 3.0) + offset
|
||||
for index in range(3)
|
||||
]
|
||||
return tuple(sorted(roots))
|
||||
|
||||
|
||||
def _real_cube_root(value: float) -> float:
|
||||
if value == 0.0:
|
||||
return 0.0
|
||||
return (1.0 if value > 0.0 else -1.0) * abs(value) ** (1.0 / 3.0)
|
||||
@@ -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,263 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import struct
|
||||
import tarfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class AmesimResultsError(ValueError):
|
||||
"""Raised when AMESim result files cannot be parsed consistently."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimVariable:
|
||||
index: int
|
||||
label: str
|
||||
data_path: str | None
|
||||
param_id: int | None
|
||||
hidden: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimResults:
|
||||
times: tuple[float, ...]
|
||||
variables: tuple[AmesimVariable, ...]
|
||||
saved_variable_indices: tuple[int, ...]
|
||||
series_by_data_path: dict[str, tuple[float, ...]]
|
||||
final_values_by_data_path: dict[str, float]
|
||||
|
||||
@property
|
||||
def point_count(self) -> int:
|
||||
return len(self.times)
|
||||
|
||||
@property
|
||||
def saved_variable_count(self) -> int:
|
||||
return len(self.saved_variable_indices)
|
||||
|
||||
def series(self, data_path: str) -> tuple[float, ...]:
|
||||
return self.series_by_data_path[data_path]
|
||||
|
||||
def final_value(self, data_path: str) -> float:
|
||||
return self.final_values_by_data_path[data_path]
|
||||
|
||||
|
||||
_DATA_PATH_RE = re.compile(r"Data_Path=(\S+)")
|
||||
_PARAM_ID_RE = re.compile(r"Param_Id=(\d+)")
|
||||
|
||||
|
||||
def load_test_mql_amesim_results(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
time_stop_s: float | None = None,
|
||||
) -> AmesimResults:
|
||||
return load_amesim_results_from_archive(
|
||||
archive_path=archive_path,
|
||||
var_member=None,
|
||||
results_member=None,
|
||||
time_stop_s=time_stop_s,
|
||||
)
|
||||
|
||||
|
||||
def load_amesim_results_from_archive(
|
||||
*,
|
||||
archive_path: str | Path,
|
||||
var_member: str | None,
|
||||
results_member: str | None,
|
||||
time_stop_s: float | None = None,
|
||||
) -> AmesimResults:
|
||||
with tarfile.open(archive_path) as archive:
|
||||
var_member, results_member = _resolve_result_members(
|
||||
archive,
|
||||
var_member=var_member,
|
||||
results_member=results_member,
|
||||
)
|
||||
var_file = archive.extractfile(var_member)
|
||||
results_file = archive.extractfile(results_member)
|
||||
if var_file is None:
|
||||
raise AmesimResultsError(f"Missing AMESim variable member: {var_member}")
|
||||
if results_file is None:
|
||||
raise AmesimResultsError(f"Missing AMESim results member: {results_member}")
|
||||
var_lines = var_file.read().decode("latin1").splitlines()
|
||||
variables = tuple(
|
||||
_parse_variable_line(index, line) for index, line in enumerate(var_lines)
|
||||
)
|
||||
if time_stop_s is not None:
|
||||
return _parse_amesim_results_window(
|
||||
results_file,
|
||||
variables,
|
||||
time_stop_s=time_stop_s,
|
||||
)
|
||||
results_data = results_file.read()
|
||||
return parse_amesim_results_bytes(results_data, variables)
|
||||
|
||||
|
||||
def _resolve_result_members(
|
||||
archive: tarfile.TarFile,
|
||||
*,
|
||||
var_member: str | None,
|
||||
results_member: str | None,
|
||||
) -> tuple[str, str]:
|
||||
member_names = set(archive.getnames())
|
||||
if var_member is not None or results_member is not None:
|
||||
if var_member is None or results_member is None:
|
||||
raise AmesimResultsError(
|
||||
"var_member and results_member must either both be set or both be omitted."
|
||||
)
|
||||
return var_member, results_member
|
||||
|
||||
preferred = ("test_mql_.var", "test_mql_.results")
|
||||
if preferred[0] in member_names and preferred[1] in member_names:
|
||||
return preferred
|
||||
|
||||
pairs = sorted(
|
||||
(name, f"{name[:-4]}.results")
|
||||
for name in member_names
|
||||
if name.endswith(".var") and f"{name[:-4]}.results" in member_names
|
||||
)
|
||||
if len(pairs) != 1:
|
||||
raise AmesimResultsError(
|
||||
"Unable to identify a unique AMESim .var/.results member pair."
|
||||
)
|
||||
return pairs[0]
|
||||
|
||||
|
||||
def _parse_amesim_results_window(
|
||||
results_file,
|
||||
variables: tuple[AmesimVariable, ...],
|
||||
*,
|
||||
time_stop_s: float,
|
||||
) -> AmesimResults:
|
||||
header = results_file.read(8)
|
||||
if len(header) < 8:
|
||||
raise AmesimResultsError("AMESim results data is too small.")
|
||||
point_count, encoded_saved_variable_count = struct.unpack("<2i", header)
|
||||
saved_variable_count = abs(encoded_saved_variable_count)
|
||||
if point_count <= 0 or saved_variable_count <= 0:
|
||||
raise AmesimResultsError("Invalid AMESim results header.")
|
||||
|
||||
mapping_data = results_file.read(saved_variable_count * 4)
|
||||
if len(mapping_data) != saved_variable_count * 4:
|
||||
raise AmesimResultsError("AMESim results variable mapping is truncated.")
|
||||
saved_variable_indices = struct.unpack(
|
||||
f"<{saved_variable_count}i",
|
||||
mapping_data,
|
||||
)
|
||||
if any(index < 0 or index >= len(variables) for index in saved_variable_indices):
|
||||
raise AmesimResultsError(
|
||||
"AMESim results variable mapping references unknown .var rows."
|
||||
)
|
||||
|
||||
row_length = 1 + saved_variable_count
|
||||
row_byte_count = row_length * 8
|
||||
times: list[float] = []
|
||||
series_lists: dict[str, list[float]] = {}
|
||||
saved_paths: list[tuple[int, str]] = []
|
||||
for column, variable_index in enumerate(saved_variable_indices, start=1):
|
||||
data_path = variables[variable_index].data_path
|
||||
if data_path is None:
|
||||
continue
|
||||
series_lists[data_path] = []
|
||||
saved_paths.append((column, data_path))
|
||||
|
||||
for _row_index in range(point_count):
|
||||
row = results_file.read(row_byte_count)
|
||||
if len(row) != row_byte_count:
|
||||
raise AmesimResultsError("AMESim results matrix is truncated.")
|
||||
time_s = struct.unpack_from("<d", row, 0)[0]
|
||||
times.append(time_s)
|
||||
for column, data_path in saved_paths:
|
||||
series_lists[data_path].append(
|
||||
struct.unpack_from("<d", row, column * 8)[0]
|
||||
)
|
||||
# Keep one real sample after the requested stop so endpoint finite
|
||||
# differences do not silently fall back to a backward-only slope.
|
||||
if time_s > time_stop_s + 1.0e-12:
|
||||
break
|
||||
|
||||
return AmesimResults(
|
||||
times=tuple(times),
|
||||
variables=variables,
|
||||
saved_variable_indices=tuple(saved_variable_indices),
|
||||
series_by_data_path={
|
||||
data_path: tuple(values) for data_path, values in series_lists.items()
|
||||
},
|
||||
final_values_by_data_path={},
|
||||
)
|
||||
|
||||
|
||||
def parse_amesim_results_bytes(
|
||||
results_data: bytes,
|
||||
variables: tuple[AmesimVariable, ...],
|
||||
) -> AmesimResults:
|
||||
if len(results_data) < 8:
|
||||
raise AmesimResultsError("AMESim results data is too small.")
|
||||
point_count, encoded_saved_variable_count = struct.unpack_from("<2i", results_data, 0)
|
||||
saved_variable_count = abs(encoded_saved_variable_count)
|
||||
if point_count <= 0 or saved_variable_count <= 0:
|
||||
raise AmesimResultsError("Invalid AMESim results header.")
|
||||
|
||||
mapping_offset = 8
|
||||
mapping_size = saved_variable_count * 4
|
||||
data_offset = mapping_offset + mapping_size
|
||||
saved_variable_indices = struct.unpack_from(
|
||||
f"<{saved_variable_count}i",
|
||||
results_data,
|
||||
mapping_offset,
|
||||
)
|
||||
if any(index < 0 or index >= len(variables) for index in saved_variable_indices):
|
||||
raise AmesimResultsError(
|
||||
"AMESim results variable mapping references unknown .var rows."
|
||||
)
|
||||
|
||||
row_length = 1 + saved_variable_count
|
||||
main_value_count = point_count * row_length
|
||||
main_byte_count = main_value_count * 8
|
||||
main_end = data_offset + main_byte_count
|
||||
if main_end > len(results_data):
|
||||
raise AmesimResultsError("AMESim results matrix is truncated.")
|
||||
|
||||
main_values = struct.unpack_from(f"<{main_value_count}d", results_data, data_offset)
|
||||
times = tuple(main_values[row * row_length] for row in range(point_count))
|
||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
||||
for column, variable_index in enumerate(saved_variable_indices, start=1):
|
||||
variable = variables[variable_index]
|
||||
if variable.data_path is None:
|
||||
continue
|
||||
series_by_data_path[variable.data_path] = tuple(
|
||||
main_values[row * row_length + column]
|
||||
for row in range(point_count)
|
||||
)
|
||||
|
||||
final_values_by_data_path: dict[str, float] = {}
|
||||
trailing_bytes = len(results_data) - main_end
|
||||
expected_final_bytes = (1 + len(variables)) * 8
|
||||
if trailing_bytes >= expected_final_bytes:
|
||||
final_values = struct.unpack_from(f"<{1 + len(variables)}d", results_data, main_end)
|
||||
for variable, value in zip(variables, final_values[1:]):
|
||||
if variable.data_path is not None:
|
||||
final_values_by_data_path[variable.data_path] = value
|
||||
|
||||
return AmesimResults(
|
||||
times=times,
|
||||
variables=variables,
|
||||
saved_variable_indices=tuple(saved_variable_indices),
|
||||
series_by_data_path=series_by_data_path,
|
||||
final_values_by_data_path=final_values_by_data_path,
|
||||
)
|
||||
|
||||
|
||||
def _parse_variable_line(index: int, line: str) -> AmesimVariable:
|
||||
data_path_match = _DATA_PATH_RE.search(line)
|
||||
param_id_match = _PARAM_ID_RE.search(line)
|
||||
label = line
|
||||
if data_path_match is not None:
|
||||
label = line[: data_path_match.start()].strip()
|
||||
return AmesimVariable(
|
||||
index=index,
|
||||
label=label,
|
||||
data_path=data_path_match.group(1) if data_path_match else None,
|
||||
param_id=int(param_id_match.group(1)) if param_id_match else None,
|
||||
hidden="HIDDEN" in line,
|
||||
)
|
||||
@@ -1,195 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql_pneumatic import (
|
||||
TestMqlPneumaticAssembly,
|
||||
build_test_mql_pneumatic_assembly,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlChamberObservation:
|
||||
time: float
|
||||
pressure_pa: float
|
||||
temperature_k: float
|
||||
gas_mass_g: float
|
||||
volume_cm3: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlChamberBinding:
|
||||
alias: str
|
||||
submodel: str
|
||||
pressure_path: str
|
||||
temperature_path: str
|
||||
gas_mass_path: str
|
||||
pressure_duplicate_paths: tuple[str, ...]
|
||||
temperature_duplicate_paths: tuple[str, ...]
|
||||
volume_path: str | None
|
||||
|
||||
@property
|
||||
def is_variable(self) -> bool:
|
||||
return self.volume_path is not None
|
||||
|
||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlChamberObservation:
|
||||
return TestMqlChamberObservation(
|
||||
time=results.times[index],
|
||||
pressure_pa=results.series(self.pressure_path)[index],
|
||||
temperature_k=results.series(self.temperature_path)[index],
|
||||
gas_mass_g=results.series(self.gas_mass_path)[index],
|
||||
volume_cm3=(
|
||||
results.series(self.volume_path)[index]
|
||||
if self.volume_path is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlChamberObservationCatalog:
|
||||
bindings: tuple[TestMqlChamberBinding, ...]
|
||||
|
||||
@property
|
||||
def fixed_count(self) -> int:
|
||||
return sum(1 for binding in self.bindings if binding.submodel == "PNCH023")
|
||||
|
||||
@property
|
||||
def variable_count(self) -> int:
|
||||
return sum(1 for binding in self.bindings if binding.submodel == "PNCH012")
|
||||
|
||||
def by_alias(self, alias: str) -> TestMqlChamberBinding:
|
||||
for binding in self.bindings:
|
||||
if binding.alias == alias:
|
||||
return binding
|
||||
raise KeyError(alias)
|
||||
|
||||
|
||||
def build_test_mql_chamber_observation_catalog(
|
||||
results: AmesimResults,
|
||||
*,
|
||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
||||
assembly: TestMqlPneumaticAssembly | None = None,
|
||||
) -> TestMqlChamberObservationCatalog:
|
||||
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
|
||||
assembly = assembly or build_test_mql_pneumatic_assembly()
|
||||
chamber_aliases = {
|
||||
**{alias: "PNCH023" for alias in assembly.fixed_chambers},
|
||||
**{alias: "PNCH012" for alias in assembly.variable_chambers},
|
||||
}
|
||||
bindings = []
|
||||
for alias, submodel in chamber_aliases.items():
|
||||
variables = tuple(
|
||||
variable
|
||||
for variable in variable_catalog.variables
|
||||
if variable.owner_alias == alias
|
||||
)
|
||||
pressure = _primary_observable(variables, "press", expected_units="Pa")
|
||||
temperature = _primary_observable(variables, "temp", expected_units="K")
|
||||
gas_mass = _required_path(
|
||||
variables,
|
||||
"mgas1" if submodel == "PNCH012" else "mgas",
|
||||
expected_units="g",
|
||||
)
|
||||
volume = _optional_path(variables, "vol", expected_units="cm**3")
|
||||
bindings.append(
|
||||
TestMqlChamberBinding(
|
||||
alias=alias,
|
||||
submodel=submodel,
|
||||
pressure_path=pressure.data_path,
|
||||
temperature_path=temperature.data_path,
|
||||
gas_mass_path=gas_mass,
|
||||
pressure_duplicate_paths=_duplicate_paths(variables, "press", expected_units="Pa"),
|
||||
temperature_duplicate_paths=_duplicate_paths(variables, "temp", expected_units="K"),
|
||||
volume_path=volume,
|
||||
)
|
||||
)
|
||||
return TestMqlChamberObservationCatalog(
|
||||
bindings=tuple(sorted(bindings, key=lambda binding: binding.alias))
|
||||
)
|
||||
|
||||
|
||||
def _primary_observable(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
signal_prefix: str,
|
||||
*,
|
||||
expected_units: str,
|
||||
) -> TestMqlVariableBinding:
|
||||
matches = tuple(
|
||||
variable
|
||||
for variable in variables
|
||||
if variable.signal_name == signal_prefix
|
||||
and "duplicate" not in variable.label
|
||||
)
|
||||
variable = _single(matches, f"primary {signal_prefix}")
|
||||
_assert_units(variable, expected_units)
|
||||
return variable
|
||||
|
||||
|
||||
def _duplicate_paths(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
signal_prefix: str,
|
||||
*,
|
||||
expected_units: str,
|
||||
) -> tuple[str, ...]:
|
||||
matches = tuple(
|
||||
variable
|
||||
for variable in variables
|
||||
if variable.signal_name.startswith(signal_prefix)
|
||||
and variable.signal_name != signal_prefix
|
||||
and "duplicate" in variable.label
|
||||
)
|
||||
for variable in matches:
|
||||
_assert_units(variable, expected_units)
|
||||
return tuple(variable.data_path for variable in matches)
|
||||
|
||||
|
||||
def _required_path(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
signal_name: str,
|
||||
*,
|
||||
expected_units: str,
|
||||
) -> str:
|
||||
variable = _single(
|
||||
tuple(variable for variable in variables if variable.signal_name == signal_name),
|
||||
signal_name,
|
||||
)
|
||||
_assert_units(variable, expected_units)
|
||||
return variable.data_path
|
||||
|
||||
|
||||
def _optional_path(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
signal_name: str,
|
||||
*,
|
||||
expected_units: str,
|
||||
) -> str | None:
|
||||
matches = tuple(variable for variable in variables if variable.signal_name == signal_name)
|
||||
if not matches:
|
||||
return None
|
||||
variable = _single(matches, signal_name)
|
||||
_assert_units(variable, expected_units)
|
||||
return variable.data_path
|
||||
|
||||
|
||||
def _single(
|
||||
matches: tuple[TestMqlVariableBinding, ...],
|
||||
description: str,
|
||||
) -> TestMqlVariableBinding:
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _assert_units(variable: TestMqlVariableBinding, expected_units: str) -> None:
|
||||
if variable.units != expected_units:
|
||||
raise ValueError(
|
||||
f"Unexpected units for {variable.data_path}: "
|
||||
f"{variable.units!r}, expected {expected_units!r}."
|
||||
)
|
||||
@@ -1,237 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bisect import bisect_left
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
|
||||
|
||||
DEFAULT_TEST_MQL_ALIGNMENT_PATHS = (
|
||||
"temp3@pn_c1_8",
|
||||
"press3@pn_c1_8",
|
||||
"vvol1@pn_brp2_8",
|
||||
"vol1@pn_brp2_8",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlComparisonMetric:
|
||||
data_path: str
|
||||
sample_count: int
|
||||
max_abs_error: float
|
||||
mean_abs_error: float
|
||||
max_rel_error: float
|
||||
final_abs_error: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlComparisonResult:
|
||||
metrics: tuple[TestMqlComparisonMetric, ...]
|
||||
|
||||
def metric(self, data_path: str) -> TestMqlComparisonMetric:
|
||||
for metric in self.metrics:
|
||||
if metric.data_path == data_path:
|
||||
return metric
|
||||
raise KeyError(data_path)
|
||||
|
||||
@property
|
||||
def max_abs_error(self) -> float:
|
||||
return max((metric.max_abs_error for metric in self.metrics), default=0.0)
|
||||
|
||||
@property
|
||||
def max_rel_error(self) -> float:
|
||||
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
|
||||
|
||||
|
||||
class TestMqlComparisonError(ValueError):
|
||||
"""Raised when Python and AMESim series cannot be aligned."""
|
||||
|
||||
|
||||
def compare_test_mql_series(
|
||||
*,
|
||||
python_times: tuple[float, ...] | list[float],
|
||||
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
||||
amesim_results: AmesimResults,
|
||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||
relative_floor: float = 1.0e-12,
|
||||
) -> TestMqlComparisonResult:
|
||||
_validate_time_axis(python_times)
|
||||
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
|
||||
metrics = []
|
||||
for data_path in selected_paths:
|
||||
python_values = tuple(float(value) for value in python_series_by_data_path[data_path])
|
||||
if len(python_values) != len(python_times):
|
||||
raise TestMqlComparisonError(
|
||||
f"Python series length mismatch for {data_path!r}: "
|
||||
f"{len(python_values)} values for {len(python_times)} time samples."
|
||||
)
|
||||
amesim_values = amesim_results.series(data_path)
|
||||
abs_errors = []
|
||||
rel_errors = []
|
||||
for time_value, python_value in zip(python_times, python_values):
|
||||
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
|
||||
abs_error = abs(python_value - amesim_value)
|
||||
abs_errors.append(abs_error)
|
||||
rel_errors.append(abs_error / max(abs(amesim_value), relative_floor))
|
||||
final_amesim_value = interpolate_series_value(
|
||||
amesim_results.times,
|
||||
amesim_values,
|
||||
float(python_times[-1]),
|
||||
)
|
||||
metrics.append(
|
||||
TestMqlComparisonMetric(
|
||||
data_path=data_path,
|
||||
sample_count=len(python_times),
|
||||
max_abs_error=max(abs_errors, default=0.0),
|
||||
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
|
||||
max_rel_error=max(rel_errors, default=0.0),
|
||||
final_abs_error=abs(python_values[-1] - final_amesim_value),
|
||||
)
|
||||
)
|
||||
return TestMqlComparisonResult(metrics=tuple(metrics))
|
||||
|
||||
|
||||
def write_test_mql_amesim_baseline_csv(
|
||||
output_dir: Path,
|
||||
amesim_results: AmesimResults,
|
||||
data_paths: tuple[str, ...] | list[str] = DEFAULT_TEST_MQL_ALIGNMENT_PATHS,
|
||||
) -> Path:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
csv_path = output_dir / "test_mql_amesim_baseline.csv"
|
||||
_validate_amesim_data_paths(amesim_results, data_paths)
|
||||
with csv_path.open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(["time_s", *data_paths])
|
||||
for index, time_value in enumerate(amesim_results.times):
|
||||
writer.writerow(
|
||||
[time_value, *(amesim_results.series(data_path)[index] for data_path in data_paths)]
|
||||
)
|
||||
return csv_path
|
||||
|
||||
|
||||
def write_test_mql_comparison_csv(
|
||||
*,
|
||||
output_dir: Path,
|
||||
python_times: tuple[float, ...] | list[float],
|
||||
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
||||
amesim_results: AmesimResults,
|
||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||
) -> tuple[Path, Path, TestMqlComparisonResult]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
|
||||
comparison = compare_test_mql_series(
|
||||
python_times=python_times,
|
||||
python_series_by_data_path=python_series_by_data_path,
|
||||
amesim_results=amesim_results,
|
||||
data_paths=selected_paths,
|
||||
)
|
||||
csv_path = output_dir / "test_mql_amesim_comparison.csv"
|
||||
summary_path = output_dir / "test_mql_amesim_comparison_summary.txt"
|
||||
|
||||
with csv_path.open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.writer(handle)
|
||||
header = ["time_s"]
|
||||
for data_path in selected_paths:
|
||||
header.extend(
|
||||
[
|
||||
f"python.{data_path}",
|
||||
f"amesim.{data_path}",
|
||||
f"abs_error.{data_path}",
|
||||
f"rel_error.{data_path}",
|
||||
]
|
||||
)
|
||||
writer.writerow(header)
|
||||
for index, time_value in enumerate(python_times):
|
||||
row = [time_value]
|
||||
for data_path in selected_paths:
|
||||
python_value = float(python_series_by_data_path[data_path][index])
|
||||
amesim_value = interpolate_series_value(
|
||||
amesim_results.times,
|
||||
amesim_results.series(data_path),
|
||||
float(time_value),
|
||||
)
|
||||
abs_error = abs(python_value - amesim_value)
|
||||
rel_error = abs_error / max(abs(amesim_value), 1.0e-12)
|
||||
row.extend([python_value, amesim_value, abs_error, rel_error])
|
||||
writer.writerow(row)
|
||||
|
||||
summary_lines = [
|
||||
(
|
||||
f"{metric.data_path}: samples={metric.sample_count}, "
|
||||
f"max_abs_error={metric.max_abs_error:.12g}, "
|
||||
f"mean_abs_error={metric.mean_abs_error:.12g}, "
|
||||
f"max_rel_error={metric.max_rel_error:.12%}, "
|
||||
f"final_abs_error={metric.final_abs_error:.12g}"
|
||||
)
|
||||
for metric in comparison.metrics
|
||||
]
|
||||
summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8")
|
||||
return csv_path, summary_path, comparison
|
||||
|
||||
|
||||
def interpolate_series_value(
|
||||
time_values: tuple[float, ...] | list[float],
|
||||
values: tuple[float, ...] | list[float],
|
||||
target_time: float,
|
||||
) -> float:
|
||||
if len(time_values) != len(values):
|
||||
raise TestMqlComparisonError("time and value series lengths differ.")
|
||||
if not time_values:
|
||||
raise TestMqlComparisonError("cannot interpolate an empty series.")
|
||||
if target_time <= time_values[0]:
|
||||
return float(values[0])
|
||||
if target_time >= time_values[-1]:
|
||||
return float(values[-1])
|
||||
|
||||
right_index = bisect_left(time_values, target_time)
|
||||
if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1.0e-12:
|
||||
return float(values[right_index])
|
||||
|
||||
left_index = right_index - 1
|
||||
left_time = float(time_values[left_index])
|
||||
right_time = float(time_values[right_index])
|
||||
fraction = (target_time - left_time) / (right_time - left_time)
|
||||
return float(values[left_index]) + fraction * (float(values[right_index]) - float(values[left_index]))
|
||||
|
||||
|
||||
def _select_data_paths(
|
||||
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
||||
amesim_results: AmesimResults,
|
||||
data_paths: tuple[str, ...] | list[str] | None,
|
||||
) -> tuple[str, ...]:
|
||||
if data_paths is None:
|
||||
data_paths = tuple(
|
||||
data_path
|
||||
for data_path in python_series_by_data_path
|
||||
if data_path in amesim_results.series_by_data_path
|
||||
)
|
||||
selected_paths = tuple(data_paths)
|
||||
if not selected_paths:
|
||||
raise TestMqlComparisonError("no common Data_Path values are available for comparison.")
|
||||
missing_python = [data_path for data_path in selected_paths if data_path not in python_series_by_data_path]
|
||||
if missing_python:
|
||||
raise TestMqlComparisonError(f"Python series missing Data_Path values: {missing_python}")
|
||||
_validate_amesim_data_paths(amesim_results, selected_paths)
|
||||
return selected_paths
|
||||
|
||||
|
||||
def _validate_amesim_data_paths(
|
||||
amesim_results: AmesimResults,
|
||||
data_paths: tuple[str, ...] | list[str],
|
||||
) -> None:
|
||||
missing_amesim = [data_path for data_path in data_paths if data_path not in amesim_results.series_by_data_path]
|
||||
if missing_amesim:
|
||||
raise TestMqlComparisonError(f"AMESim results missing Data_Path values: {missing_amesim}")
|
||||
|
||||
|
||||
def _validate_time_axis(time_values: tuple[float, ...] | list[float]) -> None:
|
||||
if not time_values:
|
||||
raise TestMqlComparisonError("Python time axis is empty.")
|
||||
previous = float(time_values[0])
|
||||
for value in time_values[1:]:
|
||||
value = float(value)
|
||||
if value < previous:
|
||||
raise TestMqlComparisonError("Python time axis must be monotonically increasing.")
|
||||
previous = value
|
||||
@@ -1,211 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql_lines import (
|
||||
TestMqlLineAssembly,
|
||||
build_test_mql_line_assembly,
|
||||
)
|
||||
|
||||
|
||||
G_PER_S_TO_KG_PER_S = 1.0e-3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlLineObservation:
|
||||
time: float
|
||||
mass_flows_kg_s: dict[str, float]
|
||||
enthalpy_flows_w: dict[str, float]
|
||||
pressures_pa: dict[str, float]
|
||||
temperatures_k: dict[str, float]
|
||||
gas_mass_g: float | None
|
||||
reynolds_number: float
|
||||
mass_flow_parameter: float
|
||||
gas_velocity_m_s: float
|
||||
friction_factor: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlLineObservationBinding:
|
||||
alias: str
|
||||
submodel: str
|
||||
pattern: str
|
||||
mass_flow_paths: tuple[str, ...]
|
||||
enthalpy_flow_paths: tuple[str, ...]
|
||||
pressure_paths: tuple[str, ...]
|
||||
temperature_paths: tuple[str, ...]
|
||||
gas_mass_path: str | None
|
||||
reynolds_path: str
|
||||
mass_flow_parameter_path: str
|
||||
gas_velocity_path: str
|
||||
friction_factor_path: str
|
||||
|
||||
def mass_flow_kg_s_series(
|
||||
self,
|
||||
results: AmesimResults,
|
||||
data_path: str | None = None,
|
||||
) -> tuple[float, ...]:
|
||||
path = data_path or self.mass_flow_paths[0]
|
||||
if path not in self.mass_flow_paths:
|
||||
raise KeyError(path)
|
||||
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(path))
|
||||
|
||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlLineObservation:
|
||||
return TestMqlLineObservation(
|
||||
time=results.times[index],
|
||||
mass_flows_kg_s={
|
||||
path: results.series(path)[index] * G_PER_S_TO_KG_PER_S
|
||||
for path in self.mass_flow_paths
|
||||
},
|
||||
enthalpy_flows_w={
|
||||
path: results.series(path)[index]
|
||||
for path in self.enthalpy_flow_paths
|
||||
},
|
||||
pressures_pa={
|
||||
path: results.series(path)[index]
|
||||
for path in self.pressure_paths
|
||||
},
|
||||
temperatures_k={
|
||||
path: results.series(path)[index]
|
||||
for path in self.temperature_paths
|
||||
},
|
||||
gas_mass_g=(
|
||||
results.series(self.gas_mass_path)[index]
|
||||
if self.gas_mass_path is not None
|
||||
else None
|
||||
),
|
||||
reynolds_number=results.series(self.reynolds_path)[index],
|
||||
mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index],
|
||||
gas_velocity_m_s=results.series(self.gas_velocity_path)[index],
|
||||
friction_factor=results.series(self.friction_factor_path)[index],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlLineObservationCatalog:
|
||||
bindings: tuple[TestMqlLineObservationBinding, ...]
|
||||
|
||||
@property
|
||||
def line_count(self) -> int:
|
||||
return len(self.bindings)
|
||||
|
||||
def by_alias(self, alias: str) -> TestMqlLineObservationBinding:
|
||||
for binding in self.bindings:
|
||||
if binding.alias == alias:
|
||||
return binding
|
||||
raise KeyError(alias)
|
||||
|
||||
def by_submodel(self, submodel: str) -> tuple[TestMqlLineObservationBinding, ...]:
|
||||
return tuple(binding for binding in self.bindings if binding.submodel == submodel)
|
||||
|
||||
|
||||
def build_test_mql_line_observation_catalog(
|
||||
results: AmesimResults,
|
||||
*,
|
||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
||||
line_assembly: TestMqlLineAssembly | None = None,
|
||||
) -> TestMqlLineObservationCatalog:
|
||||
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
|
||||
line_assembly = line_assembly or build_test_mql_line_assembly(results, variable_catalog)
|
||||
bindings = []
|
||||
for line in line_assembly.lines:
|
||||
variables = tuple(
|
||||
variable
|
||||
for variable in variable_catalog.variables
|
||||
if variable.owner_alias == line.alias
|
||||
)
|
||||
bindings.append(
|
||||
TestMqlLineObservationBinding(
|
||||
alias=line.alias,
|
||||
submodel=line.submodel,
|
||||
pattern=line.pattern,
|
||||
mass_flow_paths=_paths_with_prefix(variables, "dm", expected_units="g/s"),
|
||||
enthalpy_flow_paths=_paths_with_prefix(variables, "dh", expected_units="J/s"),
|
||||
pressure_paths=_paths_with_prefix(variables, "p", expected_units="Pa"),
|
||||
temperature_paths=_paths_with_prefix(variables, "t", expected_units="K"),
|
||||
gas_mass_path=_optional_path(variables, "mgas", expected_units="g"),
|
||||
reynolds_path=_required_path(variables, "re", expected_units=None),
|
||||
mass_flow_parameter_path=_required_path(
|
||||
variables,
|
||||
"cm",
|
||||
expected_units="(kg*K/J)**(1/2)",
|
||||
),
|
||||
gas_velocity_path=_required_path(variables, "v", expected_units="m/s"),
|
||||
friction_factor_path=_required_path(variables, "ff", expected_units=None),
|
||||
)
|
||||
)
|
||||
return TestMqlLineObservationCatalog(bindings=tuple(bindings))
|
||||
|
||||
|
||||
def _paths_with_prefix(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
prefix: str,
|
||||
*,
|
||||
expected_units: str | None,
|
||||
) -> tuple[str, ...]:
|
||||
matches = tuple(
|
||||
variable
|
||||
for variable in variables
|
||||
if variable.signal_name.startswith(prefix)
|
||||
)
|
||||
for variable in matches:
|
||||
_assert_units(variable, expected_units)
|
||||
return tuple(variable.data_path for variable in matches)
|
||||
|
||||
|
||||
def _required_path(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
signal_name: str,
|
||||
*,
|
||||
expected_units: str | None,
|
||||
) -> str:
|
||||
variable = _single_signal(variables, signal_name)
|
||||
_assert_units(variable, expected_units)
|
||||
return variable.data_path
|
||||
|
||||
|
||||
def _optional_path(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
signal_name: str,
|
||||
*,
|
||||
expected_units: str | None,
|
||||
) -> str | None:
|
||||
matches = tuple(variable for variable in variables if variable.signal_name == signal_name)
|
||||
if not matches:
|
||||
return None
|
||||
variable = _single(matches, signal_name)
|
||||
_assert_units(variable, expected_units)
|
||||
return variable.data_path
|
||||
|
||||
|
||||
def _single_signal(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
signal_name: str,
|
||||
) -> TestMqlVariableBinding:
|
||||
return _single(
|
||||
tuple(variable for variable in variables if variable.signal_name == signal_name),
|
||||
signal_name,
|
||||
)
|
||||
|
||||
|
||||
def _single(
|
||||
matches: tuple[TestMqlVariableBinding, ...],
|
||||
description: str,
|
||||
) -> TestMqlVariableBinding:
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None:
|
||||
if variable.units != expected_units:
|
||||
raise ValueError(
|
||||
f"Unexpected units for {variable.data_path}: "
|
||||
f"{variable.units!r}, expected {expected_units!r}."
|
||||
)
|
||||
@@ -1,395 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql_mechanical import (
|
||||
TestMqlMechanicalAssembly,
|
||||
build_test_mql_mechanical_assembly,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPistonObservation:
|
||||
time: float
|
||||
chamber_volume_cm3: float
|
||||
chamber_volume_rate_l_min: float
|
||||
chamber_length_mm: float
|
||||
force_port_2_n: float
|
||||
force_port_3_n: float
|
||||
displacement_port_2_m: float
|
||||
velocity_port_2_m_s: float
|
||||
displacement_port_3_m: float
|
||||
velocity_port_3_m_s: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMassEndstopObservation:
|
||||
time: float
|
||||
displacement_m: float
|
||||
velocity_m_s: float
|
||||
acceleration_m_s2: float
|
||||
lower_contact_force_n: float
|
||||
upper_contact_force_n: float
|
||||
viscous_friction_force_n: float
|
||||
dry_friction_force_n: float
|
||||
stick_flag: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlElasticEndstopObservation:
|
||||
time: float
|
||||
force_n: float
|
||||
duplicate_force_n: float
|
||||
gap_mm: float
|
||||
stiffness_n_m: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlForceSourceObservation:
|
||||
time: float
|
||||
force_n: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlForceConnectorObservation:
|
||||
time: float
|
||||
force_n: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMechanicalNodeObservation:
|
||||
time: float
|
||||
velocities_m_s: dict[int, float]
|
||||
displacements_m: dict[int, float]
|
||||
total_force_n: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPistonObservationBinding:
|
||||
alias: str
|
||||
volume_path: str
|
||||
volume_rate_path: str
|
||||
length_path: str
|
||||
force_port_2_path: str
|
||||
force_port_3_path: str
|
||||
displacement_port_2_path: str
|
||||
velocity_port_2_path: str
|
||||
displacement_port_3_path: str
|
||||
velocity_port_3_path: str
|
||||
|
||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlPistonObservation:
|
||||
return TestMqlPistonObservation(
|
||||
time=results.times[index],
|
||||
chamber_volume_cm3=results.series(self.volume_path)[index],
|
||||
chamber_volume_rate_l_min=results.series(self.volume_rate_path)[index],
|
||||
chamber_length_mm=results.series(self.length_path)[index],
|
||||
force_port_2_n=results.series(self.force_port_2_path)[index],
|
||||
force_port_3_n=results.series(self.force_port_3_path)[index],
|
||||
displacement_port_2_m=results.series(self.displacement_port_2_path)[index],
|
||||
velocity_port_2_m_s=results.series(self.velocity_port_2_path)[index],
|
||||
displacement_port_3_m=results.series(self.displacement_port_3_path)[index],
|
||||
velocity_port_3_m_s=results.series(self.velocity_port_3_path)[index],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMassEndstopObservationBinding:
|
||||
alias: str
|
||||
displacement_path: str
|
||||
velocity_path: str
|
||||
acceleration_path: str
|
||||
displacement_duplicate_path: str
|
||||
velocity_duplicate_path: str
|
||||
acceleration_duplicate_path: str
|
||||
lower_contact_force_path: str
|
||||
upper_contact_force_path: str
|
||||
viscous_friction_force_path: str
|
||||
dry_friction_force_path: str
|
||||
stick_flag_path: str
|
||||
|
||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlMassEndstopObservation:
|
||||
return TestMqlMassEndstopObservation(
|
||||
time=results.times[index],
|
||||
displacement_m=results.series(self.displacement_path)[index],
|
||||
velocity_m_s=results.series(self.velocity_path)[index],
|
||||
acceleration_m_s2=results.series(self.acceleration_path)[index],
|
||||
lower_contact_force_n=results.series(self.lower_contact_force_path)[index],
|
||||
upper_contact_force_n=results.series(self.upper_contact_force_path)[index],
|
||||
viscous_friction_force_n=results.series(self.viscous_friction_force_path)[index],
|
||||
dry_friction_force_n=results.series(self.dry_friction_force_path)[index],
|
||||
stick_flag=results.series(self.stick_flag_path)[index],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlElasticEndstopObservationBinding:
|
||||
alias: str
|
||||
force_path: str
|
||||
duplicate_force_path: str
|
||||
gap_path: str
|
||||
stiffness_path: str
|
||||
|
||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlElasticEndstopObservation:
|
||||
return TestMqlElasticEndstopObservation(
|
||||
time=results.times[index],
|
||||
force_n=results.series(self.force_path)[index],
|
||||
duplicate_force_n=results.series(self.duplicate_force_path)[index],
|
||||
gap_mm=results.series(self.gap_path)[index],
|
||||
stiffness_n_m=results.series(self.stiffness_path)[index],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlForceSourceObservationBinding:
|
||||
alias: str
|
||||
force_path: str
|
||||
|
||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceSourceObservation:
|
||||
return TestMqlForceSourceObservation(
|
||||
time=results.times[index],
|
||||
force_n=results.series(self.force_path)[index],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlForceConnectorObservationBinding:
|
||||
alias: str
|
||||
force_path: str
|
||||
|
||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceConnectorObservation:
|
||||
return TestMqlForceConnectorObservation(
|
||||
time=results.times[index],
|
||||
force_n=results.series(self.force_path)[index],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMechanicalNodeObservationBinding:
|
||||
alias: str
|
||||
velocity_paths_by_port: dict[int, str]
|
||||
displacement_paths_by_port: dict[int, str]
|
||||
total_force_path: str
|
||||
|
||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlMechanicalNodeObservation:
|
||||
return TestMqlMechanicalNodeObservation(
|
||||
time=results.times[index],
|
||||
velocities_m_s={
|
||||
port: results.series(path)[index]
|
||||
for port, path in self.velocity_paths_by_port.items()
|
||||
},
|
||||
displacements_m={
|
||||
port: results.series(path)[index]
|
||||
for port, path in self.displacement_paths_by_port.items()
|
||||
},
|
||||
total_force_n=results.series(self.total_force_path)[index],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMechanicalObservationCatalog:
|
||||
pistons: dict[str, TestMqlPistonObservationBinding]
|
||||
masses: dict[str, TestMqlMassEndstopObservationBinding]
|
||||
elastic_endstops: dict[str, TestMqlElasticEndstopObservationBinding]
|
||||
zero_force_sources: dict[str, TestMqlForceSourceObservationBinding]
|
||||
force_connectors: dict[str, TestMqlForceConnectorObservationBinding]
|
||||
mechanical_nodes: dict[str, TestMqlMechanicalNodeObservationBinding]
|
||||
|
||||
@property
|
||||
def binding_count(self) -> int:
|
||||
return (
|
||||
len(self.pistons)
|
||||
+ len(self.masses)
|
||||
+ len(self.elastic_endstops)
|
||||
+ len(self.zero_force_sources)
|
||||
+ len(self.force_connectors)
|
||||
+ len(self.mechanical_nodes)
|
||||
)
|
||||
|
||||
|
||||
def build_test_mql_mechanical_observation_catalog(
|
||||
results: AmesimResults,
|
||||
*,
|
||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
||||
mechanical_assembly: TestMqlMechanicalAssembly | None = None,
|
||||
) -> TestMqlMechanicalObservationCatalog:
|
||||
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
|
||||
mechanical_assembly = mechanical_assembly or build_test_mql_mechanical_assembly(
|
||||
amesim_results=results,
|
||||
variable_catalog=variable_catalog,
|
||||
)
|
||||
return TestMqlMechanicalObservationCatalog(
|
||||
pistons={
|
||||
alias: _build_piston_binding(alias, variable_catalog)
|
||||
for alias in mechanical_assembly.pistons
|
||||
},
|
||||
masses={
|
||||
alias: _build_mass_binding(alias, variable_catalog)
|
||||
for alias in mechanical_assembly.masses
|
||||
},
|
||||
elastic_endstops={
|
||||
alias: _build_elastic_endstop_binding(alias, variable_catalog)
|
||||
for alias in mechanical_assembly.elastic_endstops
|
||||
},
|
||||
zero_force_sources={
|
||||
alias: _build_zero_force_source_binding(alias, variable_catalog)
|
||||
for alias in mechanical_assembly.zero_force_sources
|
||||
},
|
||||
force_connectors={
|
||||
alias: _build_force_connector_binding(alias, variable_catalog)
|
||||
for alias in mechanical_assembly.force_connectors
|
||||
},
|
||||
mechanical_nodes={
|
||||
alias: _build_mechanical_node_binding(alias, variable_catalog)
|
||||
for alias in mechanical_assembly.mechanical_nodes
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _build_piston_binding(
|
||||
alias: str,
|
||||
variable_catalog: TestMqlVariableCatalog,
|
||||
) -> TestMqlPistonObservationBinding:
|
||||
variables = _owner_variables(variable_catalog, alias)
|
||||
return TestMqlPistonObservationBinding(
|
||||
alias=alias,
|
||||
volume_path=_required_path(variables, "vol1", expected_units="cm**3"),
|
||||
volume_rate_path=_required_path(variables, "vvol1", expected_units="L/min"),
|
||||
length_path=_required_path(variables, "length", expected_units="mm"),
|
||||
force_port_2_path=_required_path(variables, "f2", expected_units="N"),
|
||||
force_port_3_path=_required_path(variables, "f3", expected_units="N"),
|
||||
displacement_port_2_path=_required_path(variables, "x5", expected_units="m"),
|
||||
velocity_port_2_path=_required_path(variables, "v5", expected_units="m/s"),
|
||||
displacement_port_3_path=_required_path(variables, "x4", expected_units="m"),
|
||||
velocity_port_3_path=_required_path(variables, "v4", expected_units="m/s"),
|
||||
)
|
||||
|
||||
|
||||
def _build_mass_binding(
|
||||
alias: str,
|
||||
variable_catalog: TestMqlVariableCatalog,
|
||||
) -> TestMqlMassEndstopObservationBinding:
|
||||
variables = _owner_variables(variable_catalog, alias)
|
||||
return TestMqlMassEndstopObservationBinding(
|
||||
alias=alias,
|
||||
displacement_path=_required_path(variables, "x1", expected_units="m"),
|
||||
velocity_path=_required_path(variables, "v1", expected_units="m/s"),
|
||||
acceleration_path=_required_path(variables, "acc1", expected_units="m/s/s"),
|
||||
displacement_duplicate_path=_required_path(variables, "x1dup", expected_units="m"),
|
||||
velocity_duplicate_path=_required_path(variables, "v1dup", expected_units="m/s"),
|
||||
acceleration_duplicate_path=_required_path(variables, "acc1dup", expected_units="m/s/s"),
|
||||
lower_contact_force_path=_required_path(variables, "Fmin", expected_units="N"),
|
||||
upper_contact_force_path=_required_path(variables, "Fmax", expected_units="N"),
|
||||
viscous_friction_force_path=_required_path(variables, "Fvisc", expected_units="N"),
|
||||
dry_friction_force_path=_required_path(variables, "Ffric", expected_units="N"),
|
||||
stick_flag_path=_required_path(variables, "stick", expected_units=None),
|
||||
)
|
||||
|
||||
|
||||
def _build_elastic_endstop_binding(
|
||||
alias: str,
|
||||
variable_catalog: TestMqlVariableCatalog,
|
||||
) -> TestMqlElasticEndstopObservationBinding:
|
||||
variables = _owner_variables(variable_catalog, alias)
|
||||
return TestMqlElasticEndstopObservationBinding(
|
||||
alias=alias,
|
||||
force_path=_required_path(variables, "f1", expected_units="N"),
|
||||
duplicate_force_path=_required_path(variables, "f2", expected_units="N"),
|
||||
gap_path=_required_path(variables, "gap", expected_units="mm"),
|
||||
stiffness_path=_required_path(variables, "kval", expected_units="N/m"),
|
||||
)
|
||||
|
||||
|
||||
def _build_zero_force_source_binding(
|
||||
alias: str,
|
||||
variable_catalog: TestMqlVariableCatalog,
|
||||
) -> TestMqlForceSourceObservationBinding:
|
||||
variables = _owner_variables(variable_catalog, alias)
|
||||
return TestMqlForceSourceObservationBinding(
|
||||
alias=alias,
|
||||
force_path=_required_path(variables, "fzero", expected_units="N"),
|
||||
)
|
||||
|
||||
|
||||
def _build_force_connector_binding(
|
||||
alias: str,
|
||||
variable_catalog: TestMqlVariableCatalog,
|
||||
) -> TestMqlForceConnectorObservationBinding:
|
||||
variables = _owner_variables(variable_catalog, alias)
|
||||
return TestMqlForceConnectorObservationBinding(
|
||||
alias=alias,
|
||||
force_path=_required_path(variables, "force", expected_units="N"),
|
||||
)
|
||||
|
||||
|
||||
def _build_mechanical_node_binding(
|
||||
alias: str,
|
||||
variable_catalog: TestMqlVariableCatalog,
|
||||
) -> TestMqlMechanicalNodeObservationBinding:
|
||||
variables = _owner_variables(variable_catalog, alias)
|
||||
velocity_paths_by_port = {}
|
||||
displacement_paths_by_port = {}
|
||||
for port in range(1, 9):
|
||||
velocity_paths_by_port[port] = _required_path(
|
||||
variables,
|
||||
f"p{port}__vt",
|
||||
expected_units="m/s",
|
||||
)
|
||||
displacement_paths_by_port[port] = _required_path(
|
||||
variables,
|
||||
f"p{port}__xt",
|
||||
expected_units="m",
|
||||
)
|
||||
return TestMqlMechanicalNodeObservationBinding(
|
||||
alias=alias,
|
||||
velocity_paths_by_port=velocity_paths_by_port,
|
||||
displacement_paths_by_port=displacement_paths_by_port,
|
||||
total_force_path=_required_path(variables, "tforce", expected_units="N"),
|
||||
)
|
||||
|
||||
|
||||
def _owner_variables(
|
||||
variable_catalog: TestMqlVariableCatalog,
|
||||
alias: str,
|
||||
) -> tuple[TestMqlVariableBinding, ...]:
|
||||
return tuple(
|
||||
variable
|
||||
for variable in variable_catalog.variables
|
||||
if variable.owner_alias == alias
|
||||
)
|
||||
|
||||
|
||||
def _required_path(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
signal_name: str,
|
||||
*,
|
||||
expected_units: str | None,
|
||||
) -> str:
|
||||
variable = _single(
|
||||
tuple(variable for variable in variables if variable.signal_name == signal_name),
|
||||
signal_name,
|
||||
)
|
||||
_assert_units(variable, expected_units)
|
||||
return variable.data_path
|
||||
|
||||
|
||||
def _single(
|
||||
matches: tuple[TestMqlVariableBinding, ...],
|
||||
description: str,
|
||||
) -> TestMqlVariableBinding:
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None:
|
||||
if variable.units != expected_units:
|
||||
raise ValueError(
|
||||
f"Unexpected units for {variable.data_path}: "
|
||||
f"{variable.units!r}, expected {expected_units!r}."
|
||||
)
|
||||
@@ -1,210 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_chamber_observations import (
|
||||
TestMqlChamberObservationCatalog,
|
||||
build_test_mql_chamber_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_line_observations import (
|
||||
TestMqlLineObservationCatalog,
|
||||
build_test_mql_line_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_mechanical_observations import (
|
||||
TestMqlMechanicalObservationCatalog,
|
||||
build_test_mql_mechanical_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_orifice_observations import (
|
||||
TestMqlOrificeObservationCatalog,
|
||||
build_test_mql_orifice_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlObservationCatalog:
|
||||
variable_catalog: TestMqlVariableCatalog
|
||||
chambers: TestMqlChamberObservationCatalog
|
||||
orifices: TestMqlOrificeObservationCatalog
|
||||
lines: TestMqlLineObservationCatalog
|
||||
mechanical: TestMqlMechanicalObservationCatalog
|
||||
|
||||
@property
|
||||
def binding_count(self) -> int:
|
||||
return (
|
||||
len(self.chambers.bindings)
|
||||
+ len(self.orifices.bindings)
|
||||
+ self.lines.line_count
|
||||
+ self.mechanical.binding_count
|
||||
)
|
||||
|
||||
def data_paths_by_domain(self) -> dict[str, tuple[str, ...]]:
|
||||
return {
|
||||
"chambers": _sorted_unique(_chamber_data_paths(self.chambers)),
|
||||
"orifices": _sorted_unique(_orifice_data_paths(self.orifices)),
|
||||
"lines": _sorted_unique(_line_data_paths(self.lines)),
|
||||
"mechanical": _sorted_unique(_mechanical_data_paths(self.mechanical)),
|
||||
}
|
||||
|
||||
def data_paths(self) -> tuple[str, ...]:
|
||||
paths = []
|
||||
for domain_paths in self.data_paths_by_domain().values():
|
||||
paths.extend(domain_paths)
|
||||
return _sorted_unique(paths)
|
||||
|
||||
def baseline_series_by_data_path(
|
||||
self,
|
||||
results: AmesimResults,
|
||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||
) -> dict[str, tuple[float, ...]]:
|
||||
selected_paths = tuple(data_paths) if data_paths is not None else self.data_paths()
|
||||
_validate_observed_paths(self, selected_paths)
|
||||
return {data_path: results.series(data_path) for data_path in selected_paths}
|
||||
|
||||
|
||||
def build_test_mql_observation_catalog(results: AmesimResults) -> TestMqlObservationCatalog:
|
||||
variable_catalog = build_test_mql_variable_catalog(results)
|
||||
return TestMqlObservationCatalog(
|
||||
variable_catalog=variable_catalog,
|
||||
chambers=build_test_mql_chamber_observation_catalog(
|
||||
results,
|
||||
variable_catalog=variable_catalog,
|
||||
),
|
||||
orifices=build_test_mql_orifice_observation_catalog(
|
||||
results,
|
||||
variable_catalog=variable_catalog,
|
||||
),
|
||||
lines=build_test_mql_line_observation_catalog(
|
||||
results,
|
||||
variable_catalog=variable_catalog,
|
||||
),
|
||||
mechanical=build_test_mql_mechanical_observation_catalog(
|
||||
results,
|
||||
variable_catalog=variable_catalog,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _chamber_data_paths(catalog: TestMqlChamberObservationCatalog) -> tuple[str, ...]:
|
||||
paths = []
|
||||
for binding in catalog.bindings:
|
||||
paths.extend(
|
||||
[
|
||||
binding.pressure_path,
|
||||
binding.temperature_path,
|
||||
binding.gas_mass_path,
|
||||
*binding.pressure_duplicate_paths,
|
||||
*binding.temperature_duplicate_paths,
|
||||
]
|
||||
)
|
||||
if binding.volume_path is not None:
|
||||
paths.append(binding.volume_path)
|
||||
return tuple(paths)
|
||||
|
||||
|
||||
def _orifice_data_paths(catalog: TestMqlOrificeObservationCatalog) -> tuple[str, ...]:
|
||||
paths = []
|
||||
for binding in catalog.bindings:
|
||||
paths.extend(
|
||||
[
|
||||
binding.primary_mass_flow_path,
|
||||
binding.primary_enthalpy_flow_path,
|
||||
binding.reversed_mass_flow_path,
|
||||
binding.reversed_enthalpy_flow_path,
|
||||
binding.mass_flow_parameter_path,
|
||||
binding.gas_velocity_path,
|
||||
]
|
||||
)
|
||||
if binding.opening_path is not None:
|
||||
paths.append(binding.opening_path)
|
||||
return tuple(paths)
|
||||
|
||||
|
||||
def _line_data_paths(catalog: TestMqlLineObservationCatalog) -> tuple[str, ...]:
|
||||
paths = []
|
||||
for binding in catalog.bindings:
|
||||
paths.extend(binding.mass_flow_paths)
|
||||
paths.extend(binding.enthalpy_flow_paths)
|
||||
paths.extend(binding.pressure_paths)
|
||||
paths.extend(binding.temperature_paths)
|
||||
if binding.gas_mass_path is not None:
|
||||
paths.append(binding.gas_mass_path)
|
||||
paths.extend(
|
||||
[
|
||||
binding.reynolds_path,
|
||||
binding.mass_flow_parameter_path,
|
||||
binding.gas_velocity_path,
|
||||
binding.friction_factor_path,
|
||||
]
|
||||
)
|
||||
return tuple(paths)
|
||||
|
||||
|
||||
def _mechanical_data_paths(catalog: TestMqlMechanicalObservationCatalog) -> tuple[str, ...]:
|
||||
paths = []
|
||||
for binding in catalog.pistons.values():
|
||||
paths.extend(
|
||||
[
|
||||
binding.volume_path,
|
||||
binding.volume_rate_path,
|
||||
binding.length_path,
|
||||
binding.force_port_2_path,
|
||||
binding.force_port_3_path,
|
||||
binding.displacement_port_2_path,
|
||||
binding.velocity_port_2_path,
|
||||
binding.displacement_port_3_path,
|
||||
binding.velocity_port_3_path,
|
||||
]
|
||||
)
|
||||
for binding in catalog.masses.values():
|
||||
paths.extend(
|
||||
[
|
||||
binding.displacement_path,
|
||||
binding.velocity_path,
|
||||
binding.acceleration_path,
|
||||
binding.displacement_duplicate_path,
|
||||
binding.velocity_duplicate_path,
|
||||
binding.acceleration_duplicate_path,
|
||||
binding.lower_contact_force_path,
|
||||
binding.upper_contact_force_path,
|
||||
binding.viscous_friction_force_path,
|
||||
binding.dry_friction_force_path,
|
||||
binding.stick_flag_path,
|
||||
]
|
||||
)
|
||||
for binding in catalog.elastic_endstops.values():
|
||||
paths.extend(
|
||||
[
|
||||
binding.force_path,
|
||||
binding.duplicate_force_path,
|
||||
binding.gap_path,
|
||||
binding.stiffness_path,
|
||||
]
|
||||
)
|
||||
for binding in catalog.zero_force_sources.values():
|
||||
paths.append(binding.force_path)
|
||||
for binding in catalog.force_connectors.values():
|
||||
paths.append(binding.force_path)
|
||||
for binding in catalog.mechanical_nodes.values():
|
||||
paths.extend(binding.velocity_paths_by_port.values())
|
||||
paths.extend(binding.displacement_paths_by_port.values())
|
||||
paths.append(binding.total_force_path)
|
||||
return tuple(paths)
|
||||
|
||||
|
||||
def _validate_observed_paths(
|
||||
catalog: TestMqlObservationCatalog,
|
||||
data_paths: tuple[str, ...],
|
||||
) -> None:
|
||||
observed_paths = set(catalog.data_paths())
|
||||
missing = [data_path for data_path in data_paths if data_path not in observed_paths]
|
||||
if missing:
|
||||
raise KeyError(f"Data_Path values are not in the test_mql observation catalog: {missing}")
|
||||
|
||||
|
||||
def _sorted_unique(data_paths: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
||||
return tuple(sorted(set(data_paths)))
|
||||
@@ -1,194 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql_pneumatic import (
|
||||
TestMqlPneumaticAssembly,
|
||||
build_test_mql_pneumatic_assembly,
|
||||
)
|
||||
|
||||
|
||||
G_PER_S_TO_KG_PER_S = 1.0e-3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlOrificeObservation:
|
||||
time: float
|
||||
mass_flow_kg_s: float
|
||||
enthalpy_flow_w: float
|
||||
mass_flow_parameter: float
|
||||
gas_velocity_m_s: float
|
||||
opening: float
|
||||
effective_area_m2: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlOrificeBinding:
|
||||
alias: str
|
||||
submodel: str
|
||||
nominal_area_m2: float
|
||||
flow_coefficient: float
|
||||
primary_mass_flow_path: str
|
||||
primary_enthalpy_flow_path: str
|
||||
reversed_mass_flow_path: str
|
||||
reversed_enthalpy_flow_path: str
|
||||
mass_flow_parameter_path: str
|
||||
gas_velocity_path: str
|
||||
opening_path: str | None
|
||||
|
||||
@property
|
||||
def is_variable(self) -> bool:
|
||||
return self.opening_path is not None
|
||||
|
||||
def opening_series(self, results: AmesimResults) -> tuple[float, ...]:
|
||||
if self.opening_path is None:
|
||||
return tuple(1.0 for _ in results.times)
|
||||
return tuple(results.series(self.opening_path))
|
||||
|
||||
def mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]:
|
||||
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.primary_mass_flow_path))
|
||||
|
||||
def reversed_mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]:
|
||||
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.reversed_mass_flow_path))
|
||||
|
||||
def effective_area_series(self, results: AmesimResults) -> tuple[float, ...]:
|
||||
return tuple(self.nominal_area_m2 * max(opening, 0.0) for opening in self.opening_series(results))
|
||||
|
||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlOrificeObservation:
|
||||
opening = self.opening_series(results)[index]
|
||||
return TestMqlOrificeObservation(
|
||||
time=results.times[index],
|
||||
mass_flow_kg_s=results.series(self.primary_mass_flow_path)[index] * G_PER_S_TO_KG_PER_S,
|
||||
enthalpy_flow_w=results.series(self.primary_enthalpy_flow_path)[index],
|
||||
mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index],
|
||||
gas_velocity_m_s=results.series(self.gas_velocity_path)[index],
|
||||
opening=opening,
|
||||
effective_area_m2=self.nominal_area_m2 * max(opening, 0.0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlOrificeObservationCatalog:
|
||||
bindings: tuple[TestMqlOrificeBinding, ...]
|
||||
|
||||
@property
|
||||
def fixed_count(self) -> int:
|
||||
return sum(1 for binding in self.bindings if binding.submodel == "PNOR001")
|
||||
|
||||
@property
|
||||
def variable_count(self) -> int:
|
||||
return sum(1 for binding in self.bindings if binding.submodel == "PNVO001")
|
||||
|
||||
def by_alias(self, alias: str) -> TestMqlOrificeBinding:
|
||||
for binding in self.bindings:
|
||||
if binding.alias == alias:
|
||||
return binding
|
||||
raise KeyError(alias)
|
||||
|
||||
|
||||
def build_test_mql_orifice_observation_catalog(
|
||||
results: AmesimResults,
|
||||
*,
|
||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
||||
assembly: TestMqlPneumaticAssembly | None = None,
|
||||
) -> TestMqlOrificeObservationCatalog:
|
||||
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
|
||||
assembly = assembly or build_test_mql_pneumatic_assembly()
|
||||
bindings = []
|
||||
for alias, orifice in {
|
||||
**assembly.fixed_orifices,
|
||||
**assembly.variable_orifices,
|
||||
}.items():
|
||||
owner_variables = tuple(
|
||||
variable
|
||||
for variable in variable_catalog.variables
|
||||
if variable.owner_alias == alias
|
||||
)
|
||||
primary_mass_flow = _find_primary(owner_variables, signal_prefix="dm")
|
||||
primary_enthalpy_flow = _find_primary(owner_variables, signal_prefix="dh")
|
||||
reversed_mass_flow = _find_reversed(owner_variables, signal_prefix="dm")
|
||||
reversed_enthalpy_flow = _find_reversed(owner_variables, signal_prefix="dh")
|
||||
mass_flow_parameter = _find_by_signal(owner_variables, "cm")
|
||||
gas_velocity = _find_by_signal(owner_variables, "gasvel")
|
||||
opening = _find_optional_by_signal(owner_variables, "xv")
|
||||
bindings.append(
|
||||
TestMqlOrificeBinding(
|
||||
alias=alias,
|
||||
submodel=primary_mass_flow.submodel,
|
||||
nominal_area_m2=orifice.area,
|
||||
flow_coefficient=orifice.flow_coefficient,
|
||||
primary_mass_flow_path=primary_mass_flow.data_path,
|
||||
primary_enthalpy_flow_path=primary_enthalpy_flow.data_path,
|
||||
reversed_mass_flow_path=reversed_mass_flow.data_path,
|
||||
reversed_enthalpy_flow_path=reversed_enthalpy_flow.data_path,
|
||||
mass_flow_parameter_path=mass_flow_parameter.data_path,
|
||||
gas_velocity_path=gas_velocity.data_path,
|
||||
opening_path=opening.data_path if opening is not None else None,
|
||||
)
|
||||
)
|
||||
return TestMqlOrificeObservationCatalog(
|
||||
bindings=tuple(sorted(bindings, key=lambda binding: binding.alias))
|
||||
)
|
||||
|
||||
|
||||
def _find_primary(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
*,
|
||||
signal_prefix: str,
|
||||
) -> TestMqlVariableBinding:
|
||||
matches = [
|
||||
variable
|
||||
for variable in variables
|
||||
if variable.signal_name.startswith(signal_prefix)
|
||||
and "sign reversed duplicate" not in variable.label
|
||||
]
|
||||
return _single(matches, f"primary {signal_prefix}")
|
||||
|
||||
|
||||
def _find_reversed(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
*,
|
||||
signal_prefix: str,
|
||||
) -> TestMqlVariableBinding:
|
||||
matches = [
|
||||
variable
|
||||
for variable in variables
|
||||
if variable.signal_name.startswith(signal_prefix)
|
||||
and "sign reversed duplicate" in variable.label
|
||||
]
|
||||
return _single(matches, f"reversed {signal_prefix}")
|
||||
|
||||
|
||||
def _find_by_signal(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
signal_name: str,
|
||||
) -> TestMqlVariableBinding:
|
||||
return _single(
|
||||
[variable for variable in variables if variable.signal_name == signal_name],
|
||||
signal_name,
|
||||
)
|
||||
|
||||
|
||||
def _find_optional_by_signal(
|
||||
variables: tuple[TestMqlVariableBinding, ...],
|
||||
signal_name: str,
|
||||
) -> TestMqlVariableBinding | None:
|
||||
matches = [variable for variable in variables if variable.signal_name == signal_name]
|
||||
if not matches:
|
||||
return None
|
||||
return _single(matches, signal_name)
|
||||
|
||||
|
||||
def _single(
|
||||
matches: list[TestMqlVariableBinding],
|
||||
description: str,
|
||||
) -> TestMqlVariableBinding:
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
|
||||
return matches[0]
|
||||
@@ -1,100 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_observations import (
|
||||
TestMqlObservationCatalog,
|
||||
build_test_mql_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_variables import TestMqlVariableBinding
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlOutputSignal:
|
||||
data_path: str
|
||||
domain: str
|
||||
owner_alias: str
|
||||
owner_kind: str
|
||||
submodel: str
|
||||
signal_name: str
|
||||
units: str | None
|
||||
amesim_index: int
|
||||
saved: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlOutputSchema:
|
||||
signals: tuple[TestMqlOutputSignal, ...]
|
||||
|
||||
@property
|
||||
def signal_count(self) -> int:
|
||||
return len(self.signals)
|
||||
|
||||
def by_data_path(self, data_path: str) -> TestMqlOutputSignal:
|
||||
for signal in self.signals:
|
||||
if signal.data_path == data_path:
|
||||
return signal
|
||||
raise KeyError(data_path)
|
||||
|
||||
def data_paths(self) -> tuple[str, ...]:
|
||||
return tuple(signal.data_path for signal in self.signals)
|
||||
|
||||
def data_paths_by_domain(self, domain: str) -> tuple[str, ...]:
|
||||
return tuple(signal.data_path for signal in self.signals if signal.domain == domain)
|
||||
|
||||
def counts_by_domain(self) -> dict[str, int]:
|
||||
return dict(Counter(signal.domain for signal in self.signals))
|
||||
|
||||
def counts_by_submodel(self) -> dict[str, int]:
|
||||
return dict(Counter(signal.submodel for signal in self.signals))
|
||||
|
||||
def counts_by_owner_kind(self) -> dict[str, int]:
|
||||
return dict(Counter(signal.owner_kind for signal in self.signals))
|
||||
|
||||
def counts_by_units(self) -> dict[str | None, int]:
|
||||
return dict(Counter(signal.units for signal in self.signals))
|
||||
|
||||
|
||||
def build_test_mql_output_schema(
|
||||
results: AmesimResults,
|
||||
*,
|
||||
observation_catalog: TestMqlObservationCatalog | None = None,
|
||||
) -> TestMqlOutputSchema:
|
||||
observation_catalog = observation_catalog or build_test_mql_observation_catalog(results)
|
||||
domain_by_data_path = _domain_by_data_path(observation_catalog)
|
||||
signals = []
|
||||
for data_path in sorted(domain_by_data_path):
|
||||
variable = observation_catalog.variable_catalog.by_data_path(data_path)
|
||||
signals.append(_signal_from_variable(variable, domain_by_data_path[data_path]))
|
||||
return TestMqlOutputSchema(signals=tuple(signals))
|
||||
|
||||
|
||||
def _domain_by_data_path(
|
||||
observation_catalog: TestMqlObservationCatalog,
|
||||
) -> dict[str, str]:
|
||||
domain_by_data_path = {}
|
||||
for domain, data_paths in observation_catalog.data_paths_by_domain().items():
|
||||
for data_path in data_paths:
|
||||
if data_path in domain_by_data_path:
|
||||
raise ValueError(f"Data_Path {data_path!r} is assigned to multiple domains.")
|
||||
domain_by_data_path[data_path] = domain
|
||||
return domain_by_data_path
|
||||
|
||||
|
||||
def _signal_from_variable(
|
||||
variable: TestMqlVariableBinding,
|
||||
domain: str,
|
||||
) -> TestMqlOutputSignal:
|
||||
return TestMqlOutputSignal(
|
||||
data_path=variable.data_path,
|
||||
domain=domain,
|
||||
owner_alias=variable.owner_alias,
|
||||
owner_kind=variable.owner_kind,
|
||||
submodel=variable.submodel,
|
||||
signal_name=variable.signal_name,
|
||||
units=variable.units,
|
||||
amesim_index=variable.index,
|
||||
saved=variable.saved,
|
||||
)
|
||||
@@ -1,161 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_comparison import (
|
||||
TestMqlComparisonResult,
|
||||
compare_test_mql_series,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_schema import TestMqlOutputSchema
|
||||
|
||||
|
||||
class TestMqlOutputValidationError(ValueError):
|
||||
"""Raised when a Python test_mql output does not satisfy the AMESim output contract."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlValidatedOutput:
|
||||
times: tuple[float, ...]
|
||||
series_by_data_path: dict[str, tuple[float, ...]]
|
||||
data_paths: tuple[str, ...]
|
||||
|
||||
def series(self, data_path: str) -> tuple[float, ...]:
|
||||
if data_path not in self.series_by_data_path:
|
||||
raise KeyError(data_path)
|
||||
return self.series_by_data_path[data_path]
|
||||
|
||||
|
||||
def validate_test_mql_output(
|
||||
*,
|
||||
times: tuple[float, ...] | list[float],
|
||||
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
||||
schema: TestMqlOutputSchema,
|
||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||
allow_extra_paths: bool = False,
|
||||
require_all_schema_paths: bool = False,
|
||||
) -> TestMqlValidatedOutput:
|
||||
validated_times = _validate_time_axis(times)
|
||||
selected_paths = _select_paths(
|
||||
series_by_data_path=series_by_data_path,
|
||||
schema=schema,
|
||||
data_paths=data_paths,
|
||||
allow_extra_paths=allow_extra_paths,
|
||||
require_all_schema_paths=require_all_schema_paths,
|
||||
)
|
||||
validated_series = {
|
||||
data_path: _validate_series(
|
||||
data_path=data_path,
|
||||
values=series_by_data_path[data_path],
|
||||
expected_count=len(validated_times),
|
||||
)
|
||||
for data_path in selected_paths
|
||||
}
|
||||
return TestMqlValidatedOutput(
|
||||
times=validated_times,
|
||||
series_by_data_path=validated_series,
|
||||
data_paths=selected_paths,
|
||||
)
|
||||
|
||||
|
||||
def compare_validated_test_mql_output(
|
||||
*,
|
||||
times: tuple[float, ...] | list[float],
|
||||
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
||||
schema: TestMqlOutputSchema,
|
||||
amesim_results: AmesimResults,
|
||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||
allow_extra_paths: bool = False,
|
||||
require_all_schema_paths: bool = False,
|
||||
relative_floor: float = 1.0e-12,
|
||||
) -> TestMqlComparisonResult:
|
||||
validated = validate_test_mql_output(
|
||||
times=times,
|
||||
series_by_data_path=series_by_data_path,
|
||||
schema=schema,
|
||||
data_paths=data_paths,
|
||||
allow_extra_paths=allow_extra_paths,
|
||||
require_all_schema_paths=require_all_schema_paths,
|
||||
)
|
||||
return compare_test_mql_series(
|
||||
python_times=validated.times,
|
||||
python_series_by_data_path=validated.series_by_data_path,
|
||||
amesim_results=amesim_results,
|
||||
data_paths=validated.data_paths,
|
||||
relative_floor=relative_floor,
|
||||
)
|
||||
|
||||
|
||||
def _validate_time_axis(times: tuple[float, ...] | list[float]) -> tuple[float, ...]:
|
||||
if not times:
|
||||
raise TestMqlOutputValidationError("Python time axis is empty.")
|
||||
validated = tuple(_finite_float("time", value) for value in times)
|
||||
previous = validated[0]
|
||||
for value in validated[1:]:
|
||||
if value < previous:
|
||||
raise TestMqlOutputValidationError("Python time axis must be monotonically increasing.")
|
||||
previous = value
|
||||
return validated
|
||||
|
||||
|
||||
def _select_paths(
|
||||
*,
|
||||
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
||||
schema: TestMqlOutputSchema,
|
||||
data_paths: tuple[str, ...] | list[str] | None,
|
||||
allow_extra_paths: bool,
|
||||
require_all_schema_paths: bool,
|
||||
) -> tuple[str, ...]:
|
||||
schema_paths = set(schema.data_paths())
|
||||
provided_paths = set(series_by_data_path)
|
||||
if not allow_extra_paths:
|
||||
extra_paths = sorted(provided_paths - schema_paths)
|
||||
if extra_paths:
|
||||
raise TestMqlOutputValidationError(
|
||||
f"Python output contains Data_Path values outside test_mql schema: {extra_paths}"
|
||||
)
|
||||
if require_all_schema_paths:
|
||||
missing_schema_paths = sorted(schema_paths - provided_paths)
|
||||
if missing_schema_paths:
|
||||
raise TestMqlOutputValidationError(
|
||||
f"Python output is missing required test_mql schema Data_Path values: {missing_schema_paths}"
|
||||
)
|
||||
selected_paths = tuple(data_paths) if data_paths is not None else tuple(sorted(provided_paths & schema_paths))
|
||||
if not selected_paths:
|
||||
raise TestMqlOutputValidationError("no test_mql schema Data_Path values are available.")
|
||||
unknown_selected = [data_path for data_path in selected_paths if data_path not in schema_paths]
|
||||
if unknown_selected:
|
||||
raise TestMqlOutputValidationError(
|
||||
f"Requested Data_Path values are outside test_mql schema: {unknown_selected}"
|
||||
)
|
||||
missing_selected = [data_path for data_path in selected_paths if data_path not in series_by_data_path]
|
||||
if missing_selected:
|
||||
raise TestMqlOutputValidationError(
|
||||
f"Python output is missing selected Data_Path values: {missing_selected}"
|
||||
)
|
||||
return selected_paths
|
||||
|
||||
|
||||
def _validate_series(
|
||||
*,
|
||||
data_path: str,
|
||||
values: tuple[float, ...] | list[float],
|
||||
expected_count: int,
|
||||
) -> tuple[float, ...]:
|
||||
if len(values) != expected_count:
|
||||
raise TestMqlOutputValidationError(
|
||||
f"Python series length mismatch for {data_path!r}: "
|
||||
f"{len(values)} values for {expected_count} time samples."
|
||||
)
|
||||
return tuple(_finite_float(data_path, value) for value in values)
|
||||
|
||||
|
||||
def _finite_float(label: str, value: float) -> float:
|
||||
try:
|
||||
numeric_value = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TestMqlOutputValidationError(f"{label!r} contains a non-numeric value: {value!r}") from exc
|
||||
if not isfinite(numeric_value):
|
||||
raise TestMqlOutputValidationError(f"{label!r} contains a non-finite value: {value!r}")
|
||||
return numeric_value
|
||||
@@ -1,111 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults, AmesimVariable
|
||||
from PythonModels.systems.test_mql import COMPONENT_SPECS, CONNECTION_SPECS
|
||||
|
||||
|
||||
_UNIT_RE = re.compile(r"\[([^\]]+)\]\s*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlVariableBinding:
|
||||
index: int
|
||||
data_path: str
|
||||
signal_name: str
|
||||
owner_alias: str
|
||||
owner_kind: str
|
||||
submodel: str
|
||||
label: str
|
||||
units: str | None
|
||||
saved: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlVariableCatalog:
|
||||
variables: tuple[TestMqlVariableBinding, ...]
|
||||
|
||||
@property
|
||||
def data_path_count(self) -> int:
|
||||
return len(self.variables)
|
||||
|
||||
@property
|
||||
def saved_data_path_count(self) -> int:
|
||||
return sum(1 for variable in self.variables if variable.saved)
|
||||
|
||||
def by_data_path(self, data_path: str) -> TestMqlVariableBinding:
|
||||
for variable in self.variables:
|
||||
if variable.data_path == data_path:
|
||||
return variable
|
||||
raise KeyError(data_path)
|
||||
|
||||
def counts_by_submodel(self) -> dict[str, int]:
|
||||
return dict(Counter(variable.submodel for variable in self.variables))
|
||||
|
||||
def counts_by_owner_kind(self) -> dict[str, int]:
|
||||
return dict(Counter(variable.owner_kind for variable in self.variables))
|
||||
|
||||
def data_paths_for_owner(self, owner_alias: str) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
variable.data_path
|
||||
for variable in self.variables
|
||||
if variable.owner_alias == owner_alias
|
||||
)
|
||||
|
||||
|
||||
def build_test_mql_variable_catalog(amesim_results: AmesimResults) -> TestMqlVariableCatalog:
|
||||
owner_map = _build_owner_map()
|
||||
saved_indices = set(amesim_results.saved_variable_indices)
|
||||
bindings = []
|
||||
for variable in amesim_results.variables:
|
||||
if variable.data_path is None:
|
||||
continue
|
||||
signal_name, owner_alias = split_data_path(variable.data_path)
|
||||
owner_kind, submodel = owner_map[owner_alias]
|
||||
bindings.append(
|
||||
TestMqlVariableBinding(
|
||||
index=variable.index,
|
||||
data_path=variable.data_path,
|
||||
signal_name=signal_name,
|
||||
owner_alias=owner_alias,
|
||||
owner_kind=owner_kind,
|
||||
submodel=submodel,
|
||||
label=variable.label,
|
||||
units=_extract_units(variable),
|
||||
saved=variable.index in saved_indices,
|
||||
)
|
||||
)
|
||||
return TestMqlVariableCatalog(variables=tuple(bindings))
|
||||
|
||||
|
||||
def split_data_path(data_path: str) -> tuple[str, str]:
|
||||
if "@" not in data_path:
|
||||
raise ValueError(f"AMESim Data_Path does not contain an owner alias: {data_path!r}")
|
||||
signal_name, owner_alias = data_path.rsplit("@", 1)
|
||||
if not signal_name or not owner_alias:
|
||||
raise ValueError(f"Invalid AMESim Data_Path: {data_path!r}")
|
||||
return signal_name, owner_alias
|
||||
|
||||
|
||||
def _build_owner_map() -> dict[str, tuple[str, str]]:
|
||||
owner_map = {
|
||||
str(spec["alias"]): ("component", str(spec["submodel"]))
|
||||
for spec in COMPONENT_SPECS
|
||||
}
|
||||
owner_map.update(
|
||||
{
|
||||
str(spec["alias"]): ("connection", str(spec["submodel"]))
|
||||
for spec in CONNECTION_SPECS
|
||||
}
|
||||
)
|
||||
return owner_map
|
||||
|
||||
|
||||
def _extract_units(variable: AmesimVariable) -> str | None:
|
||||
match = _UNIT_RE.search(variable.label)
|
||||
if match is None:
|
||||
return None
|
||||
return match.group(1)
|
||||
@@ -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,77 +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_baseline import (
|
||||
TestMqlBaselineRun,
|
||||
run_test_mql_baseline_passthrough,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlBaselinePathConfig:
|
||||
archive_path: Path = field(
|
||||
default_factory=lambda: Path(__file__).resolve().parents[2] / "AmesimModels" / "test_mql.ame"
|
||||
)
|
||||
output_dir: Path | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlBaselineExecutionConfig:
|
||||
write_summary: bool = True
|
||||
data_paths: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlBaselineScriptConfig:
|
||||
paths: TestMqlBaselinePathConfig = field(default_factory=TestMqlBaselinePathConfig)
|
||||
execution: TestMqlBaselineExecutionConfig = field(default_factory=TestMqlBaselineExecutionConfig)
|
||||
|
||||
|
||||
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")
|
||||
return pythonmodels_root / "runs" / timestamp
|
||||
|
||||
|
||||
def format_test_mql_baseline_summary(run: TestMqlBaselineRun) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"Model: test_mql",
|
||||
"Mode: AMESim baseline passthrough",
|
||||
f"Samples: {run.sample_count}",
|
||||
f"Output schema signals: {run.output_schema.signal_count}",
|
||||
f"Compared signals: {run.signal_count}",
|
||||
f"Observation bindings: {run.observation_catalog.binding_count}",
|
||||
f"Max absolute error: {run.comparison.max_abs_error}",
|
||||
f"Max relative error: {run.comparison.max_rel_error}",
|
||||
]
|
||||
) + "\n"
|
||||
|
||||
|
||||
def run_test_mql_baseline(config: TestMqlBaselineScriptConfig | None = None):
|
||||
config = config or TestMqlBaselineScriptConfig()
|
||||
run = run_test_mql_baseline_passthrough(
|
||||
config.paths.archive_path,
|
||||
data_paths=config.execution.data_paths,
|
||||
)
|
||||
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_baseline_summary.txt").write_text(
|
||||
format_test_mql_baseline_summary(run),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return run, output_dir
|
||||
|
||||
|
||||
def main() -> None:
|
||||
run, output_dir = run_test_mql_baseline()
|
||||
print(format_test_mql_baseline_summary(run), end="")
|
||||
print(f"Output directory: {output_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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."""
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,77 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
|
||||
from PythonModels.reporting.test_mql_observations import (
|
||||
TestMqlObservationCatalog,
|
||||
build_test_mql_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_schema import (
|
||||
TestMqlOutputSchema,
|
||||
build_test_mql_output_schema,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_validation import (
|
||||
TestMqlValidatedOutput,
|
||||
compare_validated_test_mql_output,
|
||||
validate_test_mql_output,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlBaselineRun:
|
||||
amesim_results: AmesimResults
|
||||
observation_catalog: TestMqlObservationCatalog
|
||||
output_schema: TestMqlOutputSchema
|
||||
output: TestMqlValidatedOutput
|
||||
comparison: TestMqlComparisonResult
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return len(self.output.times)
|
||||
|
||||
@property
|
||||
def signal_count(self) -> int:
|
||||
return len(self.output.data_paths)
|
||||
|
||||
|
||||
def run_test_mql_baseline_passthrough(
|
||||
archive_path: Path,
|
||||
*,
|
||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||
) -> TestMqlBaselineRun:
|
||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
||||
output_schema = build_test_mql_output_schema(
|
||||
amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
)
|
||||
selected_paths = tuple(data_paths) if data_paths is not None else output_schema.data_paths()
|
||||
baseline_series = observation_catalog.baseline_series_by_data_path(
|
||||
amesim_results,
|
||||
selected_paths,
|
||||
)
|
||||
output = validate_test_mql_output(
|
||||
times=amesim_results.times,
|
||||
series_by_data_path=baseline_series,
|
||||
schema=output_schema,
|
||||
data_paths=selected_paths,
|
||||
require_all_schema_paths=data_paths is None,
|
||||
)
|
||||
comparison = compare_validated_test_mql_output(
|
||||
times=output.times,
|
||||
series_by_data_path=output.series_by_data_path,
|
||||
schema=output_schema,
|
||||
amesim_results=amesim_results,
|
||||
data_paths=output.data_paths,
|
||||
require_all_schema_paths=data_paths is None,
|
||||
)
|
||||
return TestMqlBaselineRun(
|
||||
amesim_results=amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
output_schema=output_schema,
|
||||
output=output,
|
||||
comparison=comparison,
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,468 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.components.amesim_pneumatic import m3_to_cm3
|
||||
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
||||
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
|
||||
from PythonModels.reporting.test_mql_observations import (
|
||||
TestMqlObservationCatalog,
|
||||
build_test_mql_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_schema import (
|
||||
TestMqlOutputSchema,
|
||||
build_test_mql_output_schema,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_output_validation import (
|
||||
TestMqlValidatedOutput,
|
||||
compare_validated_test_mql_output,
|
||||
validate_test_mql_output,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_variables import build_test_mql_variable_catalog
|
||||
from PythonModels.systems.test_mql_mechanical import (
|
||||
TestMqlMechanicalAssembly,
|
||||
build_test_mql_mechanical_assembly,
|
||||
)
|
||||
from PythonModels.systems.test_mql_pneumatic import (
|
||||
TestMqlPneumaticAssembly,
|
||||
build_test_mql_pneumatic_assembly,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlComputedPistonGeometryRun:
|
||||
amesim_results: AmesimResults
|
||||
observation_catalog: TestMqlObservationCatalog
|
||||
output_schema: TestMqlOutputSchema
|
||||
mechanical_assembly: TestMqlMechanicalAssembly
|
||||
output: TestMqlValidatedOutput
|
||||
comparison: TestMqlComparisonResult
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return len(self.output.times)
|
||||
|
||||
@property
|
||||
def signal_count(self) -> int:
|
||||
return len(self.output.data_paths)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlComputedGeometryRun:
|
||||
amesim_results: AmesimResults
|
||||
observation_catalog: TestMqlObservationCatalog
|
||||
output_schema: TestMqlOutputSchema
|
||||
mechanical_assembly: TestMqlMechanicalAssembly
|
||||
pneumatic_assembly: TestMqlPneumaticAssembly
|
||||
output: TestMqlValidatedOutput
|
||||
comparison: TestMqlComparisonResult
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return len(self.output.times)
|
||||
|
||||
@property
|
||||
def signal_count(self) -> int:
|
||||
return len(self.output.data_paths)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlComputedLineRelationsRun:
|
||||
amesim_results: AmesimResults
|
||||
observation_catalog: TestMqlObservationCatalog
|
||||
output_schema: TestMqlOutputSchema
|
||||
output: TestMqlValidatedOutput
|
||||
comparison: TestMqlComparisonResult
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return len(self.output.times)
|
||||
|
||||
@property
|
||||
def signal_count(self) -> int:
|
||||
return len(self.output.data_paths)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlComputedPneumaticRelationsRun:
|
||||
amesim_results: AmesimResults
|
||||
observation_catalog: TestMqlObservationCatalog
|
||||
output_schema: TestMqlOutputSchema
|
||||
output: TestMqlValidatedOutput
|
||||
comparison: TestMqlComparisonResult
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return len(self.output.times)
|
||||
|
||||
@property
|
||||
def signal_count(self) -> int:
|
||||
return len(self.output.data_paths)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlComputedMechanicalRelationsRun:
|
||||
amesim_results: AmesimResults
|
||||
observation_catalog: TestMqlObservationCatalog
|
||||
output_schema: TestMqlOutputSchema
|
||||
mechanical_assembly: TestMqlMechanicalAssembly
|
||||
output: TestMqlValidatedOutput
|
||||
comparison: TestMqlComparisonResult
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return len(self.output.times)
|
||||
|
||||
@property
|
||||
def signal_count(self) -> int:
|
||||
return len(self.output.data_paths)
|
||||
|
||||
|
||||
def run_test_mql_computed_piston_geometry(
|
||||
archive_path: Path,
|
||||
) -> TestMqlComputedPistonGeometryRun:
|
||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
||||
output_schema = build_test_mql_output_schema(
|
||||
amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
)
|
||||
variable_catalog = build_test_mql_variable_catalog(amesim_results)
|
||||
mechanical_assembly = build_test_mql_mechanical_assembly(
|
||||
amesim_results=amesim_results,
|
||||
variable_catalog=variable_catalog,
|
||||
)
|
||||
output_series = _compute_piston_geometry_series(amesim_results, mechanical_assembly)
|
||||
output_data_paths = tuple(output_series)
|
||||
output = validate_test_mql_output(
|
||||
times=amesim_results.times,
|
||||
series_by_data_path=output_series,
|
||||
schema=output_schema,
|
||||
data_paths=output_data_paths,
|
||||
)
|
||||
comparison = compare_validated_test_mql_output(
|
||||
times=output.times,
|
||||
series_by_data_path=output.series_by_data_path,
|
||||
schema=output_schema,
|
||||
amesim_results=amesim_results,
|
||||
data_paths=output.data_paths,
|
||||
)
|
||||
return TestMqlComputedPistonGeometryRun(
|
||||
amesim_results=amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
output_schema=output_schema,
|
||||
mechanical_assembly=mechanical_assembly,
|
||||
output=output,
|
||||
comparison=comparison,
|
||||
)
|
||||
|
||||
|
||||
def run_test_mql_computed_geometry(
|
||||
archive_path: Path,
|
||||
) -> TestMqlComputedGeometryRun:
|
||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
||||
output_schema = build_test_mql_output_schema(
|
||||
amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
)
|
||||
variable_catalog = build_test_mql_variable_catalog(amesim_results)
|
||||
mechanical_assembly = build_test_mql_mechanical_assembly(
|
||||
amesim_results=amesim_results,
|
||||
variable_catalog=variable_catalog,
|
||||
)
|
||||
pneumatic_assembly = build_test_mql_pneumatic_assembly()
|
||||
output_series = {
|
||||
**_compute_piston_geometry_series(amesim_results, mechanical_assembly),
|
||||
**_compute_variable_chamber_volume_series(
|
||||
amesim_results,
|
||||
mechanical_assembly,
|
||||
pneumatic_assembly,
|
||||
),
|
||||
}
|
||||
output_data_paths = tuple(output_series)
|
||||
output = validate_test_mql_output(
|
||||
times=amesim_results.times,
|
||||
series_by_data_path=output_series,
|
||||
schema=output_schema,
|
||||
data_paths=output_data_paths,
|
||||
)
|
||||
comparison = compare_validated_test_mql_output(
|
||||
times=output.times,
|
||||
series_by_data_path=output.series_by_data_path,
|
||||
schema=output_schema,
|
||||
amesim_results=amesim_results,
|
||||
data_paths=output.data_paths,
|
||||
)
|
||||
return TestMqlComputedGeometryRun(
|
||||
amesim_results=amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
output_schema=output_schema,
|
||||
mechanical_assembly=mechanical_assembly,
|
||||
pneumatic_assembly=pneumatic_assembly,
|
||||
output=output,
|
||||
comparison=comparison,
|
||||
)
|
||||
|
||||
|
||||
def run_test_mql_computed_line_relations(
|
||||
archive_path: Path,
|
||||
) -> TestMqlComputedLineRelationsRun:
|
||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
||||
output_schema = build_test_mql_output_schema(
|
||||
amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
)
|
||||
output_series = _compute_line_reversed_series(amesim_results, observation_catalog)
|
||||
output_data_paths = tuple(output_series)
|
||||
output = validate_test_mql_output(
|
||||
times=amesim_results.times,
|
||||
series_by_data_path=output_series,
|
||||
schema=output_schema,
|
||||
data_paths=output_data_paths,
|
||||
)
|
||||
comparison = compare_validated_test_mql_output(
|
||||
times=output.times,
|
||||
series_by_data_path=output.series_by_data_path,
|
||||
schema=output_schema,
|
||||
amesim_results=amesim_results,
|
||||
data_paths=output.data_paths,
|
||||
)
|
||||
return TestMqlComputedLineRelationsRun(
|
||||
amesim_results=amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
output_schema=output_schema,
|
||||
output=output,
|
||||
comparison=comparison,
|
||||
)
|
||||
|
||||
|
||||
def run_test_mql_computed_pneumatic_relations(
|
||||
archive_path: Path,
|
||||
) -> TestMqlComputedPneumaticRelationsRun:
|
||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
||||
output_schema = build_test_mql_output_schema(
|
||||
amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
)
|
||||
output_series = {
|
||||
**_compute_chamber_duplicate_series(amesim_results, observation_catalog),
|
||||
**_compute_orifice_reversed_series(amesim_results, observation_catalog),
|
||||
}
|
||||
output_data_paths = tuple(output_series)
|
||||
output = validate_test_mql_output(
|
||||
times=amesim_results.times,
|
||||
series_by_data_path=output_series,
|
||||
schema=output_schema,
|
||||
data_paths=output_data_paths,
|
||||
)
|
||||
comparison = compare_validated_test_mql_output(
|
||||
times=output.times,
|
||||
series_by_data_path=output.series_by_data_path,
|
||||
schema=output_schema,
|
||||
amesim_results=amesim_results,
|
||||
data_paths=output.data_paths,
|
||||
)
|
||||
return TestMqlComputedPneumaticRelationsRun(
|
||||
amesim_results=amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
output_schema=output_schema,
|
||||
output=output,
|
||||
comparison=comparison,
|
||||
)
|
||||
|
||||
|
||||
def run_test_mql_computed_mechanical_relations(
|
||||
archive_path: Path,
|
||||
) -> TestMqlComputedMechanicalRelationsRun:
|
||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
||||
output_schema = build_test_mql_output_schema(
|
||||
amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
)
|
||||
variable_catalog = build_test_mql_variable_catalog(amesim_results)
|
||||
mechanical_assembly = build_test_mql_mechanical_assembly(
|
||||
amesim_results=amesim_results,
|
||||
variable_catalog=variable_catalog,
|
||||
)
|
||||
output_series = {
|
||||
**_compute_mass_duplicate_series(amesim_results, mechanical_assembly),
|
||||
**_compute_inactive_mass_force_series(amesim_results, mechanical_assembly),
|
||||
**_compute_zero_force_source_series(amesim_results, mechanical_assembly),
|
||||
}
|
||||
output_data_paths = tuple(output_series)
|
||||
output = validate_test_mql_output(
|
||||
times=amesim_results.times,
|
||||
series_by_data_path=output_series,
|
||||
schema=output_schema,
|
||||
data_paths=output_data_paths,
|
||||
)
|
||||
comparison = compare_validated_test_mql_output(
|
||||
times=output.times,
|
||||
series_by_data_path=output.series_by_data_path,
|
||||
schema=output_schema,
|
||||
amesim_results=amesim_results,
|
||||
data_paths=output.data_paths,
|
||||
)
|
||||
return TestMqlComputedMechanicalRelationsRun(
|
||||
amesim_results=amesim_results,
|
||||
observation_catalog=observation_catalog,
|
||||
output_schema=output_schema,
|
||||
mechanical_assembly=mechanical_assembly,
|
||||
output=output,
|
||||
comparison=comparison,
|
||||
)
|
||||
|
||||
|
||||
def _compute_piston_geometry_series(
|
||||
amesim_results: AmesimResults,
|
||||
mechanical_assembly: TestMqlMechanicalAssembly,
|
||||
) -> dict[str, tuple[float, ...]]:
|
||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
||||
for alias in sorted(mechanical_assembly.pistons):
|
||||
piston = mechanical_assembly.pistons[alias]
|
||||
geometry = piston.geometry()
|
||||
x4 = amesim_results.series(f"x4@{alias}")
|
||||
x5 = amesim_results.series(f"x5@{alias}")
|
||||
v4 = amesim_results.series(f"v4@{alias}")
|
||||
v5 = amesim_results.series(f"v5@{alias}")
|
||||
series_by_data_path[f"length@{alias}"] = tuple(
|
||||
geometry.chamber_length_mm(port4, port5)
|
||||
for port4, port5 in zip(x4, x5)
|
||||
)
|
||||
series_by_data_path[f"vol1@{alias}"] = tuple(
|
||||
geometry.chamber_volume_cm3(port4, port5)
|
||||
for port4, port5 in zip(x4, x5)
|
||||
)
|
||||
series_by_data_path[f"vvol1@{alias}"] = tuple(
|
||||
geometry.chamber_volume_rate_l_min(port4, port5)
|
||||
for port4, port5 in zip(v4, v5)
|
||||
)
|
||||
return series_by_data_path
|
||||
|
||||
|
||||
def _compute_variable_chamber_volume_series(
|
||||
amesim_results: AmesimResults,
|
||||
mechanical_assembly: TestMqlMechanicalAssembly,
|
||||
pneumatic_assembly: TestMqlPneumaticAssembly,
|
||||
) -> dict[str, tuple[float, ...]]:
|
||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
||||
for chamber_alias in sorted(pneumatic_assembly.variable_chambers):
|
||||
chamber = pneumatic_assembly.variable_chambers[chamber_alias]
|
||||
piston_alias = _piston_alias_for_variable_chamber(chamber_alias)
|
||||
piston = mechanical_assembly.pistons[piston_alias]
|
||||
geometry = piston.geometry()
|
||||
x4 = amesim_results.series(f"x4@{piston_alias}")
|
||||
x5 = amesim_results.series(f"x5@{piston_alias}")
|
||||
dead_volume_cm3 = m3_to_cm3(chamber.dead_volume)
|
||||
series_by_data_path[f"vol@{chamber_alias}"] = tuple(
|
||||
dead_volume_cm3 + geometry.chamber_volume_cm3(port4, port5)
|
||||
for port4, port5 in zip(x4, x5)
|
||||
)
|
||||
return series_by_data_path
|
||||
|
||||
|
||||
def _piston_alias_for_variable_chamber(chamber_alias: str) -> str:
|
||||
if not chamber_alias.startswith("pn_c1"):
|
||||
raise ValueError(f"Unexpected PNCH012 alias: {chamber_alias}")
|
||||
return chamber_alias.replace("pn_c1", "pn_brp2", 1)
|
||||
|
||||
|
||||
def _compute_mass_duplicate_series(
|
||||
amesim_results: AmesimResults,
|
||||
mechanical_assembly: TestMqlMechanicalAssembly,
|
||||
) -> dict[str, tuple[float, ...]]:
|
||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
||||
for alias in sorted(mechanical_assembly.masses):
|
||||
for signal_name in ("x1", "v1", "acc1"):
|
||||
source_path = f"{signal_name}@{alias}"
|
||||
duplicate_path = f"{signal_name}dup@{alias}"
|
||||
series_by_data_path[duplicate_path] = tuple(
|
||||
-value for value in amesim_results.series(source_path)
|
||||
)
|
||||
return series_by_data_path
|
||||
|
||||
|
||||
def _compute_inactive_mass_force_series(
|
||||
amesim_results: AmesimResults,
|
||||
mechanical_assembly: TestMqlMechanicalAssembly,
|
||||
) -> dict[str, tuple[float, ...]]:
|
||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
||||
for alias in sorted(mechanical_assembly.masses):
|
||||
mass = mechanical_assembly.masses[alias].endstop()
|
||||
x1 = amesim_results.series(f"x1@{alias}")
|
||||
v1 = amesim_results.series(f"v1@{alias}")
|
||||
series_by_data_path[f"Fmin@{alias}"] = tuple(
|
||||
mass.lower_static_force_magnitude(displacement)
|
||||
for displacement in x1
|
||||
)
|
||||
series_by_data_path[f"Fvisc@{alias}"] = tuple(
|
||||
mass.viscous_friction_force(velocity)
|
||||
for velocity in v1
|
||||
)
|
||||
series_by_data_path[f"Ffric@{alias}"] = tuple(0.0 for _ in x1)
|
||||
return series_by_data_path
|
||||
|
||||
|
||||
def _compute_zero_force_source_series(
|
||||
amesim_results: AmesimResults,
|
||||
mechanical_assembly: TestMqlMechanicalAssembly,
|
||||
) -> dict[str, tuple[float, ...]]:
|
||||
return {
|
||||
f"fzero@{alias}": tuple(0.0 for _ in amesim_results.times)
|
||||
for alias in sorted(mechanical_assembly.zero_force_sources)
|
||||
}
|
||||
|
||||
|
||||
def _compute_chamber_duplicate_series(
|
||||
amesim_results: AmesimResults,
|
||||
observation_catalog: TestMqlObservationCatalog,
|
||||
) -> dict[str, tuple[float, ...]]:
|
||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
||||
for binding in observation_catalog.chambers.bindings:
|
||||
pressure_series = amesim_results.series(binding.pressure_path)
|
||||
temperature_series = amesim_results.series(binding.temperature_path)
|
||||
for duplicate_path in binding.pressure_duplicate_paths:
|
||||
series_by_data_path[duplicate_path] = tuple(pressure_series)
|
||||
for duplicate_path in binding.temperature_duplicate_paths:
|
||||
series_by_data_path[duplicate_path] = tuple(temperature_series)
|
||||
return series_by_data_path
|
||||
|
||||
|
||||
def _compute_orifice_reversed_series(
|
||||
amesim_results: AmesimResults,
|
||||
observation_catalog: TestMqlObservationCatalog,
|
||||
) -> dict[str, tuple[float, ...]]:
|
||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
||||
for binding in observation_catalog.orifices.bindings:
|
||||
series_by_data_path[binding.reversed_mass_flow_path] = tuple(
|
||||
-value for value in amesim_results.series(binding.primary_mass_flow_path)
|
||||
)
|
||||
series_by_data_path[binding.reversed_enthalpy_flow_path] = tuple(
|
||||
-value for value in amesim_results.series(binding.primary_enthalpy_flow_path)
|
||||
)
|
||||
return series_by_data_path
|
||||
|
||||
|
||||
def _compute_line_reversed_series(
|
||||
amesim_results: AmesimResults,
|
||||
observation_catalog: TestMqlObservationCatalog,
|
||||
) -> dict[str, tuple[float, ...]]:
|
||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
||||
for binding in observation_catalog.lines.by_submodel("PNL00R"):
|
||||
if len(binding.mass_flow_paths) != 2 or len(binding.enthalpy_flow_paths) != 2:
|
||||
raise ValueError(f"Expected two PNL00R flow paths for {binding.alias}.")
|
||||
primary_mass_path, reversed_mass_path = binding.mass_flow_paths
|
||||
primary_enthalpy_path, reversed_enthalpy_path = binding.enthalpy_flow_paths
|
||||
series_by_data_path[reversed_mass_path] = tuple(
|
||||
-value for value in amesim_results.series(primary_mass_path)
|
||||
)
|
||||
series_by_data_path[reversed_enthalpy_path] = tuple(
|
||||
-value for value in amesim_results.series(primary_enthalpy_path)
|
||||
)
|
||||
return series_by_data_path
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import operator
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||
from PythonModels.systems.test_mql import COMPONENT_SPECS, GLOBAL_PARAMETERS
|
||||
|
||||
|
||||
_BINARY_OPERATORS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.Pow: operator.pow,
|
||||
}
|
||||
_UNARY_OPERATORS = {
|
||||
ast.UAdd: operator.pos,
|
||||
ast.USub: operator.neg,
|
||||
}
|
||||
|
||||
|
||||
class TestMqlExpressionError(ValueError):
|
||||
"""Raised when an AMESim parameter expression cannot be resolved safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlResolvedParameter:
|
||||
name: str
|
||||
title: str
|
||||
raw_value: str
|
||||
units: str
|
||||
value: float | None
|
||||
|
||||
@property
|
||||
def is_numeric(self) -> bool:
|
||||
return self.value is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlResolvedComponent:
|
||||
alias: str
|
||||
component_name: str
|
||||
submodel: str
|
||||
label: str
|
||||
parameters: dict[str, TestMqlResolvedParameter]
|
||||
|
||||
def parameter_value(self, name: str) -> float:
|
||||
parameter = self.parameters[name]
|
||||
if parameter.value is None:
|
||||
raise KeyError(f"Parameter {name!r} on {self.alias!r} is not numeric")
|
||||
return parameter.value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlConfig:
|
||||
raw_global_parameters: dict[str, str]
|
||||
global_parameters: dict[str, float]
|
||||
fluid: PengRobinsonFluid
|
||||
components: tuple[TestMqlResolvedComponent, ...]
|
||||
|
||||
@classmethod
|
||||
def from_amesim_specs(cls) -> "TestMqlConfig":
|
||||
raw_globals = dict(GLOBAL_PARAMETERS)
|
||||
numeric_globals = {
|
||||
name: value
|
||||
for name, raw in raw_globals.items()
|
||||
if (value := resolve_numeric_expression(raw, {})) is not None
|
||||
}
|
||||
components = tuple(
|
||||
_resolve_component(spec, numeric_globals)
|
||||
for spec in COMPONENT_SPECS
|
||||
)
|
||||
return cls(
|
||||
raw_global_parameters=raw_globals,
|
||||
global_parameters=numeric_globals,
|
||||
fluid=HELIUM_PR,
|
||||
components=components,
|
||||
)
|
||||
|
||||
def component(self, alias: str) -> TestMqlResolvedComponent:
|
||||
for component in self.components:
|
||||
if component.alias == alias:
|
||||
return component
|
||||
raise KeyError(alias)
|
||||
|
||||
def components_by_submodel(self, submodel: str) -> tuple[TestMqlResolvedComponent, ...]:
|
||||
return tuple(component for component in self.components if component.submodel == submodel)
|
||||
|
||||
|
||||
def _resolve_component(
|
||||
spec: dict[str, Any],
|
||||
variables: dict[str, float],
|
||||
) -> TestMqlResolvedComponent:
|
||||
parameters = {}
|
||||
for parameter in spec.get("parameters", []):
|
||||
name = str(parameter["name"])
|
||||
raw_value = str(parameter["value"])
|
||||
parameters[name] = TestMqlResolvedParameter(
|
||||
name=name,
|
||||
title=str(parameter["title"]),
|
||||
raw_value=raw_value,
|
||||
units=str(parameter["units"]),
|
||||
value=resolve_numeric_expression(raw_value, variables),
|
||||
)
|
||||
return TestMqlResolvedComponent(
|
||||
alias=str(spec["alias"]),
|
||||
component_name=str(spec["component_name"]),
|
||||
submodel=str(spec["submodel"]),
|
||||
label=str(spec["label"]),
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
|
||||
def resolve_numeric_expression(
|
||||
expression: str,
|
||||
variables: dict[str, float],
|
||||
) -> float | None:
|
||||
expression = expression.strip()
|
||||
if not expression:
|
||||
return None
|
||||
normalized = expression.replace("^", "**")
|
||||
try:
|
||||
parsed = ast.parse(normalized, mode="eval")
|
||||
value = float(_eval_node(parsed.body, variables))
|
||||
except (SyntaxError, TestMqlExpressionError, ValueError, TypeError, ZeroDivisionError):
|
||||
return None
|
||||
return value if isfinite(value) else None
|
||||
|
||||
|
||||
def _eval_node(node: ast.AST, variables: dict[str, float]) -> float:
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return float(node.value)
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id not in variables:
|
||||
raise TestMqlExpressionError(f"Unknown variable: {node.id}")
|
||||
return float(variables[node.id])
|
||||
if isinstance(node, ast.BinOp):
|
||||
operator_type = type(node.op)
|
||||
if operator_type not in _BINARY_OPERATORS:
|
||||
raise TestMqlExpressionError(f"Unsupported binary operator: {operator_type}")
|
||||
return float(_BINARY_OPERATORS[operator_type](_eval_node(node.left, variables), _eval_node(node.right, variables)))
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
operator_type = type(node.op)
|
||||
if operator_type not in _UNARY_OPERATORS:
|
||||
raise TestMqlExpressionError(f"Unsupported unary operator: {operator_type}")
|
||||
return float(_UNARY_OPERATORS[operator_type](_eval_node(node.operand, variables)))
|
||||
raise TestMqlExpressionError(f"Unsupported expression node: {type(node)}")
|
||||
@@ -1,451 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import tarfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.systems.test_mql import CONNECTION_SPECS, GLOBAL_PARAMETERS
|
||||
from PythonModels.systems.test_mql_config import resolve_numeric_expression
|
||||
|
||||
|
||||
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPnl0001Spec:
|
||||
alias: str
|
||||
source_component: str
|
||||
source_port: str
|
||||
target_component: str
|
||||
target_port: str
|
||||
diameter_mm: float
|
||||
length_m: float
|
||||
relative_roughness: float
|
||||
polytropic_constant: float
|
||||
heat_transfer_coefficient: float
|
||||
external_temperature_k: float
|
||||
gas_type_index: int
|
||||
mode: int
|
||||
initial_temperature_k: float
|
||||
initial_gauge_pressure_pa: float
|
||||
|
||||
@property
|
||||
def initial_absolute_pressure_pa(self) -> float:
|
||||
return self.initial_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPnl0002Spec:
|
||||
alias: str
|
||||
source_component: str
|
||||
source_port: str
|
||||
target_component: str
|
||||
target_port: str
|
||||
diameter_mm: float
|
||||
length_m: float
|
||||
relative_roughness: float
|
||||
polytropic_constant: float
|
||||
heat_transfer_coefficient: float
|
||||
external_temperature_k: float
|
||||
gas_type_index: int
|
||||
mode: int
|
||||
initial_center_temperature_k: float
|
||||
initial_center_gauge_pressure_pa: float
|
||||
|
||||
@property
|
||||
def initial_center_absolute_pressure_pa(self) -> float:
|
||||
return self.initial_center_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPnl0003Spec:
|
||||
alias: str
|
||||
source_component: str
|
||||
source_port: str
|
||||
target_component: str
|
||||
target_port: str
|
||||
diameter_mm: float
|
||||
length_m: float
|
||||
relative_roughness: float
|
||||
polytropic_constant: float
|
||||
heat_transfer_coefficient: float
|
||||
external_temperature_k: float
|
||||
gas_type_index: int
|
||||
mode: int
|
||||
initial_temperature_1_k: float
|
||||
initial_gauge_pressure_1_pa: float
|
||||
initial_temperature_2_k: float
|
||||
initial_gauge_pressure_2_pa: float
|
||||
|
||||
@property
|
||||
def initial_absolute_pressure_1_pa(self) -> float:
|
||||
return self.initial_gauge_pressure_1_pa + AMESIM_REFERENCE_PRESSURE_PA
|
||||
|
||||
@property
|
||||
def initial_absolute_pressure_2_pa(self) -> float:
|
||||
return self.initial_gauge_pressure_2_pa + AMESIM_REFERENCE_PRESSURE_PA
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPnl00rSpec:
|
||||
alias: str
|
||||
source_component: str
|
||||
source_port: str
|
||||
target_component: str
|
||||
target_port: str
|
||||
diameter_mm: float
|
||||
length_m: float
|
||||
relative_roughness: float
|
||||
gas_type_index: int
|
||||
|
||||
|
||||
def load_test_mql_pnl0001_specs(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
cir_member: str = "test_mql_.cir",
|
||||
) -> tuple[TestMqlPnl0001Spec, ...]:
|
||||
"""Load resolved PNL0001 geometry and initial states from the AMESim source."""
|
||||
with tarfile.open(archive_path) as archive:
|
||||
cir_file = archive.extractfile(cir_member)
|
||||
if cir_file is None:
|
||||
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
|
||||
cir_text = cir_file.read().decode("latin1")
|
||||
|
||||
numeric_globals = {
|
||||
name: value
|
||||
for name, expression in GLOBAL_PARAMETERS.items()
|
||||
if (value := resolve_numeric_expression(expression, {})) is not None
|
||||
}
|
||||
connections = {
|
||||
str(connection["alias"]): connection
|
||||
for connection in CONNECTION_SPECS
|
||||
if connection["submodel"] == "PNL0001"
|
||||
}
|
||||
specs = []
|
||||
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
|
||||
if _optional_text(block, "SUB_NAME") != "PNL0001":
|
||||
continue
|
||||
alias = _required_text(block, "ALIAS")
|
||||
connection = connections.get(alias)
|
||||
if connection is None:
|
||||
raise ValueError(f"PNL0001 line {alias!r} is absent from CONNECTION_SPECS")
|
||||
real_parameters = _parameter_expressions(block, "RPARAM")
|
||||
integer_parameters = _parameter_expressions(block, "IPARAM")
|
||||
state_values = _evar_values(block)
|
||||
specs.append(
|
||||
TestMqlPnl0001Spec(
|
||||
alias=alias,
|
||||
source_component=str(connection["source_component"]),
|
||||
source_port=str(connection["source_port"]),
|
||||
target_component=str(connection["target_component"]),
|
||||
target_port=str(connection["target_port"]),
|
||||
diameter_mm=_required_numeric(
|
||||
alias, "diam", real_parameters, numeric_globals
|
||||
),
|
||||
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
|
||||
relative_roughness=_required_numeric(
|
||||
alias, "rr", real_parameters, numeric_globals
|
||||
),
|
||||
polytropic_constant=_required_numeric(
|
||||
alias, "k", real_parameters, numeric_globals
|
||||
),
|
||||
heat_transfer_coefficient=_required_numeric(
|
||||
alias, "kth", real_parameters, numeric_globals
|
||||
),
|
||||
external_temperature_k=_required_numeric(
|
||||
alias, "extemp", real_parameters, numeric_globals
|
||||
),
|
||||
gas_type_index=int(
|
||||
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
|
||||
),
|
||||
mode=int(
|
||||
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
|
||||
),
|
||||
initial_temperature_k=_required_numeric(
|
||||
alias, "t2", state_values, numeric_globals
|
||||
),
|
||||
initial_gauge_pressure_pa=_required_numeric(
|
||||
alias, "p2", state_values, numeric_globals
|
||||
),
|
||||
)
|
||||
)
|
||||
if set(connections) != {spec.alias for spec in specs}:
|
||||
missing = sorted(set(connections) - {spec.alias for spec in specs})
|
||||
raise ValueError(f"Missing PNL0001 parameter blocks: {missing}")
|
||||
return tuple(specs)
|
||||
|
||||
|
||||
def load_test_mql_pnl0002_specs(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
cir_member: str = "test_mql_.cir",
|
||||
) -> tuple[TestMqlPnl0002Spec, ...]:
|
||||
"""Load resolved PNL0002 geometry and center compliance initial state."""
|
||||
with tarfile.open(archive_path) as archive:
|
||||
cir_file = archive.extractfile(cir_member)
|
||||
if cir_file is None:
|
||||
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
|
||||
cir_text = cir_file.read().decode("latin1")
|
||||
|
||||
numeric_globals = {
|
||||
name: value
|
||||
for name, expression in GLOBAL_PARAMETERS.items()
|
||||
if (value := resolve_numeric_expression(expression, {})) is not None
|
||||
}
|
||||
connections = {
|
||||
str(connection["alias"]): connection
|
||||
for connection in CONNECTION_SPECS
|
||||
if connection["submodel"] == "PNL0002"
|
||||
}
|
||||
specs = []
|
||||
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
|
||||
if _optional_text(block, "SUB_NAME") != "PNL0002":
|
||||
continue
|
||||
alias = _required_text(block, "ALIAS")
|
||||
connection = connections.get(alias)
|
||||
if connection is None:
|
||||
raise ValueError(f"PNL0002 line {alias!r} is absent from CONNECTION_SPECS")
|
||||
real_parameters = _parameter_expressions(block, "RPARAM")
|
||||
integer_parameters = _parameter_expressions(block, "IPARAM")
|
||||
state_values = _ivar_values(block)
|
||||
specs.append(
|
||||
TestMqlPnl0002Spec(
|
||||
alias=alias,
|
||||
source_component=str(connection["source_component"]),
|
||||
source_port=str(connection["source_port"]),
|
||||
target_component=str(connection["target_component"]),
|
||||
target_port=str(connection["target_port"]),
|
||||
diameter_mm=_required_numeric(
|
||||
alias, "diam", real_parameters, numeric_globals
|
||||
),
|
||||
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
|
||||
relative_roughness=_required_numeric(
|
||||
alias, "rr", real_parameters, numeric_globals
|
||||
),
|
||||
polytropic_constant=_required_numeric(
|
||||
alias, "k", real_parameters, numeric_globals
|
||||
),
|
||||
heat_transfer_coefficient=_required_numeric(
|
||||
alias, "kth", real_parameters, numeric_globals
|
||||
),
|
||||
external_temperature_k=_required_numeric(
|
||||
alias, "extemp", real_parameters, numeric_globals
|
||||
),
|
||||
gas_type_index=int(
|
||||
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
|
||||
),
|
||||
mode=int(
|
||||
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
|
||||
),
|
||||
initial_center_temperature_k=_required_numeric(
|
||||
alias, "tctr", state_values, numeric_globals
|
||||
),
|
||||
initial_center_gauge_pressure_pa=_required_numeric(
|
||||
alias, "pctr", state_values, numeric_globals
|
||||
),
|
||||
)
|
||||
)
|
||||
if set(connections) != {spec.alias for spec in specs}:
|
||||
missing = sorted(set(connections) - {spec.alias for spec in specs})
|
||||
raise ValueError(f"Missing PNL0002 parameter blocks: {missing}")
|
||||
return tuple(specs)
|
||||
|
||||
|
||||
def load_test_mql_pnl0003_specs(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
cir_member: str = "test_mql_.cir",
|
||||
) -> tuple[TestMqlPnl0003Spec, ...]:
|
||||
"""Load resolved PNL0003 geometry and both compliance initial states."""
|
||||
with tarfile.open(archive_path) as archive:
|
||||
cir_file = archive.extractfile(cir_member)
|
||||
if cir_file is None:
|
||||
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
|
||||
cir_text = cir_file.read().decode("latin1")
|
||||
|
||||
numeric_globals = {
|
||||
name: value
|
||||
for name, expression in GLOBAL_PARAMETERS.items()
|
||||
if (value := resolve_numeric_expression(expression, {})) is not None
|
||||
}
|
||||
connections = {
|
||||
str(connection["alias"]): connection
|
||||
for connection in CONNECTION_SPECS
|
||||
if connection["submodel"] == "PNL0003"
|
||||
}
|
||||
specs = []
|
||||
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
|
||||
if _optional_text(block, "SUB_NAME") != "PNL0003":
|
||||
continue
|
||||
alias = _required_text(block, "ALIAS")
|
||||
connection = connections.get(alias)
|
||||
if connection is None:
|
||||
raise ValueError(f"PNL0003 line {alias!r} is absent from CONNECTION_SPECS")
|
||||
real_parameters = _parameter_expressions(block, "RPARAM")
|
||||
integer_parameters = _parameter_expressions(block, "IPARAM")
|
||||
state_values = _evar_values(block)
|
||||
specs.append(
|
||||
TestMqlPnl0003Spec(
|
||||
alias=alias,
|
||||
source_component=str(connection["source_component"]),
|
||||
source_port=str(connection["source_port"]),
|
||||
target_component=str(connection["target_component"]),
|
||||
target_port=str(connection["target_port"]),
|
||||
diameter_mm=_required_numeric(
|
||||
alias, "diam", real_parameters, numeric_globals
|
||||
),
|
||||
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
|
||||
relative_roughness=_required_numeric(
|
||||
alias, "rr", real_parameters, numeric_globals
|
||||
),
|
||||
polytropic_constant=_required_numeric(
|
||||
alias, "k", real_parameters, numeric_globals
|
||||
),
|
||||
heat_transfer_coefficient=_required_numeric(
|
||||
alias, "kth", real_parameters, numeric_globals
|
||||
),
|
||||
external_temperature_k=_required_numeric(
|
||||
alias, "extemp", real_parameters, numeric_globals
|
||||
),
|
||||
gas_type_index=int(
|
||||
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
|
||||
),
|
||||
mode=int(
|
||||
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
|
||||
),
|
||||
initial_temperature_1_k=_required_numeric(
|
||||
alias, "t1", state_values, numeric_globals
|
||||
),
|
||||
initial_gauge_pressure_1_pa=_required_numeric(
|
||||
alias, "p1", state_values, numeric_globals
|
||||
),
|
||||
initial_temperature_2_k=_required_numeric(
|
||||
alias, "t2", state_values, numeric_globals
|
||||
),
|
||||
initial_gauge_pressure_2_pa=_required_numeric(
|
||||
alias, "p2", state_values, numeric_globals
|
||||
),
|
||||
)
|
||||
)
|
||||
if set(connections) != {spec.alias for spec in specs}:
|
||||
missing = sorted(set(connections) - {spec.alias for spec in specs})
|
||||
raise ValueError(f"Missing PNL0003 parameter blocks: {missing}")
|
||||
return tuple(specs)
|
||||
|
||||
|
||||
def load_test_mql_pnl00r_specs(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
cir_member: str = "test_mql_.cir",
|
||||
) -> tuple[TestMqlPnl00rSpec, ...]:
|
||||
"""Load resolved PNL00R geometry from the AMESim source."""
|
||||
with tarfile.open(archive_path) as archive:
|
||||
cir_file = archive.extractfile(cir_member)
|
||||
if cir_file is None:
|
||||
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
|
||||
cir_text = cir_file.read().decode("latin1")
|
||||
|
||||
numeric_globals = {
|
||||
name: value
|
||||
for name, expression in GLOBAL_PARAMETERS.items()
|
||||
if (value := resolve_numeric_expression(expression, {})) is not None
|
||||
}
|
||||
connections = {
|
||||
str(connection["alias"]): connection
|
||||
for connection in CONNECTION_SPECS
|
||||
if connection["submodel"] == "PNL00R"
|
||||
}
|
||||
specs = []
|
||||
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
|
||||
if _optional_text(block, "SUB_NAME") != "PNL00R":
|
||||
continue
|
||||
alias = _required_text(block, "ALIAS")
|
||||
connection = connections.get(alias)
|
||||
if connection is None:
|
||||
raise ValueError(f"PNL00R line {alias!r} is absent from CONNECTION_SPECS")
|
||||
real_parameters = _parameter_expressions(block, "RPARAM")
|
||||
integer_parameters = _parameter_expressions(block, "IPARAM")
|
||||
specs.append(
|
||||
TestMqlPnl00rSpec(
|
||||
alias=alias,
|
||||
source_component=str(connection["source_component"]),
|
||||
source_port=str(connection["source_port"]),
|
||||
target_component=str(connection["target_component"]),
|
||||
target_port=str(connection["target_port"]),
|
||||
diameter_mm=_required_numeric(
|
||||
alias, "diam", real_parameters, numeric_globals
|
||||
),
|
||||
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
|
||||
relative_roughness=_required_numeric(
|
||||
alias, "rr", real_parameters, numeric_globals
|
||||
),
|
||||
gas_type_index=int(
|
||||
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
|
||||
),
|
||||
)
|
||||
)
|
||||
if set(connections) != {spec.alias for spec in specs}:
|
||||
missing = sorted(set(connections) - {spec.alias for spec in specs})
|
||||
raise ValueError(f"Missing PNL00R parameter blocks: {missing}")
|
||||
return tuple(specs)
|
||||
|
||||
|
||||
def _parameter_expressions(block: str, tag_name: str) -> dict[str, str]:
|
||||
parameters = {}
|
||||
for parameter_block in re.findall(
|
||||
rf"<{tag_name}>.*?</{tag_name}>",
|
||||
block,
|
||||
flags=re.DOTALL,
|
||||
):
|
||||
parameters[_required_text(parameter_block, "VARNAME")] = _required_text(
|
||||
parameter_block,
|
||||
"VALUE",
|
||||
)
|
||||
return parameters
|
||||
|
||||
|
||||
def _ivar_values(block: str) -> dict[str, str]:
|
||||
values = {}
|
||||
for variable_block in re.findall(r"<IVAR>.*?</IVAR>", block, flags=re.DOTALL):
|
||||
value = _optional_text(variable_block, "VALUE")
|
||||
if value:
|
||||
values[_required_text(variable_block, "VARNAME")] = value
|
||||
return values
|
||||
|
||||
|
||||
def _evar_values(block: str) -> dict[str, str]:
|
||||
values = {}
|
||||
for variable_block in re.findall(r"<EVAR>.*?</EVAR>", block, flags=re.DOTALL):
|
||||
value = _optional_text(variable_block, "VALUE")
|
||||
if value:
|
||||
values[_required_text(variable_block, "VARNAME")] = value
|
||||
return values
|
||||
|
||||
|
||||
def _required_numeric(
|
||||
alias: str,
|
||||
name: str,
|
||||
expressions: dict[str, str],
|
||||
variables: dict[str, float],
|
||||
) -> float:
|
||||
if name not in expressions:
|
||||
raise ValueError(f"Missing {name!r} on line {alias!r}")
|
||||
value = resolve_numeric_expression(expressions[name], variables)
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
f"Cannot resolve {name!r}={expressions[name]!r} on line {alias!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _required_text(block: str, tag_name: str) -> str:
|
||||
value = _optional_text(block, tag_name)
|
||||
if value is None:
|
||||
raise ValueError(f"Missing AMESim circuit element: {tag_name}")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_text(block: str, tag_name: str) -> str | None:
|
||||
match = re.search(rf"<{tag_name}>(.*?)</{tag_name}>", block, flags=re.DOTALL)
|
||||
return match.group(1).strip() if match is not None else None
|
||||
@@ -1,102 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql import CONNECTION_SPECS
|
||||
|
||||
|
||||
TEST_MQL_PNEUMATIC_LINE_SUBMODELS = ("PNL0001", "PNL0002", "PNL0003", "PNL00R")
|
||||
_LINE_PATTERN_RE = re.compile(r"\(([^()]+)\)\s*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlLineConnection:
|
||||
index: int
|
||||
alias: str
|
||||
submodel: str
|
||||
pattern: str
|
||||
source_component: str
|
||||
source_port: str
|
||||
target_component: str
|
||||
target_port: str
|
||||
label: str
|
||||
data_paths: tuple[str, ...]
|
||||
signal_names: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def has_compliance(self) -> bool:
|
||||
return "C" in self.pattern
|
||||
|
||||
@property
|
||||
def has_resistance(self) -> bool:
|
||||
return "R" in self.pattern
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlLineAssembly:
|
||||
lines: tuple[TestMqlLineConnection, ...]
|
||||
|
||||
@property
|
||||
def line_count(self) -> int:
|
||||
return len(self.lines)
|
||||
|
||||
def by_alias(self, alias: str) -> TestMqlLineConnection:
|
||||
for line in self.lines:
|
||||
if line.alias == alias:
|
||||
return line
|
||||
raise KeyError(alias)
|
||||
|
||||
def by_submodel(self, submodel: str) -> tuple[TestMqlLineConnection, ...]:
|
||||
return tuple(line for line in self.lines if line.submodel == submodel)
|
||||
|
||||
def counts_by_submodel(self) -> dict[str, int]:
|
||||
return dict(Counter(line.submodel for line in self.lines))
|
||||
|
||||
def aliases(self) -> tuple[str, ...]:
|
||||
return tuple(line.alias for line in self.lines)
|
||||
|
||||
|
||||
def build_test_mql_line_assembly(
|
||||
amesim_results: AmesimResults,
|
||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
||||
) -> TestMqlLineAssembly:
|
||||
variable_catalog = variable_catalog or build_test_mql_variable_catalog(amesim_results)
|
||||
lines = []
|
||||
for spec in CONNECTION_SPECS:
|
||||
submodel = str(spec["submodel"])
|
||||
if submodel not in TEST_MQL_PNEUMATIC_LINE_SUBMODELS:
|
||||
continue
|
||||
data_paths = variable_catalog.data_paths_for_owner(str(spec["alias"]))
|
||||
signal_names = tuple(path.rsplit("@", 1)[0] for path in data_paths)
|
||||
lines.append(
|
||||
TestMqlLineConnection(
|
||||
index=int(spec["index"]),
|
||||
alias=str(spec["alias"]),
|
||||
submodel=submodel,
|
||||
pattern=_line_pattern(str(spec["label"]), submodel),
|
||||
source_component=str(spec["source_component"]),
|
||||
source_port=str(spec["source_port"]),
|
||||
target_component=str(spec["target_component"]),
|
||||
target_port=str(spec["target_port"]),
|
||||
label=str(spec["label"]),
|
||||
data_paths=data_paths,
|
||||
signal_names=signal_names,
|
||||
)
|
||||
)
|
||||
return TestMqlLineAssembly(lines=tuple(lines))
|
||||
|
||||
|
||||
def _line_pattern(label: str, submodel: str) -> str:
|
||||
match = _LINE_PATTERN_RE.search(label)
|
||||
if match is not None:
|
||||
return match.group(1)
|
||||
if submodel == "PNL00R":
|
||||
return "R"
|
||||
return submodel
|
||||
@@ -1,544 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.components.amesim_mechanical import (
|
||||
AmesimElasticEndstop,
|
||||
AmesimMassFrictionEndstops,
|
||||
AmesimPistonGeometry,
|
||||
circular_area,
|
||||
mm_to_m,
|
||||
)
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
|
||||
|
||||
|
||||
MM_TO_M = 1.0e-3
|
||||
N_PER_MM_TO_N_PER_M = 1.0e3
|
||||
N_PER_MM_PER_S_TO_N_PER_M_PER_S = 1.0e3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPistonSpec:
|
||||
alias: str
|
||||
piston_diameter_m: float
|
||||
rod_diameter_m: float
|
||||
zero_displacement_m: float
|
||||
piston_area_m2: float
|
||||
rod_area_m2: float
|
||||
annulus_area_m2: float
|
||||
data_paths: tuple[str, ...]
|
||||
|
||||
def geometry(self) -> AmesimPistonGeometry:
|
||||
return AmesimPistonGeometry(
|
||||
piston_diameter_m=self.piston_diameter_m,
|
||||
rod_diameter_m=self.rod_diameter_m,
|
||||
zero_length_m=self.zero_displacement_m,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMassEndstopSpec:
|
||||
alias: str
|
||||
mass_kg: float
|
||||
xmin_m: float
|
||||
xmax_m: float
|
||||
min_stiffness_n_per_m: float
|
||||
max_stiffness_n_per_m: float
|
||||
min_damping_n_per_m_per_s: float
|
||||
max_damping_n_per_m_per_s: float
|
||||
min_penetration_m: float
|
||||
max_penetration_m: float
|
||||
stiction_force_n: float
|
||||
coulomb_friction_n: float
|
||||
viscous_friction_n_per_m_per_s: float
|
||||
windage_n_per_m2_per_s2: float
|
||||
stick_velocity_threshold_m_s: float
|
||||
reset_velocity_threshold_m_s: float
|
||||
rest_coeff: float
|
||||
stribeck_constant_m_s: float
|
||||
use_friction: bool
|
||||
stop_type: int
|
||||
initial_velocity_m_s: float
|
||||
initial_displacement_m: float
|
||||
data_paths: tuple[str, ...]
|
||||
|
||||
def endstop(self) -> AmesimMassFrictionEndstops:
|
||||
return AmesimMassFrictionEndstops(
|
||||
mass_kg=self.mass_kg,
|
||||
lower_limit_m=self.xmin_m,
|
||||
upper_limit_m=self.xmax_m,
|
||||
lower_stiffness_n_per_m=self.min_stiffness_n_per_m,
|
||||
upper_stiffness_n_per_m=self.max_stiffness_n_per_m,
|
||||
lower_damping_n_per_m_per_s=self.min_damping_n_per_m_per_s,
|
||||
upper_damping_n_per_m_per_s=self.max_damping_n_per_m_per_s,
|
||||
viscous_friction_n_per_m_per_s=self.viscous_friction_n_per_m_per_s,
|
||||
coulomb_friction_n=self.coulomb_friction_n,
|
||||
stiction_force_n=self.stiction_force_n,
|
||||
windage_n_per_m2_per_s2=self.windage_n_per_m2_per_s2,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlElasticEndstopSpec:
|
||||
alias: str
|
||||
gap_m: float
|
||||
contact_stiffness_n_per_m: float
|
||||
contact_damping_n_per_m_per_s: float
|
||||
spring_diameter_m: float
|
||||
wire_diameter_m: float
|
||||
data_paths: tuple[str, ...]
|
||||
|
||||
def endstop(self) -> AmesimElasticEndstop:
|
||||
return AmesimElasticEndstop(
|
||||
contact_stiffness_n_per_m=self.contact_stiffness_n_per_m,
|
||||
contact_damping_n_per_m_per_s=self.contact_damping_n_per_m_per_s,
|
||||
gap0_m=self.gap_m,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMechanicalNodeSpec:
|
||||
alias: str
|
||||
port_count: int
|
||||
sum_mode: int
|
||||
data_paths: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPiecewiseLinearSignalSpec:
|
||||
alias: str
|
||||
t_start_s: float
|
||||
starts: tuple[float, ...]
|
||||
ends: tuple[float, ...]
|
||||
durations_s: tuple[float, ...]
|
||||
stage_count: int
|
||||
is_cyclic: bool
|
||||
data_paths: tuple[str, ...]
|
||||
|
||||
def output_at(self, time_s: float) -> float:
|
||||
if self.stage_count <= 0:
|
||||
return 0.0
|
||||
elapsed = max(time_s - self.t_start_s, 0.0)
|
||||
active_durations = self.durations_s[: self.stage_count]
|
||||
total_duration = sum(active_durations)
|
||||
if self.is_cyclic and total_duration > 0.0:
|
||||
elapsed = elapsed % total_duration
|
||||
|
||||
stage_start_time = 0.0
|
||||
for index, duration in enumerate(active_durations):
|
||||
stage_end_time = stage_start_time + duration
|
||||
if elapsed < stage_end_time or index == self.stage_count - 1:
|
||||
if duration <= 0.0:
|
||||
return self.ends[index]
|
||||
fraction = (elapsed - stage_start_time) / duration
|
||||
return self.starts[index] + fraction * (self.ends[index] - self.starts[index])
|
||||
stage_start_time = stage_end_time
|
||||
return self.ends[self.stage_count - 1]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlForceConnectorSpec:
|
||||
alias: str
|
||||
signal_alias: str
|
||||
target_mass_alias: str
|
||||
data_paths: tuple[str, ...]
|
||||
|
||||
def force_at(
|
||||
self,
|
||||
time_s: float,
|
||||
signals: dict[str, TestMqlPiecewiseLinearSignalSpec],
|
||||
) -> float:
|
||||
return signals[self.signal_alias].output_at(time_s)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMechanicalAssembly:
|
||||
pistons: dict[str, TestMqlPistonSpec]
|
||||
masses: dict[str, TestMqlMassEndstopSpec]
|
||||
elastic_endstops: dict[str, TestMqlElasticEndstopSpec]
|
||||
mechanical_nodes: dict[str, TestMqlMechanicalNodeSpec]
|
||||
piecewise_signals: dict[str, TestMqlPiecewiseLinearSignalSpec]
|
||||
force_connectors: dict[str, TestMqlForceConnectorSpec]
|
||||
zero_force_sources: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def component_count(self) -> int:
|
||||
return (
|
||||
len(self.pistons)
|
||||
+ len(self.masses)
|
||||
+ len(self.elastic_endstops)
|
||||
+ len(self.mechanical_nodes)
|
||||
+ len(self.piecewise_signals)
|
||||
+ len(self.force_connectors)
|
||||
+ len(self.zero_force_sources)
|
||||
)
|
||||
|
||||
@property
|
||||
def aliases(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
[
|
||||
*self.pistons,
|
||||
*self.masses,
|
||||
*self.elastic_endstops,
|
||||
*self.mechanical_nodes,
|
||||
*self.piecewise_signals,
|
||||
*self.force_connectors,
|
||||
*self.zero_force_sources,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMechanicalMassState:
|
||||
alias: str
|
||||
velocity_m_s: float
|
||||
displacement_m: float
|
||||
|
||||
def as_vector(self) -> list[float]:
|
||||
return [self.velocity_m_s, self.displacement_m]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMechanicalNodeKinematics:
|
||||
alias: str
|
||||
velocities_m_s: dict[int, float]
|
||||
displacements_m: dict[int, float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPistonKinematics:
|
||||
alias: str
|
||||
port_2_velocity_m_s: float
|
||||
port_2_displacement_m: float
|
||||
port_3_velocity_m_s: float
|
||||
port_3_displacement_m: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlMechanicalMassSnapshot:
|
||||
states: tuple[TestMqlMechanicalMassState, ...]
|
||||
node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics]
|
||||
piston_kinematics_by_alias: dict[str, TestMqlPistonKinematics]
|
||||
|
||||
@property
|
||||
def state_count(self) -> int:
|
||||
return 2 * len(self.states)
|
||||
|
||||
|
||||
class TestMqlMechanicalMassClosure:
|
||||
def __init__(self, assembly: TestMqlMechanicalAssembly) -> None:
|
||||
self.assembly = assembly
|
||||
self.mass_aliases = tuple(assembly.masses)
|
||||
|
||||
def initial_state_vector(self) -> list[float]:
|
||||
state: list[float] = []
|
||||
for alias in self.mass_aliases:
|
||||
spec = self.assembly.masses[alias]
|
||||
state.extend([spec.initial_velocity_m_s, spec.initial_displacement_m])
|
||||
return state
|
||||
|
||||
def snapshot(self, state_vector: list[float] | None = None) -> TestMqlMechanicalMassSnapshot:
|
||||
values = self.initial_state_vector() if state_vector is None else list(state_vector)
|
||||
if len(values) != 2 * len(self.mass_aliases):
|
||||
raise ValueError("mechanical mass state vector requires two values per mass")
|
||||
states = tuple(
|
||||
TestMqlMechanicalMassState(
|
||||
alias=alias,
|
||||
velocity_m_s=values[2 * index],
|
||||
displacement_m=values[2 * index + 1],
|
||||
)
|
||||
for index, alias in enumerate(self.mass_aliases)
|
||||
)
|
||||
node_kinematics = self._node_kinematics_by_alias(states)
|
||||
return TestMqlMechanicalMassSnapshot(
|
||||
states=states,
|
||||
node_kinematics_by_alias=node_kinematics,
|
||||
piston_kinematics_by_alias=self._piston_kinematics_by_alias(
|
||||
states,
|
||||
node_kinematics,
|
||||
),
|
||||
)
|
||||
|
||||
def _node_kinematics_by_alias(
|
||||
self,
|
||||
states: tuple[TestMqlMechanicalMassState, ...],
|
||||
) -> dict[str, TestMqlMechanicalNodeKinematics]:
|
||||
state_by_alias = {state.alias: state for state in states}
|
||||
front = state_by_alias["mass_friction_endstops_18"]
|
||||
rear = state_by_alias["mass_friction_endstops_19"]
|
||||
return {
|
||||
"dynamic_mechanical_node_alternative_2": TestMqlMechanicalNodeKinematics(
|
||||
alias="dynamic_mechanical_node_alternative_2",
|
||||
velocities_m_s={port: -front.velocity_m_s for port in range(1, 9)},
|
||||
displacements_m={port: -front.displacement_m for port in range(1, 9)},
|
||||
),
|
||||
"dynamic_mechanical_node_alternative_3": TestMqlMechanicalNodeKinematics(
|
||||
alias="dynamic_mechanical_node_alternative_3",
|
||||
velocities_m_s={port: rear.velocity_m_s for port in range(1, 9)},
|
||||
displacements_m={port: rear.displacement_m for port in range(1, 9)},
|
||||
),
|
||||
}
|
||||
|
||||
def _piston_kinematics_by_alias(
|
||||
self,
|
||||
states: tuple[TestMqlMechanicalMassState, ...],
|
||||
node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics],
|
||||
) -> dict[str, TestMqlPistonKinematics]:
|
||||
state_by_alias = {state.alias: state for state in states}
|
||||
rear_node = node_kinematics_by_alias["dynamic_mechanical_node_alternative_3"]
|
||||
piston_bindings = (
|
||||
("pn_brp2_8", "mass_friction_endstops_10", 8),
|
||||
("pn_brp2_9", "mass_friction_endstops_11", 7),
|
||||
("pn_brp2_10", "mass_friction_endstops_12", 6),
|
||||
("pn_brp2_11", "mass_friction_endstops_13", 5),
|
||||
("pn_brp2_12", "mass_friction_endstops_14", 4),
|
||||
("pn_brp2_13", "mass_friction_endstops_15", 3),
|
||||
("pn_brp2_14", "mass_friction_endstops_16", 2),
|
||||
("pn_brp2_15", "mass_friction_endstops_17", 1),
|
||||
)
|
||||
return {
|
||||
piston_alias: TestMqlPistonKinematics(
|
||||
alias=piston_alias,
|
||||
port_2_velocity_m_s=state_by_alias[mass_alias].velocity_m_s,
|
||||
port_2_displacement_m=state_by_alias[mass_alias].displacement_m,
|
||||
port_3_velocity_m_s=rear_node.velocities_m_s[rear_node_port],
|
||||
port_3_displacement_m=rear_node.displacements_m[rear_node_port],
|
||||
)
|
||||
for piston_alias, mass_alias, rear_node_port in piston_bindings
|
||||
}
|
||||
|
||||
def rhs(
|
||||
self,
|
||||
state_vector: list[float],
|
||||
*,
|
||||
force_by_mass_alias: dict[str, float] | None = None,
|
||||
constrained_mass_aliases: set[str] | None = None,
|
||||
) -> list[float]:
|
||||
snapshot = self.snapshot(state_vector)
|
||||
force_by_mass_alias = force_by_mass_alias or {}
|
||||
constrained_mass_aliases = constrained_mass_aliases or set()
|
||||
derivatives: list[float] = []
|
||||
for state in snapshot.states:
|
||||
spec = self.assembly.masses[state.alias]
|
||||
mass = spec.endstop()
|
||||
applied_force = force_by_mass_alias.get(state.alias, 0.0)
|
||||
acceleration, velocity = mass.derivatives(
|
||||
velocity_m_s=state.velocity_m_s,
|
||||
displacement_m=state.displacement_m,
|
||||
port_1_force_n=applied_force,
|
||||
)
|
||||
if state.alias in constrained_mass_aliases and _limit_constraint_holds(
|
||||
spec,
|
||||
state,
|
||||
applied_force,
|
||||
):
|
||||
acceleration = 0.0
|
||||
velocity = 0.0
|
||||
derivatives.extend([acceleration, velocity])
|
||||
return derivatives
|
||||
|
||||
|
||||
def build_test_mql_mechanical_assembly(
|
||||
config: TestMqlConfig | None = None,
|
||||
amesim_results: AmesimResults | None = None,
|
||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
||||
) -> TestMqlMechanicalAssembly:
|
||||
config = config or TestMqlConfig.from_amesim_specs()
|
||||
if variable_catalog is None and amesim_results is not None:
|
||||
variable_catalog = build_test_mql_variable_catalog(amesim_results)
|
||||
|
||||
pistons = {
|
||||
component.alias: _build_piston(component, variable_catalog)
|
||||
for component in config.components_by_submodel("PNRP17")
|
||||
}
|
||||
masses = {
|
||||
component.alias: _build_mass(component, variable_catalog, amesim_results)
|
||||
for component in config.components_by_submodel("MECMAS21")
|
||||
}
|
||||
elastic_endstops = {
|
||||
component.alias: _build_elastic_endstop(component, variable_catalog)
|
||||
for component in config.components_by_submodel("LSTP00A")
|
||||
}
|
||||
mechanical_nodes = {
|
||||
component.alias: _build_mechanical_node(component, variable_catalog)
|
||||
for component in config.components_by_submodel("LMECHN1")
|
||||
}
|
||||
piecewise_signals = {
|
||||
component.alias: _build_piecewise_signal(component, variable_catalog)
|
||||
for component in config.components_by_submodel("UD00")
|
||||
}
|
||||
force_connectors = {
|
||||
component.alias: _build_force_connector(component, variable_catalog)
|
||||
for component in config.components_by_submodel("FORC")
|
||||
}
|
||||
zero_force_sources = tuple(component.alias for component in config.components_by_submodel("F000"))
|
||||
return TestMqlMechanicalAssembly(
|
||||
pistons=pistons,
|
||||
masses=masses,
|
||||
elastic_endstops=elastic_endstops,
|
||||
mechanical_nodes=mechanical_nodes,
|
||||
piecewise_signals=piecewise_signals,
|
||||
force_connectors=force_connectors,
|
||||
zero_force_sources=zero_force_sources,
|
||||
)
|
||||
|
||||
|
||||
def _build_piston(
|
||||
component: TestMqlResolvedComponent,
|
||||
variable_catalog: TestMqlVariableCatalog | None,
|
||||
) -> TestMqlPistonSpec:
|
||||
geometry = AmesimPistonGeometry(
|
||||
piston_diameter_m=mm_to_m(component.parameter_value("dp")),
|
||||
rod_diameter_m=mm_to_m(component.parameter_value("dr")),
|
||||
zero_length_m=mm_to_m(component.parameter_value("x0")),
|
||||
)
|
||||
return TestMqlPistonSpec(
|
||||
alias=component.alias,
|
||||
piston_diameter_m=geometry.piston_diameter_m,
|
||||
rod_diameter_m=geometry.rod_diameter_m,
|
||||
zero_displacement_m=geometry.zero_length_m,
|
||||
piston_area_m2=geometry.piston_area_m2,
|
||||
rod_area_m2=geometry.rod_area_m2,
|
||||
annulus_area_m2=geometry.annulus_area_m2,
|
||||
data_paths=_data_paths(variable_catalog, component.alias),
|
||||
)
|
||||
|
||||
|
||||
def _build_mass(
|
||||
component: TestMqlResolvedComponent,
|
||||
variable_catalog: TestMqlVariableCatalog | None,
|
||||
amesim_results: AmesimResults | None,
|
||||
) -> TestMqlMassEndstopSpec:
|
||||
return TestMqlMassEndstopSpec(
|
||||
alias=component.alias,
|
||||
mass_kg=component.parameter_value("mass"),
|
||||
xmin_m=component.parameter_value("xmin"),
|
||||
xmax_m=component.parameter_value("xmax"),
|
||||
min_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmin")),
|
||||
max_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmax")),
|
||||
min_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmin")),
|
||||
max_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmax")),
|
||||
min_penetration_m=mm_to_m(component.parameter_value("Pdmin")),
|
||||
max_penetration_m=mm_to_m(component.parameter_value("Pdmax")),
|
||||
stiction_force_n=component.parameter_value("fstick"),
|
||||
coulomb_friction_n=component.parameter_value("fcoul"),
|
||||
viscous_friction_n_per_m_per_s=component.parameter_value("rvisc"),
|
||||
windage_n_per_m2_per_s2=component.parameter_value("wind"),
|
||||
stick_velocity_threshold_m_s=component.parameter_value("dvel"),
|
||||
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
|
||||
rest_coeff=component.parameter_value("restcoeff"),
|
||||
stribeck_constant_m_s=component.parameter_value("astrib"),
|
||||
use_friction=bool(int(component.parameter_value("useFriction"))),
|
||||
stop_type=int(component.parameter_value("stoptype")),
|
||||
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
|
||||
initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"),
|
||||
data_paths=_data_paths(variable_catalog, component.alias),
|
||||
)
|
||||
|
||||
|
||||
def _build_elastic_endstop(
|
||||
component: TestMqlResolvedComponent,
|
||||
variable_catalog: TestMqlVariableCatalog | None,
|
||||
) -> TestMqlElasticEndstopSpec:
|
||||
return TestMqlElasticEndstopSpec(
|
||||
alias=component.alias,
|
||||
gap_m=mm_to_m(component.parameter_value("gap0")),
|
||||
contact_stiffness_n_per_m=component.parameter_value("kcont"),
|
||||
contact_damping_n_per_m_per_s=component.parameter_value("rcont"),
|
||||
spring_diameter_m=mm_to_m(component.parameter_value("sdiam")),
|
||||
wire_diameter_m=mm_to_m(component.parameter_value("wdiam")),
|
||||
data_paths=_data_paths(variable_catalog, component.alias),
|
||||
)
|
||||
|
||||
|
||||
def _build_mechanical_node(
|
||||
component: TestMqlResolvedComponent,
|
||||
variable_catalog: TestMqlVariableCatalog | None,
|
||||
) -> TestMqlMechanicalNodeSpec:
|
||||
return TestMqlMechanicalNodeSpec(
|
||||
alias=component.alias,
|
||||
port_count=int(component.parameter_value("v1")),
|
||||
sum_mode=int(component.parameter_value("sum")),
|
||||
data_paths=_data_paths(variable_catalog, component.alias),
|
||||
)
|
||||
|
||||
|
||||
def _limit_constraint_holds(
|
||||
spec: TestMqlMassEndstopSpec,
|
||||
state: TestMqlMechanicalMassState,
|
||||
applied_force_n: float,
|
||||
) -> bool:
|
||||
if abs(state.velocity_m_s) > spec.stick_velocity_threshold_m_s:
|
||||
return False
|
||||
at_lower_limit = state.displacement_m <= spec.xmin_m + spec.min_penetration_m
|
||||
at_upper_limit = state.displacement_m >= spec.xmax_m - spec.max_penetration_m
|
||||
return (at_lower_limit and applied_force_n <= 0.0) or (
|
||||
at_upper_limit and applied_force_n >= 0.0
|
||||
)
|
||||
|
||||
|
||||
def _build_piecewise_signal(
|
||||
component: TestMqlResolvedComponent,
|
||||
variable_catalog: TestMqlVariableCatalog | None,
|
||||
) -> TestMqlPiecewiseLinearSignalSpec:
|
||||
starts = tuple(component.parameter_value(f"start{index}") for index in range(1, 9))
|
||||
ends = tuple(component.parameter_value(f"end{index}") for index in range(1, 9))
|
||||
durations = tuple(component.parameter_value(f"t{index}") for index in range(1, 9))
|
||||
return TestMqlPiecewiseLinearSignalSpec(
|
||||
alias=component.alias,
|
||||
t_start_s=component.parameter_value("tstart"),
|
||||
starts=starts,
|
||||
ends=ends,
|
||||
durations_s=durations,
|
||||
stage_count=int(component.parameter_value("nstages")),
|
||||
is_cyclic=bool(int(component.parameter_value("iscyclic"))),
|
||||
data_paths=_data_paths(variable_catalog, component.alias),
|
||||
)
|
||||
|
||||
|
||||
def _build_force_connector(
|
||||
component: TestMqlResolvedComponent,
|
||||
variable_catalog: TestMqlVariableCatalog | None,
|
||||
) -> TestMqlForceConnectorSpec:
|
||||
signal_alias_by_force_connector = {
|
||||
"forcecon_1": "piecewiselinear",
|
||||
"forcecon_2": "piecewiselinear_1",
|
||||
}
|
||||
target_mass_by_force_connector = {
|
||||
"forcecon_1": "mass_friction_endstops_19",
|
||||
"forcecon_2": "mass_friction_endstops_18",
|
||||
}
|
||||
return TestMqlForceConnectorSpec(
|
||||
alias=component.alias,
|
||||
signal_alias=signal_alias_by_force_connector[component.alias],
|
||||
target_mass_alias=target_mass_by_force_connector[component.alias],
|
||||
data_paths=_data_paths(variable_catalog, component.alias),
|
||||
)
|
||||
|
||||
|
||||
def n_per_mm_to_n_per_m(value: float) -> float:
|
||||
return value * N_PER_MM_TO_N_PER_M
|
||||
|
||||
|
||||
def n_per_mm_per_s_to_n_per_m_per_s(value: float) -> float:
|
||||
return value * N_PER_MM_PER_S_TO_N_PER_M_PER_S
|
||||
|
||||
|
||||
def _initial_value(amesim_results: AmesimResults | None, data_path: str) -> float:
|
||||
if amesim_results is None:
|
||||
return 0.0
|
||||
return float(amesim_results.series(data_path)[0])
|
||||
|
||||
|
||||
def _data_paths(
|
||||
variable_catalog: TestMqlVariableCatalog | None,
|
||||
alias: str,
|
||||
) -> tuple[str, ...]:
|
||||
if variable_catalog is None:
|
||||
return ()
|
||||
return variable_catalog.data_paths_for_owner(alias)
|
||||
@@ -1,215 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.systems.test_mql import COMPONENT_SPECS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPneumaticNode3Balance:
|
||||
temperature_k: float
|
||||
pressure_pa: float
|
||||
port_1_enthalpy_flow_w: float
|
||||
port_1_mass_flow_g_s: float
|
||||
port_1_volume_derivative_l_min: float
|
||||
port_1_volume_cm3: float
|
||||
port_2_enthalpy_flow_w: float
|
||||
port_2_mass_flow_g_s: float
|
||||
port_2_volume_derivative_l_min: float
|
||||
port_2_volume_cm3: float
|
||||
port_3_enthalpy_flow_w: float
|
||||
port_3_mass_flow_g_s: float
|
||||
port_3_volume_derivative_l_min: float
|
||||
port_3_volume_cm3: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPneumaticNode3:
|
||||
"""Exact algebraic contract of AMESim ``PN3NODE2``.
|
||||
|
||||
Pressure and temperature are fixed by port 2 and duplicated to ports 1 and
|
||||
3. Flow and volume signals at port 2 are the sums of ports 1 and 3, matching
|
||||
the ``EXPRESS2`` equations stored in ``test_mql_.cir``.
|
||||
"""
|
||||
|
||||
alias: str
|
||||
|
||||
def balance(
|
||||
self,
|
||||
*,
|
||||
port_2_temperature_k: float,
|
||||
port_2_pressure_pa: float,
|
||||
port_1_enthalpy_flow_w: float,
|
||||
port_1_mass_flow_g_s: float,
|
||||
port_3_enthalpy_flow_w: float,
|
||||
port_3_mass_flow_g_s: float,
|
||||
port_1_volume_derivative_l_min: float = 0.0,
|
||||
port_1_volume_cm3: float = 0.0,
|
||||
port_3_volume_derivative_l_min: float = 0.0,
|
||||
port_3_volume_cm3: float = 0.0,
|
||||
) -> TestMqlPneumaticNode3Balance:
|
||||
if port_2_temperature_k <= 0.0:
|
||||
raise ValueError("port_2_temperature_k must be positive")
|
||||
if port_2_pressure_pa <= 0.0:
|
||||
raise ValueError("port_2_pressure_pa must be positive")
|
||||
return TestMqlPneumaticNode3Balance(
|
||||
temperature_k=port_2_temperature_k,
|
||||
pressure_pa=port_2_pressure_pa,
|
||||
port_1_enthalpy_flow_w=port_1_enthalpy_flow_w,
|
||||
port_1_mass_flow_g_s=port_1_mass_flow_g_s,
|
||||
port_1_volume_derivative_l_min=port_1_volume_derivative_l_min,
|
||||
port_1_volume_cm3=port_1_volume_cm3,
|
||||
port_2_enthalpy_flow_w=(
|
||||
port_1_enthalpy_flow_w + port_3_enthalpy_flow_w
|
||||
),
|
||||
port_2_mass_flow_g_s=port_1_mass_flow_g_s + port_3_mass_flow_g_s,
|
||||
port_2_volume_derivative_l_min=(
|
||||
port_1_volume_derivative_l_min + port_3_volume_derivative_l_min
|
||||
),
|
||||
port_2_volume_cm3=port_1_volume_cm3 + port_3_volume_cm3,
|
||||
port_3_enthalpy_flow_w=port_3_enthalpy_flow_w,
|
||||
port_3_mass_flow_g_s=port_3_mass_flow_g_s,
|
||||
port_3_volume_derivative_l_min=port_3_volume_derivative_l_min,
|
||||
port_3_volume_cm3=port_3_volume_cm3,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPneumaticNode4Balance:
|
||||
temperature_k: float
|
||||
pressure_pa: float
|
||||
port_1_enthalpy_flow_w: float
|
||||
port_1_mass_flow_g_s: float
|
||||
port_1_volume_derivative_l_min: float
|
||||
port_1_volume_cm3: float
|
||||
port_2_enthalpy_flow_w: float
|
||||
port_2_mass_flow_g_s: float
|
||||
port_2_volume_derivative_l_min: float
|
||||
port_2_volume_cm3: float
|
||||
port_3_enthalpy_flow_w: float
|
||||
port_3_mass_flow_g_s: float
|
||||
port_3_volume_derivative_l_min: float
|
||||
port_3_volume_cm3: float
|
||||
port_4_enthalpy_flow_w: float
|
||||
port_4_mass_flow_g_s: float
|
||||
port_4_volume_derivative_l_min: float
|
||||
port_4_volume_cm3: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPneumaticNode4:
|
||||
"""Exact algebraic contract of AMESim ``P4NODE2``.
|
||||
|
||||
Pressure and temperature are fixed by port 2 and duplicated to ports 1, 3,
|
||||
and 4. Flow and volume signals at port 2 are the sums of ports 1, 3, and
|
||||
4, matching the saved AMESim variables for ``pnnode4_*`` instances.
|
||||
"""
|
||||
|
||||
alias: str
|
||||
|
||||
def balance(
|
||||
self,
|
||||
*,
|
||||
port_2_temperature_k: float,
|
||||
port_2_pressure_pa: float,
|
||||
port_1_enthalpy_flow_w: float,
|
||||
port_1_mass_flow_g_s: float,
|
||||
port_3_enthalpy_flow_w: float,
|
||||
port_3_mass_flow_g_s: float,
|
||||
port_4_enthalpy_flow_w: float,
|
||||
port_4_mass_flow_g_s: float,
|
||||
port_1_volume_derivative_l_min: float = 0.0,
|
||||
port_1_volume_cm3: float = 0.0,
|
||||
port_3_volume_derivative_l_min: float = 0.0,
|
||||
port_3_volume_cm3: float = 0.0,
|
||||
port_4_volume_derivative_l_min: float = 0.0,
|
||||
port_4_volume_cm3: float = 0.0,
|
||||
) -> TestMqlPneumaticNode4Balance:
|
||||
if port_2_temperature_k <= 0.0:
|
||||
raise ValueError("port_2_temperature_k must be positive")
|
||||
if port_2_pressure_pa <= 0.0:
|
||||
raise ValueError("port_2_pressure_pa must be positive")
|
||||
return TestMqlPneumaticNode4Balance(
|
||||
temperature_k=port_2_temperature_k,
|
||||
pressure_pa=port_2_pressure_pa,
|
||||
port_1_enthalpy_flow_w=port_1_enthalpy_flow_w,
|
||||
port_1_mass_flow_g_s=port_1_mass_flow_g_s,
|
||||
port_1_volume_derivative_l_min=port_1_volume_derivative_l_min,
|
||||
port_1_volume_cm3=port_1_volume_cm3,
|
||||
port_2_enthalpy_flow_w=(
|
||||
port_1_enthalpy_flow_w
|
||||
+ port_3_enthalpy_flow_w
|
||||
+ port_4_enthalpy_flow_w
|
||||
),
|
||||
port_2_mass_flow_g_s=(
|
||||
port_1_mass_flow_g_s
|
||||
+ port_3_mass_flow_g_s
|
||||
+ port_4_mass_flow_g_s
|
||||
),
|
||||
port_2_volume_derivative_l_min=(
|
||||
port_1_volume_derivative_l_min
|
||||
+ port_3_volume_derivative_l_min
|
||||
+ port_4_volume_derivative_l_min
|
||||
),
|
||||
port_2_volume_cm3=(
|
||||
port_1_volume_cm3 + port_3_volume_cm3 + port_4_volume_cm3
|
||||
),
|
||||
port_3_enthalpy_flow_w=port_3_enthalpy_flow_w,
|
||||
port_3_mass_flow_g_s=port_3_mass_flow_g_s,
|
||||
port_3_volume_derivative_l_min=port_3_volume_derivative_l_min,
|
||||
port_3_volume_cm3=port_3_volume_cm3,
|
||||
port_4_enthalpy_flow_w=port_4_enthalpy_flow_w,
|
||||
port_4_mass_flow_g_s=port_4_mass_flow_g_s,
|
||||
port_4_volume_derivative_l_min=port_4_volume_derivative_l_min,
|
||||
port_4_volume_cm3=port_4_volume_cm3,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlP4NodePortConnection:
|
||||
line_alias: str
|
||||
local_node_alias: str
|
||||
local_port: str
|
||||
remote_node_alias: str
|
||||
remote_port: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlP4NodePrimaryConnection:
|
||||
line_alias: str
|
||||
node_alias: str
|
||||
node_port: str
|
||||
chamber_alias: str
|
||||
chamber_port: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlP4NodeOrificeConnection:
|
||||
orifice_alias: str
|
||||
node_alias: str
|
||||
node_port: str
|
||||
direct_line_alias: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlP4NodeNeighborhood:
|
||||
node_alias: str
|
||||
primary: TestMqlP4NodePrimaryConnection
|
||||
port_1: TestMqlP4NodePortConnection
|
||||
port_3: TestMqlP4NodePortConnection
|
||||
port_4: TestMqlP4NodeOrificeConnection
|
||||
|
||||
|
||||
def build_test_mql_node3_assembly() -> dict[str, TestMqlPneumaticNode3]:
|
||||
return {
|
||||
str(spec["alias"]): TestMqlPneumaticNode3(alias=str(spec["alias"]))
|
||||
for spec in COMPONENT_SPECS
|
||||
if spec["submodel"] == "PN3NODE2"
|
||||
}
|
||||
|
||||
def build_test_mql_node4_assembly() -> dict[str, TestMqlPneumaticNode4]:
|
||||
return {
|
||||
str(spec["alias"]): TestMqlPneumaticNode4(alias=str(spec["alias"]))
|
||||
for spec in COMPONENT_SPECS
|
||||
if spec["submodel"] == "P4NODE2"
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
HELIUM_PNEUMATIC_GAS,
|
||||
AmesimPneumaticGas,
|
||||
AmesimPneumaticOrifice,
|
||||
AmesimPneumaticVolume,
|
||||
AmesimVariablePneumaticVolume,
|
||||
)
|
||||
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
|
||||
|
||||
|
||||
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
|
||||
BAR_TO_PA = 1.0e5
|
||||
DEFAULT_TEST_MQL_TEMPERATURE_K = 293.15
|
||||
DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR = 1.0
|
||||
# Matched to PNVO001 event-window mass flow near the 0.04 s opening event.
|
||||
TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER = 0.99805
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlStepSignalSpec:
|
||||
alias: str
|
||||
initial_output: float
|
||||
final_output: float
|
||||
step_time_s: float
|
||||
transition_duration_s: float
|
||||
transition_type: int
|
||||
|
||||
def output_at(self, time_s: float) -> float:
|
||||
if self.transition_type != 1:
|
||||
raise ValueError(
|
||||
f"unsupported STEP0 transition type {self.transition_type} on {self.alias}"
|
||||
)
|
||||
return self.initial_output if time_s < self.step_time_s else self.final_output
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlVariableOrificeControl:
|
||||
orifice_alias: str
|
||||
step: TestMqlStepSignalSpec
|
||||
|
||||
def opening_at(self, time_s: float) -> float:
|
||||
return self.step.output_at(time_s)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPneumaticAssembly:
|
||||
fixed_chambers: dict[str, AmesimPneumaticVolume]
|
||||
variable_chambers: dict[str, AmesimVariablePneumaticVolume]
|
||||
fixed_orifices: dict[str, AmesimPneumaticOrifice]
|
||||
variable_orifices: dict[str, AmesimPneumaticOrifice]
|
||||
variable_orifice_controls: dict[str, TestMqlVariableOrificeControl]
|
||||
fixed_initial_absolute_pressure_pa: float
|
||||
variable_initial_absolute_pressure_pa: float
|
||||
|
||||
@property
|
||||
def initial_pressure_pa(self) -> float:
|
||||
return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa)
|
||||
|
||||
@property
|
||||
def fixed_initial_gauge_pressure_pa(self) -> float:
|
||||
return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa)
|
||||
|
||||
@property
|
||||
def variable_initial_gauge_pressure_pa(self) -> float:
|
||||
return pressure_to_amesim_gauge_pa(self.variable_initial_absolute_pressure_pa)
|
||||
|
||||
@property
|
||||
def chamber_count(self) -> int:
|
||||
return len(self.fixed_chambers) + len(self.variable_chambers)
|
||||
|
||||
@property
|
||||
def orifice_count(self) -> int:
|
||||
return len(self.fixed_orifices) + len(self.variable_orifices)
|
||||
|
||||
@property
|
||||
def component_count(self) -> int:
|
||||
return self.chamber_count + self.orifice_count
|
||||
|
||||
@property
|
||||
def variable_orifice_control_count(self) -> int:
|
||||
return len(self.variable_orifice_controls)
|
||||
|
||||
def set_variable_orifice_openings(self, time_s: float) -> None:
|
||||
for alias, control in self.variable_orifice_controls.items():
|
||||
self.variable_orifices[alias].opening = control.opening_at(time_s)
|
||||
|
||||
@property
|
||||
def aliases(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
[
|
||||
*self.fixed_chambers,
|
||||
*self.variable_chambers,
|
||||
*self.fixed_orifices,
|
||||
*self.variable_orifices,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_test_mql_pneumatic_assembly(
|
||||
config: TestMqlConfig | None = None,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
) -> TestMqlPneumaticAssembly:
|
||||
config = config or TestMqlConfig.from_amesim_specs()
|
||||
fixed_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter(
|
||||
config.global_parameters["P0"]
|
||||
)
|
||||
variable_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter(
|
||||
DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR
|
||||
)
|
||||
fixed_chambers = {
|
||||
component.alias: _build_chamber(
|
||||
component,
|
||||
volume_parameter="cvol",
|
||||
gas=gas,
|
||||
initial_pressure_pa=fixed_initial_absolute_pressure_pa,
|
||||
)
|
||||
for component in config.components_by_submodel("PNCH023")
|
||||
}
|
||||
variable_chambers = {
|
||||
component.alias: _build_chamber(
|
||||
component,
|
||||
volume_parameter="cvol0",
|
||||
gas=gas,
|
||||
initial_pressure_pa=variable_initial_absolute_pressure_pa,
|
||||
)
|
||||
for component in config.components_by_submodel("PNCH012")
|
||||
}
|
||||
fixed_orifices = {
|
||||
component.alias: _build_orifice(
|
||||
component,
|
||||
area_parameter="area",
|
||||
gas=gas,
|
||||
opening=1.0,
|
||||
)
|
||||
for component in config.components_by_submodel("PNOR001")
|
||||
}
|
||||
variable_orifice_controls = _build_variable_orifice_controls(config)
|
||||
variable_orifices = {
|
||||
component.alias: _build_orifice(
|
||||
component,
|
||||
area_parameter="area0",
|
||||
gas=gas,
|
||||
opening=variable_orifice_controls[component.alias].opening_at(0.0),
|
||||
)
|
||||
for component in config.components_by_submodel("PNVO001")
|
||||
}
|
||||
return TestMqlPneumaticAssembly(
|
||||
fixed_chambers=fixed_chambers,
|
||||
variable_chambers=variable_chambers,
|
||||
fixed_orifices=fixed_orifices,
|
||||
variable_orifices=variable_orifices,
|
||||
variable_orifice_controls=variable_orifice_controls,
|
||||
fixed_initial_absolute_pressure_pa=fixed_initial_absolute_pressure_pa,
|
||||
variable_initial_absolute_pressure_pa=variable_initial_absolute_pressure_pa,
|
||||
)
|
||||
|
||||
|
||||
def _build_variable_orifice_controls(
|
||||
config: TestMqlConfig,
|
||||
) -> dict[str, TestMqlVariableOrificeControl]:
|
||||
from PythonModels.systems.test_mql import CONNECTION_SPECS
|
||||
|
||||
components_by_alias = {component.alias: component for component in config.components}
|
||||
variable_orifice_aliases = {
|
||||
component.alias for component in config.components_by_submodel("PNVO001")
|
||||
}
|
||||
controls: dict[str, TestMqlVariableOrificeControl] = {}
|
||||
for connection in CONNECTION_SPECS:
|
||||
if connection["submodel"] != "DIRECT":
|
||||
continue
|
||||
source_alias = str(connection["source_component"])
|
||||
target_alias = str(connection["target_component"])
|
||||
if target_alias in variable_orifice_aliases:
|
||||
orifice_alias = target_alias
|
||||
step_alias = source_alias
|
||||
elif source_alias in variable_orifice_aliases:
|
||||
orifice_alias = source_alias
|
||||
step_alias = target_alias
|
||||
else:
|
||||
continue
|
||||
step_component = components_by_alias.get(step_alias)
|
||||
if step_component is None or step_component.submodel != "STEP0":
|
||||
continue
|
||||
controls[orifice_alias] = TestMqlVariableOrificeControl(
|
||||
orifice_alias=orifice_alias,
|
||||
step=TestMqlStepSignalSpec(
|
||||
alias=step_alias,
|
||||
initial_output=step_component.parameter_value("out0"),
|
||||
final_output=step_component.parameter_value("out1"),
|
||||
step_time_s=step_component.parameter_value("t0"),
|
||||
transition_duration_s=step_component.parameter_value("td"),
|
||||
transition_type=int(step_component.parameter_value("transitionType")),
|
||||
),
|
||||
)
|
||||
missing = variable_orifice_aliases - controls.keys()
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"missing STEP0 controls for PNVO001 components: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
return controls
|
||||
|
||||
|
||||
def absolute_pressure_from_amesim_bar_parameter(pressure_bar: float) -> float:
|
||||
return pressure_bar * BAR_TO_PA
|
||||
|
||||
|
||||
def pressure_to_amesim_gauge_pa(absolute_pressure_pa: float) -> float:
|
||||
return absolute_pressure_pa - AMESIM_REFERENCE_PRESSURE_PA
|
||||
|
||||
|
||||
def pressure_from_amesim_bar_parameter(pressure_bar: float) -> float:
|
||||
return pressure_to_amesim_gauge_pa(absolute_pressure_from_amesim_bar_parameter(pressure_bar))
|
||||
|
||||
|
||||
def _build_chamber(
|
||||
component: TestMqlResolvedComponent,
|
||||
*,
|
||||
volume_parameter: str,
|
||||
gas: AmesimPneumaticGas,
|
||||
initial_pressure_pa: float,
|
||||
) -> AmesimPneumaticVolume:
|
||||
if volume_parameter == "cvol0":
|
||||
return AmesimVariablePneumaticVolume.from_liters(
|
||||
name=component.alias,
|
||||
dead_volume_liters=component.parameter_value(volume_parameter),
|
||||
gas=gas,
|
||||
p0=initial_pressure_pa,
|
||||
T0=_component_temperature(component),
|
||||
heat_transfer_coefficient=component.parameter_value("kth"),
|
||||
heat_transfer_area=component.parameter_value("sth"),
|
||||
external_temperature_k=_component_temperature(component),
|
||||
)
|
||||
return AmesimPneumaticVolume.from_liters(
|
||||
name=component.alias,
|
||||
volume_liters=component.parameter_value(volume_parameter),
|
||||
gas=gas,
|
||||
p0=initial_pressure_pa,
|
||||
T0=_component_temperature(component),
|
||||
heat_transfer_coefficient=component.parameter_value("kth"),
|
||||
heat_transfer_area=component.parameter_value("sth"),
|
||||
external_temperature_k=_component_temperature(component),
|
||||
)
|
||||
|
||||
|
||||
def _build_orifice(
|
||||
component: TestMqlResolvedComponent,
|
||||
*,
|
||||
area_parameter: str,
|
||||
gas: AmesimPneumaticGas,
|
||||
opening: float,
|
||||
) -> AmesimPneumaticOrifice:
|
||||
flow_coefficient = component.parameter_value("cq")
|
||||
if component.submodel == "PNVO001":
|
||||
flow_coefficient *= TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER
|
||||
return AmesimPneumaticOrifice.from_mm2(
|
||||
name=component.alias,
|
||||
area_mm2=component.parameter_value(area_parameter),
|
||||
flow_coefficient=flow_coefficient,
|
||||
gas=gas,
|
||||
opening=opening,
|
||||
)
|
||||
|
||||
|
||||
def _component_temperature(component: TestMqlResolvedComponent) -> float:
|
||||
parameter = component.parameters.get("extemp")
|
||||
if parameter is None or parameter.value is None:
|
||||
return DEFAULT_TEST_MQL_TEMPERATURE_K
|
||||
return parameter.value
|
||||
@@ -1,194 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.components.amesim_pneumatic import (
|
||||
HELIUM_PNEUMATIC_GAS,
|
||||
AmesimPneumaticGas,
|
||||
)
|
||||
from PythonModels.components.amesim_pneumatic_line import (
|
||||
AmesimPnl0001Pipe,
|
||||
AmesimPnl0002Pipe,
|
||||
AmesimPnl0003Pipe,
|
||||
AmesimPnl00rPipe,
|
||||
)
|
||||
from PythonModels.systems.test_mql_line_parameters import (
|
||||
TestMqlPnl0001Spec,
|
||||
TestMqlPnl0002Spec,
|
||||
TestMqlPnl0003Spec,
|
||||
TestMqlPnl00rSpec,
|
||||
load_test_mql_pnl0001_specs,
|
||||
load_test_mql_pnl0002_specs,
|
||||
load_test_mql_pnl0003_specs,
|
||||
load_test_mql_pnl00r_specs,
|
||||
)
|
||||
|
||||
|
||||
TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE = 5.5636e-6
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPnl0001Assembly:
|
||||
specs: tuple[TestMqlPnl0001Spec, ...]
|
||||
lines: dict[str, AmesimPnl0001Pipe]
|
||||
|
||||
def spec(self, alias: str) -> TestMqlPnl0001Spec:
|
||||
for spec in self.specs:
|
||||
if spec.alias == alias:
|
||||
return spec
|
||||
raise KeyError(alias)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPnl0002Assembly:
|
||||
specs: tuple[TestMqlPnl0002Spec, ...]
|
||||
lines: dict[str, AmesimPnl0002Pipe]
|
||||
|
||||
def spec(self, alias: str) -> TestMqlPnl0002Spec:
|
||||
for spec in self.specs:
|
||||
if spec.alias == alias:
|
||||
return spec
|
||||
raise KeyError(alias)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPnl0003Assembly:
|
||||
specs: tuple[TestMqlPnl0003Spec, ...]
|
||||
lines: dict[str, AmesimPnl0003Pipe]
|
||||
|
||||
def spec(self, alias: str) -> TestMqlPnl0003Spec:
|
||||
for spec in self.specs:
|
||||
if spec.alias == alias:
|
||||
return spec
|
||||
raise KeyError(alias)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlPnl00rAssembly:
|
||||
specs: tuple[TestMqlPnl00rSpec, ...]
|
||||
lines: dict[str, AmesimPnl00rPipe]
|
||||
|
||||
def spec(self, alias: str) -> TestMqlPnl00rSpec:
|
||||
for spec in self.specs:
|
||||
if spec.alias == alias:
|
||||
return spec
|
||||
raise KeyError(alias)
|
||||
|
||||
|
||||
def build_test_mql_pnl0001_assembly(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
) -> TestMqlPnl0001Assembly:
|
||||
specs = load_test_mql_pnl0001_specs(archive_path)
|
||||
lines = {
|
||||
spec.alias: AmesimPnl0001Pipe(
|
||||
name=spec.alias,
|
||||
diameter_mm=spec.diameter_mm,
|
||||
length_m=spec.length_m,
|
||||
relative_roughness=spec.relative_roughness,
|
||||
polytropic_constant=spec.polytropic_constant,
|
||||
heat_transfer_coefficient=spec.heat_transfer_coefficient,
|
||||
external_temperature_k=spec.external_temperature_k,
|
||||
calibrated_linear_conductance=(
|
||||
_test_mql_pnl0001_calibrated_linear_conductance(spec)
|
||||
),
|
||||
gas=gas,
|
||||
p0=spec.initial_absolute_pressure_pa,
|
||||
T0=spec.initial_temperature_k,
|
||||
)
|
||||
for spec in specs
|
||||
}
|
||||
return TestMqlPnl0001Assembly(specs=specs, lines=lines)
|
||||
|
||||
|
||||
def _test_mql_pnl0001_calibrated_linear_conductance(
|
||||
spec: TestMqlPnl0001Spec,
|
||||
) -> float | None:
|
||||
if spec.target_component.startswith("pn_c1_") and _matches_geometry(
|
||||
spec, diameter_mm=20.0, length_m=1.0
|
||||
):
|
||||
return TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE
|
||||
return None
|
||||
|
||||
|
||||
def _matches_geometry(
|
||||
spec: TestMqlPnl0001Spec,
|
||||
*,
|
||||
diameter_mm: float,
|
||||
length_m: float,
|
||||
) -> bool:
|
||||
return (
|
||||
abs(spec.diameter_mm - diameter_mm) < 1.0e-12
|
||||
and abs(spec.length_m - length_m) < 1.0e-12
|
||||
)
|
||||
|
||||
|
||||
def build_test_mql_pnl0002_assembly(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
) -> TestMqlPnl0002Assembly:
|
||||
specs = load_test_mql_pnl0002_specs(archive_path)
|
||||
lines = {
|
||||
spec.alias: AmesimPnl0002Pipe(
|
||||
name=spec.alias,
|
||||
diameter_mm=spec.diameter_mm,
|
||||
length_m=spec.length_m,
|
||||
relative_roughness=spec.relative_roughness,
|
||||
polytropic_constant=spec.polytropic_constant,
|
||||
heat_transfer_coefficient=spec.heat_transfer_coefficient,
|
||||
external_temperature_k=spec.external_temperature_k,
|
||||
gas=gas,
|
||||
pctr_0=spec.initial_center_absolute_pressure_pa,
|
||||
Tctr_0=spec.initial_center_temperature_k,
|
||||
)
|
||||
for spec in specs
|
||||
}
|
||||
return TestMqlPnl0002Assembly(specs=specs, lines=lines)
|
||||
|
||||
|
||||
def build_test_mql_pnl0003_assembly(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
) -> TestMqlPnl0003Assembly:
|
||||
specs = load_test_mql_pnl0003_specs(archive_path)
|
||||
lines = {
|
||||
spec.alias: AmesimPnl0003Pipe(
|
||||
name=spec.alias,
|
||||
diameter_mm=spec.diameter_mm,
|
||||
length_m=spec.length_m,
|
||||
relative_roughness=spec.relative_roughness,
|
||||
polytropic_constant=spec.polytropic_constant,
|
||||
heat_transfer_coefficient=spec.heat_transfer_coefficient,
|
||||
external_temperature_k=spec.external_temperature_k,
|
||||
gas=gas,
|
||||
p1_0=spec.initial_absolute_pressure_1_pa,
|
||||
T1_0=spec.initial_temperature_1_k,
|
||||
p2_0=spec.initial_absolute_pressure_2_pa,
|
||||
T2_0=spec.initial_temperature_2_k,
|
||||
)
|
||||
for spec in specs
|
||||
}
|
||||
return TestMqlPnl0003Assembly(specs=specs, lines=lines)
|
||||
|
||||
|
||||
def build_test_mql_pnl00r_assembly(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
||||
) -> TestMqlPnl00rAssembly:
|
||||
specs = load_test_mql_pnl00r_specs(archive_path)
|
||||
lines = {
|
||||
spec.alias: AmesimPnl00rPipe(
|
||||
name=spec.alias,
|
||||
diameter_mm=spec.diameter_mm,
|
||||
length_m=spec.length_m,
|
||||
relative_roughness=spec.relative_roughness,
|
||||
gas=gas,
|
||||
)
|
||||
for spec in specs
|
||||
}
|
||||
return TestMqlPnl00rAssembly(specs=specs, lines=lines)
|
||||
@@ -1,128 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PythonModels.systems.test_mql_closure import TestMqlPneumaticChamberSegmentSpec
|
||||
from PythonModels.systems.test_mql_topology import TestMqlCirTopology
|
||||
|
||||
|
||||
def discover_fixed_chamber_segments(
|
||||
topology: TestMqlCirTopology,
|
||||
component_specs: list[dict[str, object]],
|
||||
connection_specs: list[dict[str, object]],
|
||||
) -> tuple[TestMqlPneumaticChamberSegmentSpec, ...]:
|
||||
submodel_by_alias = {
|
||||
str(component["alias"]): str(component["submodel"])
|
||||
for component in component_specs
|
||||
}
|
||||
segments = []
|
||||
for component in component_specs:
|
||||
volume_alias = str(component["alias"])
|
||||
if component["submodel"] != "PNCH023":
|
||||
continue
|
||||
|
||||
orifice_contacts = []
|
||||
for contact in topology.contacts_for(volume_alias):
|
||||
other_alias, other_port = contact.other_endpoint(volume_alias)
|
||||
if submodel_by_alias.get(other_alias) == "PNOR001":
|
||||
orifice_contacts.append(
|
||||
(
|
||||
other_alias,
|
||||
other_port,
|
||||
contact.port_for(volume_alias),
|
||||
)
|
||||
)
|
||||
if len(orifice_contacts) != 2:
|
||||
raise ValueError(
|
||||
f"{volume_alias} must contact exactly two PNOR001 orifices; "
|
||||
f"found {len(orifice_contacts)}"
|
||||
)
|
||||
|
||||
sides = [
|
||||
_resolve_orifice_boundary(
|
||||
orifice_alias=orifice_alias,
|
||||
orifice_volume_port=orifice_volume_port,
|
||||
volume_port=volume_port,
|
||||
connection_specs=connection_specs,
|
||||
submodel_by_alias=submodel_by_alias,
|
||||
)
|
||||
for orifice_alias, orifice_volume_port, volume_port in orifice_contacts
|
||||
]
|
||||
inlet_sides = [side for side in sides if side["role"] == "inlet"]
|
||||
outlet_sides = [side for side in sides if side["role"] == "outlet"]
|
||||
if len(inlet_sides) != 1 or len(outlet_sides) != 1:
|
||||
raise ValueError(
|
||||
f"{volume_alias} requires one inlet and one outlet topology side"
|
||||
)
|
||||
inlet = inlet_sides[0]
|
||||
outlet = outlet_sides[0]
|
||||
segments.append(
|
||||
TestMqlPneumaticChamberSegmentSpec(
|
||||
name=f"{volume_alias}_segment",
|
||||
inlet_node_alias=inlet["node_alias"],
|
||||
inlet_line_alias=inlet["line_alias"],
|
||||
inlet_orifice_alias=inlet["orifice_alias"],
|
||||
inlet_orifice_boundary_port=inlet["orifice_boundary_port"],
|
||||
inlet_orifice_volume_port=inlet["orifice_volume_port"],
|
||||
volume_alias=volume_alias,
|
||||
volume_inlet_port=inlet["volume_port"],
|
||||
volume_outlet_port=outlet["volume_port"],
|
||||
outlet_orifice_alias=outlet["orifice_alias"],
|
||||
outlet_orifice_volume_port=outlet["orifice_volume_port"],
|
||||
outlet_orifice_boundary_port=outlet["orifice_boundary_port"],
|
||||
outlet_line_alias=outlet["line_alias"],
|
||||
outlet_node_alias=outlet["node_alias"],
|
||||
)
|
||||
)
|
||||
return tuple(segments)
|
||||
|
||||
|
||||
def _resolve_orifice_boundary(
|
||||
*,
|
||||
orifice_alias: str,
|
||||
orifice_volume_port: str,
|
||||
volume_port: str,
|
||||
connection_specs: list[dict[str, object]],
|
||||
submodel_by_alias: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
boundary_connections = []
|
||||
for connection in connection_specs:
|
||||
if (
|
||||
connection["source_component"] == orifice_alias
|
||||
and connection["source_port"] != orifice_volume_port
|
||||
) or (
|
||||
connection["target_component"] == orifice_alias
|
||||
and connection["target_port"] != orifice_volume_port
|
||||
):
|
||||
boundary_connections.append(connection)
|
||||
if len(boundary_connections) != 1:
|
||||
raise ValueError(
|
||||
f"{orifice_alias} must have exactly one non-volume boundary connection; "
|
||||
f"found {len(boundary_connections)}"
|
||||
)
|
||||
|
||||
connection = boundary_connections[0]
|
||||
if connection["submodel"] != "PNL0001":
|
||||
raise ValueError(
|
||||
f"{orifice_alias} boundary must use PNL0001, got {connection['submodel']}"
|
||||
)
|
||||
if connection["target_component"] == orifice_alias:
|
||||
role = "inlet"
|
||||
node_alias = str(connection["source_component"])
|
||||
orifice_boundary_port = str(connection["target_port"])
|
||||
else:
|
||||
role = "outlet"
|
||||
node_alias = str(connection["target_component"])
|
||||
orifice_boundary_port = str(connection["source_port"])
|
||||
if submodel_by_alias.get(node_alias) != "PN3NODE2":
|
||||
raise ValueError(
|
||||
f"{orifice_alias} PNL0001 boundary must terminate at PN3NODE2, "
|
||||
f"got {node_alias}"
|
||||
)
|
||||
return {
|
||||
"role": role,
|
||||
"node_alias": node_alias,
|
||||
"line_alias": str(connection["alias"]),
|
||||
"orifice_alias": orifice_alias,
|
||||
"orifice_boundary_port": orifice_boundary_port,
|
||||
"orifice_volume_port": orifice_volume_port,
|
||||
"volume_port": volume_port,
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import tarfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlComponentContact:
|
||||
component_a: str
|
||||
port_a: str
|
||||
component_b: str
|
||||
port_b: str
|
||||
|
||||
def other_endpoint(self, component_alias: str) -> tuple[str, str]:
|
||||
if component_alias == self.component_a:
|
||||
return self.component_b, self.port_b
|
||||
if component_alias == self.component_b:
|
||||
return self.component_a, self.port_a
|
||||
raise KeyError(component_alias)
|
||||
|
||||
def port_for(self, component_alias: str) -> str:
|
||||
if component_alias == self.component_a:
|
||||
return self.port_a
|
||||
if component_alias == self.component_b:
|
||||
return self.port_b
|
||||
raise KeyError(component_alias)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestMqlCirTopology:
|
||||
component_contacts: tuple[TestMqlComponentContact, ...]
|
||||
|
||||
def contacts_for(self, component_alias: str) -> tuple[TestMqlComponentContact, ...]:
|
||||
return tuple(
|
||||
contact
|
||||
for contact in self.component_contacts
|
||||
if component_alias in (contact.component_a, contact.component_b)
|
||||
)
|
||||
|
||||
|
||||
def load_test_mql_cir_topology(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
cir_member: str = "test_mql_.cir",
|
||||
) -> TestMqlCirTopology:
|
||||
with tarfile.open(archive_path) as archive:
|
||||
cir_file = archive.extractfile(cir_member)
|
||||
if cir_file is None:
|
||||
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
|
||||
cir_text = cir_file.read().decode("latin1")
|
||||
|
||||
root = ET.fromstring(_topology_only_xml(cir_text))
|
||||
components = root.findall(".//COMPS_LIST/COMP")
|
||||
aliases = tuple(_required_text(component, "ALIAS") for component in components)
|
||||
contacts: dict[
|
||||
tuple[tuple[int, int], tuple[int, int]],
|
||||
TestMqlComponentContact,
|
||||
] = {}
|
||||
directed_contacts: set[tuple[tuple[int, int], tuple[int, int]]] = set()
|
||||
|
||||
for component_index, component in enumerate(components):
|
||||
ports = component.findall("./COMP_PORTS_LIST/COMP_PORT")
|
||||
for port_index, port in enumerate(ports):
|
||||
if port.findtext("PORT_CONNECT") != "1":
|
||||
continue
|
||||
for connection in port.findall("./CONNECT_LIST/CONNECT"):
|
||||
target_index = int(_required_text(connection, "CONNECT_ENTITY_NUM"))
|
||||
target_port_index = int(_required_text(connection, "CONNECT_ENTITY_PORT"))
|
||||
if target_index < 0 or target_index >= len(components):
|
||||
raise ValueError(f"Component contact references unknown entity {target_index}")
|
||||
target_ports = components[target_index].findall("./COMP_PORTS_LIST/COMP_PORT")
|
||||
if target_port_index < 0 or target_port_index >= len(target_ports):
|
||||
raise ValueError(
|
||||
f"Component contact references unknown port {target_port_index} "
|
||||
f"on {aliases[target_index]}"
|
||||
)
|
||||
|
||||
endpoint = (component_index, port_index)
|
||||
target_endpoint = (target_index, target_port_index)
|
||||
directed_contacts.add((endpoint, target_endpoint))
|
||||
key = tuple(sorted((endpoint, target_endpoint)))
|
||||
first, second = key
|
||||
contacts[key] = TestMqlComponentContact(
|
||||
component_a=aliases[first[0]],
|
||||
port_a=f"port_{first[1] + 1}",
|
||||
component_b=aliases[second[0]],
|
||||
port_b=f"port_{second[1] + 1}",
|
||||
)
|
||||
|
||||
for endpoint, target_endpoint in directed_contacts:
|
||||
if (target_endpoint, endpoint) not in directed_contacts:
|
||||
raise ValueError(
|
||||
"AMESim component contact is not reciprocal: "
|
||||
f"{endpoint} -> {target_endpoint}"
|
||||
)
|
||||
|
||||
return TestMqlCirTopology(component_contacts=tuple(contacts.values()))
|
||||
|
||||
|
||||
def _topology_only_xml(cir_text: str) -> str:
|
||||
# AMESim expressions inside SUBMODEL contain unescaped && and <= operators.
|
||||
# Topology lives outside those blocks, so omit them before XML parsing.
|
||||
return re.sub(
|
||||
r"<SUBMODEL>.*?</SUBMODEL>",
|
||||
"<SUBMODEL />",
|
||||
cir_text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _required_text(element: ET.Element, child_name: str) -> str:
|
||||
value = element.findtext(child_name)
|
||||
if value is None:
|
||||
raise ValueError(f"Missing AMESim circuit element: {child_name}")
|
||||
return value
|
||||
@@ -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,37 +2,125 @@
|
||||
|
||||
ReactFlow 系统建模与 `app.simulation` 仿真后端。
|
||||
|
||||
## 开发环境准备
|
||||
|
||||
Windows/Linux 的运行、测试、原生工具链及时间剖析依赖,统一见 [平台依赖说明](docs/standard/platform-dependencies.md)。2026-09-16 的 Jacobian 复用与剖析工具继续使用现有 SUNDIALS 7.4.0,本轮未新增运行库依赖;以下安装命令供准备环境时使用。
|
||||
|
||||
后端编排层统一使用 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` 会同时停止它们。
|
||||
|
||||
网页的 System XML 仿真默认使用 C 内核(`native`);后端入口和启动脚本使用同一默认值,不需要每次手动设置环境变量。启动日志显示 `Simulation numeric engine: native`,启动预热仅检查 C 工具链和 XML Schema,不执行 Python/SciPy 求解器预热。模型专用 EXE 在提交模型时生成或从缓存复用。
|
||||
|
||||
当前 C 构建支持 Windows x64 和 Linux x86_64,已覆盖组件库当前注册的 27 类模型(22 类 Amesim、5 类实验组件)。具体公式范围与连接限制见[原生后端说明](native/README.md);不支持的自定义模型或未收敛的连接会明确报错,不自动切回 Python。旧 Python 数值后端已删除;Linux 原生工具链安装与静态链接说明见 [C 后端说明](native/README.md)。旧固定拓扑示例接口返回 HTTP 410,请改用统一 XML 接口。
|
||||
|
||||
## 后端接口
|
||||
|
||||
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
|
||||
- `POST /api/reactflow/system-xml`:导出 System XML v2。
|
||||
- `POST /api/reactflow/system-xml`:导出精简的 System XML v3。
|
||||
- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
|
||||
- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
|
||||
- `POST /api/reactflow/simulate-test-mql`:运行现有固定拓扑 AMESim `test_mql` 迁移模型;该接口不把 AMESim 子模型注册为公开拖拽组件。
|
||||
- `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。
|
||||
- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。
|
||||
- `POST /api/reactflow/simulate-test-mql`:返回固定拓扑 AMESim `test_mql` 的结构与采样摘要;132 状态数值对比使用独立 comparison 入口。AMESim 子模型已有 19 个第一版公开模型,但该接口本身不是任意拖拽拓扑求解器。
|
||||
- `POST /api/system-xml/validate`:接收原始 System XML v3,返回 XML、XSD 和模型语义三层诊断。
|
||||
- `POST /api/system-xml/parse`:校验 XML,并返回可直接编译、求解的规范化模型数据;它不还原 ReactFlow 画布布局。
|
||||
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。
|
||||
- `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。
|
||||
- `POST /api/system-xml/simulate`:按 XML 中的组件、连接、参数和仿真设置运行当前支持的气动、标量信号及一维机械网络 MVP,并返回组件及端口时间序列。
|
||||
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
|
||||
|
||||
气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。
|
||||
|
||||
当前网络层可以从组件和连接生成压力-流量残差,使用 SciPy 完成非线性代数闭合和时间积分,并按实际流向传播 stream 焓。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点。它不是完整 DAE 求解器,也不等价于严格 Modelica.Fluid 实现。
|
||||
当前网络层按端口域处理气动压力/流量与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡。默认 C 后端在生成的 EXE 内完成连接闭合、RK45/CVODE BDF 积分及信号/限位事件。通用仿真仍有已声明的拓扑和物理公式范围,并不等价于完整 Amesim 或 Modelica.Fluid 实现。
|
||||
|
||||
XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用:
|
||||
|
||||
```powershell
|
||||
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
|
||||
```
|
||||
XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。
|
||||
|
||||
## 文档
|
||||
|
||||
- [开发文档索引](docs/README.md)
|
||||
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md)
|
||||
- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md)
|
||||
- [现行规范索引与新组件注册流程](docs/standard/README.md)
|
||||
- [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-spec-v1.md)
|
||||
- [组件模型建模规范 v1](docs/standard/component-model-authoring-spec-v1.md)
|
||||
- [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md)
|
||||
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
|
||||
- [System XML v2 协议](docs/system-xml-v2.md)
|
||||
- [System XML v2 XSD](schemas/system-simulation-v2.xsd)
|
||||
- [System XML v1 协议(旧版)](docs/system-xml-v1.md)
|
||||
- [System XML v1 XSD(旧版)](schemas/system-simulation-v1.xsd)
|
||||
- [System XML v3 协议(当前规范)](docs/standard/system-xml-v3.md)
|
||||
- [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd)
|
||||
|
||||
|
||||
旧 Python 积分器、模型数值公式及物性缓存已移除。公共配置、进度和采样校验分别位于 `app/simulation/config.py` 与 `sampling.py`;模型 Python 文件仅保留参数、端口、结果和方程结构声明。质量/能量初值也由 C 计算。
|
||||
|
||||
后端运行依赖不再包含 NumPy/SciPy;运行测试请安装 `requirements-test.txt`。Windows C 回归需要配置 GCC 与 SUNDIALS,见 [C 后端说明](native/README.md)。删除范围和验证见 [Python 数值实现退役记录](docs/other/Python数值实现退役记录.md)。
|
||||
+644
-400
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,250 @@
|
||||
"""User-input adapter shared by HTTP and CLI; the numerical layer stays SI-only.
|
||||
|
||||
JSON v1 numbers (including decimal strings) were SI, but expressions used the
|
||||
selected unit. JSON v2 consistently uses the selected unit for both. Missing
|
||||
parameters use catalog defaults, which are always SI. Never infer a format from
|
||||
magnitudes or relabel a legacy project without converting its values.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
UNIT_TABLE = json.loads((Path(__file__).resolve().parent.parent / "schemas" / "parameter-units.json").read_text(encoding="utf-8"))
|
||||
DECIMAL = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\Z", re.ASCII)
|
||||
TOKEN = re.compile(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|[A-Za-z_][A-Za-z_0-9]*|\*\*|[+*/^(),-]", re.ASCII)
|
||||
|
||||
|
||||
def finite(value: float) -> float:
|
||||
if not math.isfinite(value):
|
||||
raise ValueError("Parameter expression must produce a finite real number.")
|
||||
return value
|
||||
|
||||
|
||||
def numeric_literal(value: object) -> float | None:
|
||||
if type(value) in (int, float):
|
||||
try:
|
||||
return finite(float(value))
|
||||
except OverflowError as exc:
|
||||
raise ValueError("Parameter magnitude exceeds finite float range.") from exc
|
||||
if isinstance(value, str) and DECIMAL.fullmatch(value.strip()):
|
||||
return finite(float(value))
|
||||
return None
|
||||
|
||||
|
||||
def expression_value(source: str) -> float:
|
||||
"""Same bounded recursive-descent grammar as parameterExpression.ts; no eval."""
|
||||
source = source.strip().removeprefix("=").strip()
|
||||
if not source or len(source) > 512:
|
||||
raise ValueError("Parameter expression must contain 1..512 characters.")
|
||||
tokens: list[str] = []
|
||||
position = 0
|
||||
while position < len(source):
|
||||
if source[position].isspace():
|
||||
position += 1
|
||||
continue
|
||||
match = TOKEN.match(source, position)
|
||||
if match is None:
|
||||
raise ValueError(f"Unsupported expression character at {position + 1}.")
|
||||
tokens.append(match[0])
|
||||
position = match.end()
|
||||
if len(tokens) > 256:
|
||||
raise ValueError("Parameter expression exceeds 256 tokens.")
|
||||
tokens.append("")
|
||||
index = 0
|
||||
operations = 0
|
||||
|
||||
def current():
|
||||
return tokens[index]
|
||||
|
||||
def take():
|
||||
nonlocal index
|
||||
token = current()
|
||||
if token:
|
||||
index += 1
|
||||
return token
|
||||
|
||||
def operation():
|
||||
nonlocal operations
|
||||
operations += 1
|
||||
if operations > 256:
|
||||
raise ValueError("Parameter expression exceeds 256 operations.")
|
||||
|
||||
def depth_check(depth):
|
||||
if depth > 32:
|
||||
raise ValueError("Parameter expression exceeds 32 nesting levels.")
|
||||
|
||||
def additive(depth):
|
||||
value = multiplicative(depth)
|
||||
while current() in ("+", "-"):
|
||||
op = take()
|
||||
right = multiplicative(depth)
|
||||
operation()
|
||||
value = finite(value + right if op == "+" else value - right)
|
||||
return value
|
||||
|
||||
def multiplicative(depth):
|
||||
value = unary(depth)
|
||||
while current() in ("*", "/"):
|
||||
op = take()
|
||||
right = unary(depth)
|
||||
operation()
|
||||
value = finite(value * right if op == "*" else value / right)
|
||||
return value
|
||||
|
||||
def unary(depth):
|
||||
depth_check(depth)
|
||||
if current() in ("+", "-"):
|
||||
op = take()
|
||||
operation()
|
||||
value = unary(depth + 1)
|
||||
return value if op == "+" else -value
|
||||
return power(depth)
|
||||
|
||||
def power(depth):
|
||||
depth_check(depth)
|
||||
value = primary(depth)
|
||||
if current() in ("^", "**"):
|
||||
take()
|
||||
exponent = unary(depth + 1)
|
||||
operation()
|
||||
value = finite(math.pow(value, exponent))
|
||||
return value
|
||||
|
||||
def primary(depth):
|
||||
depth_check(depth)
|
||||
token = take()
|
||||
if token == "(":
|
||||
value = additive(depth + 1)
|
||||
if take() != ")":
|
||||
raise ValueError("Missing closing parenthesis.")
|
||||
return value
|
||||
if token and (token[0].isdigit() or token[0] == "."):
|
||||
return finite(float(token))
|
||||
name = token.lower()
|
||||
if token and (token[0].isalpha() or token[0] == "_"):
|
||||
if current() != "(":
|
||||
if name in ("pi", "e"):
|
||||
return math.pi if name == "pi" else math.e
|
||||
raise ValueError(f"Unknown identifier: {token}.")
|
||||
depth_check(depth + 1)
|
||||
take()
|
||||
args = []
|
||||
if current() != ")":
|
||||
while True:
|
||||
if len(args) >= 16:
|
||||
raise ValueError("Functions accept at most 16 arguments.")
|
||||
args.append(additive(depth + 1))
|
||||
if current() != ",":
|
||||
break
|
||||
take()
|
||||
if take() != ")":
|
||||
raise ValueError("Missing function closing parenthesis.")
|
||||
operation()
|
||||
functions = {"sqrt": math.sqrt, "abs": abs, "sin": math.sin,
|
||||
"cos": math.cos, "tan": math.tan, "asin": math.asin,
|
||||
"acos": math.acos, "atan": math.atan, "exp": math.exp,
|
||||
"ln": math.log, "log": math.log, "log10": math.log10,
|
||||
"pow": math.pow}
|
||||
if name in ("min", "max") and args:
|
||||
return finite((min if name == "min" else max)(args))
|
||||
if name not in functions or len(args) != (2 if name == "pow" else 1):
|
||||
raise ValueError(f"Unsupported function or argument count: {token}.")
|
||||
return finite(functions[name](*args))
|
||||
raise ValueError("Expected a number, constant or function.")
|
||||
|
||||
try:
|
||||
result = additive(0)
|
||||
if current():
|
||||
raise ValueError("Unexpected trailing expression content.")
|
||||
return finite(result)
|
||||
except (ArithmeticError, RecursionError) as exc:
|
||||
raise ValueError("Invalid arithmetic or expression domain.") from exc
|
||||
|
||||
|
||||
def unit_conversion(definition, unit: str) -> tuple[float, float]:
|
||||
options = UNIT_TABLE.get(definition.quantity, {}) if definition.unit else {}
|
||||
if unit in options:
|
||||
scale, offset, _ = options[unit]
|
||||
return scale, offset
|
||||
if unit == definition.unit:
|
||||
return 1.0, 0.0
|
||||
raise ValueError(f"Unsupported unit '{unit}' for {definition.name} ({definition.quantity}).")
|
||||
|
||||
|
||||
def prepare_project(project):
|
||||
"""Copy external input to a current-version, numeric SI execution project.
|
||||
|
||||
Returns consolidated version notices to the caller. Does not mutate saved
|
||||
data and does not weaken the strict XML/native model-version checks.
|
||||
"""
|
||||
from app.simulation.registry import get_component_model_spec
|
||||
|
||||
normalized = project.model_copy(deep=True)
|
||||
notices = []
|
||||
specs = {}
|
||||
for node in normalized.nodes:
|
||||
model = node.data
|
||||
spec = specs.get(model.modelType)
|
||||
if spec is None:
|
||||
spec = get_component_model_spec(model.modelType)
|
||||
specs[model.modelType] = spec
|
||||
if model.componentType != spec.model_type:
|
||||
raise ValueError(f"COMPONENT_MODEL_TYPE_MISMATCH: {node.id}.")
|
||||
if model.modelVersion != spec.model_version:
|
||||
notices.append({"componentId": node.id, "label": model.label or node.id,
|
||||
"storedVersion": model.modelVersion,
|
||||
"currentVersion": spec.model_version})
|
||||
for name, value in model.parameters.items():
|
||||
definition = spec.parameter_by_name.get(name)
|
||||
if definition is None:
|
||||
raise ValueError(f"Component '{node.id}' contains unsupported parameters: {name}.")
|
||||
try:
|
||||
scale, offset = unit_conversion(definition, model.parameterUnits.get(name, definition.unit))
|
||||
number = numeric_literal(value)
|
||||
is_expression = number is None
|
||||
if is_expression:
|
||||
if not isinstance(value, str) or definition.editor:
|
||||
raise ValueError("Expected a numeric value; discrete parameters cannot use expressions.")
|
||||
number = expression_value(value)
|
||||
if project.projectSchemaVersion == 2 or is_expression:
|
||||
number = finite(number * scale + offset)
|
||||
model.parameters[name] = number
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{node.id}.{name}: {exc}") from exc
|
||||
# Explicit legacy migrations also used by the browser.
|
||||
if model.modelVersion == "0.1.0" and model.modelType == "amesim_forc":
|
||||
model.parameters.setdefault("direction", 1.0)
|
||||
if model.modelVersion == "0.1.0" and model.modelType == "amesim_lmechn1":
|
||||
count = model.parameters.get("v1")
|
||||
if count in range(1, 9):
|
||||
for edge in normalized.edges:
|
||||
if edge.source == node.id and edge.sourceHandle == "port_9":
|
||||
edge.sourceHandle = f"port_{int(count) + 1}"
|
||||
if edge.target == node.id and edge.targetHandle == "port_9":
|
||||
edge.targetHandle = f"port_{int(count) + 1}"
|
||||
model.parameters["sum"] = 1.0
|
||||
# Historical LMECHN1 exposed only nine ports; use its migrated contract.
|
||||
from app.main import ReactFlowPortDefinition
|
||||
model.ports = [ReactFlowPortDefinition(name=p.name, kind=p.kind, domain=p.domain,
|
||||
nominalRole=p.nominal_role, positiveFlowDirection=p.positive_flow_direction)
|
||||
for p in spec.ports]
|
||||
model.modelVersion = spec.model_version
|
||||
model.parameterUnits = {p.name: p.unit for p in spec.parameters}
|
||||
model.parameterScientificNotation = {}
|
||||
for name in ("t_start", "t_stop", "step", "max_step"):
|
||||
value = getattr(normalized.simulation, name)
|
||||
number = numeric_literal(value)
|
||||
if number is None:
|
||||
number = expression_value(value)
|
||||
setattr(normalized.simulation, name, number)
|
||||
normalized.projectSchemaVersion = 1 # Internal numeric SI contract, never a v2 wire payload.
|
||||
return normalized, notices
|
||||
|
||||
|
||||
def version_warning(notices):
|
||||
return {"code": "COMPONENT_MODEL_VERSION_WARNING",
|
||||
"message": "旧版或版本未知的组件将使用当前模型执行,可能仿真失败或结果与实际不符。",
|
||||
"components": notices}
|
||||
+12
-379
@@ -1,386 +1,19 @@
|
||||
# 仿真后端
|
||||
|
||||
`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
|
||||
当前采用 Python 编排、C 数值执行。输入 XML 经校验后,Python 根据模型参数和连接生成系统专用 C;GCC 编译成 EXE,EXE 内执行初始化、物性、流量、机械、RK45/CVODE BDF、事件和采样。求解循环不调用 Python。
|
||||
|
||||
目标不是逐行翻译源模型,而是建立可运行、可测试、可导出,并能与 OpenModelica 或 AMESim baseline 对比的 Python 仿真框架。
|
||||
## 目录
|
||||
|
||||
当前包含两条模型线:`Testmodel` 已有可运行的 ODE 近似和 OpenModelica 对比能力;`test_mql` 已形成 132 状态气动机械总闭包,正在按 AMESim baseline 做数值校准。
|
||||
- `components/`:模型参数、端口、显示和结果声明。
|
||||
- `core/`、`registry.py`、`systems/network.py`:模型合同与网络结构校验。
|
||||
- `native_codegen/`:C 生成、构建、进程运行和 CLI。
|
||||
- `config.py`、`sampling.py`、`results.py`:公共配置、进度、采样网格校验与结果类型。
|
||||
- `performance.py`、`warmup.py`:编排计时和启动工具链检查。
|
||||
- `reporting/amesim_results.py`:外部 Amesim 结果读取。
|
||||
- 仓库根目录 `native/`:C 组件公式和求解器。
|
||||
|
||||
## 当前目录
|
||||
旧 Python 积分器、数值组件方法和固定算例专用求解器已经退役。旧 `/api/reactflow/simulate-testmodel`、`/api/reactflow/simulate-test-mql` 返回 410;使用 `/api/system-xml/simulate` 或流式接口。
|
||||
|
||||
- `core/`: 元件基类、端口、状态、介质、方程和元数据协议。
|
||||
- `solvers/`: ODE、压力流量代数方程和 stream 求解。
|
||||
- `components/experimental/`: 用于验证元件开发规范的临时组件库。
|
||||
- `components/experimental/storage/`: 气瓶和贮箱等储能元件。
|
||||
- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。
|
||||
- `components/experimental/junctions/`: 三通等连接节点。
|
||||
- `components/amesim/`: AMESim 气动、信号和机械组件原语。
|
||||
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
|
||||
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑、基线运行入口,以及 test_mql 当前迁移过程中的系统装配和诊断脚本。
|
||||
- `examples/test_mql/`: AMESim `test_mql` 的前端调用运行入口。
|
||||
- `reporting/`: CSV、SVG、运行报告、Modelica 对比结果、AMESim 结果读取和诊断报告导出。
|
||||
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
|
||||
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
|
||||
旧 `ir/` 包、专属 schema、规范与测试已删除。当前 C 生成器直接使用经过校验的网络结构,不依赖旧 System IR v2。
|
||||
|
||||
稳定基准存放在 `tests/baselines/simulation/`,实际运行产物默认写入被 Git 忽略的
|
||||
`app/data/simulation-runs/`。新增或修改元件时,先阅读 `components/example.md`。
|
||||
需要把运行产物写到仓库外时,可以设置 `SIMULATIONAPP_DATA_DIR` 环境变量。
|
||||
|
||||
FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow
|
||||
启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。
|
||||
|
||||
临时组件库的声明入口是 `components/experimental/library.py`。公开模型必须在
|
||||
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
|
||||
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
|
||||
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
|
||||
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。
|
||||
|
||||
当前关键文件:
|
||||
|
||||
- `core/medium.py`: 温度相关的理想气体近似介质 `IdealGasMedium`
|
||||
- `core/peng_robinson.py`: `test_mql` 使用的氦气 Peng-Robinson 物性
|
||||
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
||||
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
||||
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
||||
- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
|
||||
- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
|
||||
- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
|
||||
- `examples/testmodel/test_mql.py`: `test_mql` 系统装配、132 状态总闭包和关键输出映射
|
||||
- `examples/testmodel/test_mql_closure.py`: `test_mql` 气动网络 closure、snapshot、流量计算和端口写回
|
||||
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
|
||||
- `reporting/amesim_results.py`: AMESim 结果读取入口
|
||||
- `examples/testmodel/run_test_mql_full_state_comparison.py`: `test_mql` 短时域 AMESim comparison 和诊断入口
|
||||
- `examples/testmodel/run_test_mql.py`: test_mql 基线运行与程序化执行入口
|
||||
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
|
||||
|
||||
## 当前阶段进度
|
||||
|
||||
这一阶段原先有 4 件重点工作,现在的状态如下:
|
||||
|
||||
1. `mytee1` 的 stream/焓传播语义:已完成当前阶段收紧
|
||||
现在如果只有一条支路发生倒流,下游来流焓统一按 `tank.h` 处理,不再临时借另一条支路的焓来凑。
|
||||
2. 下游初始化/约束处理:已完成当前阶段收口
|
||||
之前是“直接改对象状态再开始积分”,现在已经收成显式的 `consistent_initial_state_vector()` 初始化入口。当前这一步会在不改下游总质量、总内能的前提下,把几段直接相连的体积拉回同一个连接压力。
|
||||
3. 自动校验:已完成当前阶段首版
|
||||
已经补了标准库 `unittest` 回归测试,先把初始化投影是否守恒、是否污染原始状态,以及 4 个主变量的提交基线锁住。
|
||||
4. 更严格介质模型:已完成当前阶段首版
|
||||
已经从固定 `cp/cv` 的理想气体近似,推进到随温度变化的空气近似,并接上了内能反解和初始化求根。
|
||||
|
||||
如果只看结果,可以把这一阶段理解成:
|
||||
|
||||
- 连接器语义:首轮收紧已完成
|
||||
- 初始化入口:首轮收口已完成
|
||||
- 基线验证:首轮保护已完成
|
||||
- 介质精化:首轮近似已完成
|
||||
|
||||
## 当前阶段收口
|
||||
|
||||
上一轮 `N0-N3` 已全部完成首版,当前可以简单理解为:
|
||||
|
||||
1. `N0`:系统层里最明显的流向/焓判断已经继续下沉到组件 helper。
|
||||
2. `N1`:模型参数和运行参数已经收口到配置对象。
|
||||
3. `N2`:运行接口已经分成“准备请求”和“执行请求”两层。
|
||||
4. `N3`:结果导出和命令行报告格式化已经统一收口到 `reporting/`。
|
||||
|
||||
这一轮结束后,项目已经不缺“能不能跑”的能力,下一步更重要的是把后续开发最容易卡住的地方先处理掉。
|
||||
|
||||
## 本次推送更新
|
||||
|
||||
本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态:
|
||||
|
||||
1. `M2`:已完成当前阶段首版
|
||||
- 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `examples/testmodel/system.py` 拆到 `examples/testmodel/closure.py`
|
||||
- `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节
|
||||
|
||||
2. `M3`:已完成当前阶段首版
|
||||
- 已给两条支路入口流量固定点求解、下游公共压力投影补了显式诊断
|
||||
- 诊断内容至少包含 `converged / iterations / residual`
|
||||
- 已支持严格模式;内部求解不收敛时可以直接抛错,而不是静默返回最后一个近似值
|
||||
- `run_testmodel()` 的结构化结果和 `testmodel_run_report.txt` 已能带出最后一次内部闭合求解诊断
|
||||
|
||||
3. `M4`:已完成当前阶段首版
|
||||
- 自动测试已不再只盯最终主变量结果
|
||||
- 现在已经覆盖:
|
||||
- 改支路参数后,初始支路入口流量是否按预期变化
|
||||
- 更偏激配置下,初始化和内部闭合是否仍然收敛
|
||||
- 有无 Modelica 参考两种运行路径下,程序接口与产物行为是否一致
|
||||
|
||||
4. `M5`:已启动
|
||||
- 当前已经明确选择优先走“更容易扩展”的方向,而不是先追求更贴近 Modelica
|
||||
- 已完成第一步:把闭合器内部原来大量写死的 `upper/lower` 双支路逻辑,收成可复用的 `BranchClosureComponents / BranchClosureState` 结构
|
||||
- 当前已继续推进到 `G1-G5` 的首轮兼容层改造:`snapshot` 已提供通用分支集合,系统层结果生成已拆成“通用键生成 + 旧键别名派生”两层,报告层已开始优先消费通用分支键,旧导出列名仍通过兼容映射保留,兼容测试已显式保护分支顺序和旧导出语义
|
||||
|
||||
## 下一阶段接手建议
|
||||
|
||||
如果继续往前推进,建议按下面顺序做,而不是再零散补功能:
|
||||
|
||||
1. `G1`:已完成当前阶段首轮兼容接入
|
||||
- `TestModelSnapshot` 已新增 `branches` 集合
|
||||
- 每个分支当前至少带 `name / pipe / inlet_flow / outlet_flow / inlet_h / inlet_flow_diagnostics`
|
||||
- `pipe_upper / pipe_lower / branch_inlet_flows / branch_outlet_flows` 目前仍保留为兼容属性,供旧调用方继续使用
|
||||
|
||||
2. `G2`:已完成当前阶段首轮内部迁移
|
||||
- `evaluate_solution()` 已改成从 `snapshot.branches` 读取数据,再通过显式分支名映射写回当前旧列名
|
||||
- `rhs()` 里的分支导数计算已改成通过通用 helper 按分支循环生成,再按当前状态向量顺序拼回
|
||||
- 当前外部导出列名仍保持兼容:
|
||||
- `mypipe.p`
|
||||
- `mypipe1.p`
|
||||
- `branch_upper.in/out`
|
||||
- `branch_lower.in/out`
|
||||
|
||||
3. `G3`:已完成当前阶段首轮兼容测试
|
||||
- 当前测试已经显式保护:
|
||||
- `branches` 顺序是否稳定
|
||||
- `snapshot` 新字段和兼容字段是否一致
|
||||
- 旧导出列名是否仍映射到正确分支语义
|
||||
- 参数变化后 `upper/lower` 的名字和顺序是否不会被打乱
|
||||
|
||||
4. `G4`:已完成当前阶段首轮兼容拆层
|
||||
- `evaluate_solution()` 现在会同时产出:
|
||||
- 通用分支键:`branch.<branch_name>.p/in/out`
|
||||
- 旧兼容键:`mypipe.p`、`mypipe1.p`、`branch_upper.*`、`branch_lower.*`
|
||||
- 报告层当前已开始优先读取通用分支键,旧键只作为兼容后备
|
||||
- 当前已经把“内部统一表达”和“旧接口兼容导出”拆成两层,但还没有把所有报告/导出逻辑都迁干净
|
||||
|
||||
5. `G5`:已完成当前阶段首轮兼容收口
|
||||
- `evaluate_solution()` 当前会先生成通用分支键,再统一派生旧兼容键
|
||||
- 报告层当前已支持“通用键优先、旧键兼容后备”
|
||||
- 当前已经把系统层和 reporting 层的主要旧专名读取入口收口到少量 helper 上,后续继续迁移不会再到处散改
|
||||
|
||||
6. `P1`:下一阶段建议从这里接手
|
||||
当前更合适的下一步,不是继续深挖内核通用化,而是切回结果导向主线:
|
||||
- 定义一份稳定的外部输入参数 schema
|
||||
- 明确这些结构化参数如何映射到 `TestModelConfig / TestModelRunConfig`
|
||||
- 建立“结构化参数 -> 仿真执行 -> 结果产物/摘要”的稳定接口
|
||||
这样可以直接服务后续文档解析、网页入口和报告生成,而不是继续在 `Testmodel` 内部做边际收益越来越低的抽象整理
|
||||
|
||||
7. `P2`:在 `P1` 完成后,再推进文档解析或报告生成链路
|
||||
更现实的顺序应是:
|
||||
- 先把结构化输入跑通
|
||||
- 再把结果摘要/产物组织成更接近最终产品的输出包
|
||||
- 最后再接 Word 解析或页面入口
|
||||
|
||||
如果后续继续推进,这个 README 也要一起更新,不要长期保留已经失效的路线描述。
|
||||
|
||||
## 当前实现了什么
|
||||
|
||||
当前代码已经实现:
|
||||
|
||||
1. `m`、`U` 作为动态元件主状态,`p`、`T`、`rho`、`u`、`h` 作为派生量。
|
||||
2. `Cylinder`、`Tank`、`Pipe` 的刚性绝热容腔近似。
|
||||
3. `Orifice` 的压差开方流量关系。
|
||||
4. `Tee` 的简化混合焓处理。
|
||||
5. `Testmodel` 的系统级拓扑映射和一版可运行的 `rhs(t, x)`。
|
||||
6. 基于 `solve_ivp` 的积分入口,以及 SciPy 不可用时的 RK4 回退。
|
||||
7. 温度相关空气近似介质,包括 `cp(T)`、`h(T)`、`u(T)` 以及 `u -> T` 反解。
|
||||
8. 显式一致初值入口 `consistent_initial_state_vector()`,以及可迭代初始化器 `initialize_consistent_state()`。
|
||||
9. Python 主变量结果导出:
|
||||
`mytank.p`、`mytank.T`、`mycylinder.p`、`mycylinder.T`
|
||||
10. 贮箱温度曲线导出:
|
||||
`testmodel_tank_temperature.csv`
|
||||
`testmodel_tank_temperature.svg`
|
||||
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
|
||||
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
|
||||
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
|
||||
|
||||
当前没有实现:
|
||||
|
||||
- 通用 DAE 初始化器
|
||||
- `Modelica.Media.Air.SimpleAir` 的严格复刻
|
||||
- 一般高指数 DAE、事件和严格 Modelica `inStream/actualStream` 求解器
|
||||
|
||||
## 当前怎么运行
|
||||
|
||||
最小运行方式:
|
||||
|
||||
```bash
|
||||
python -m app.simulation.examples.testmodel.run
|
||||
```
|
||||
|
||||
如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如:
|
||||
|
||||
```python
|
||||
from app.simulation.examples.testmodel.run import (
|
||||
TestModelRunConfig,
|
||||
TestModelSamplingConfig,
|
||||
run_testmodel,
|
||||
)
|
||||
from app.simulation.examples.testmodel.system import (
|
||||
BranchConfig,
|
||||
CylinderConfig,
|
||||
OrificeConfig,
|
||||
PipeConfig,
|
||||
TankConfig,
|
||||
TestModelConfig,
|
||||
)
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
|
||||
run_config = TestModelRunConfig(
|
||||
model=TestModelConfig(
|
||||
cylinder=CylinderConfig(p0=30e6),
|
||||
upper_branch=BranchConfig(
|
||||
orifice=OrificeConfig(K=8e-6),
|
||||
pipe=PipeConfig(length=6.0, diameter=0.03),
|
||||
),
|
||||
tank=TankConfig(volume=0.12),
|
||||
),
|
||||
solver=SolveIVPConfig(t_start=0.0, t_stop=10.0, method="BDF"),
|
||||
sampling=TestModelSamplingConfig(step=0.05),
|
||||
)
|
||||
|
||||
result = run_testmodel(run_config=run_config)
|
||||
```
|
||||
|
||||
如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行:
|
||||
|
||||
```python
|
||||
from app.simulation.examples.testmodel.run import (
|
||||
prepare_testmodel_run,
|
||||
run_prepared_testmodel,
|
||||
TestModelRunConfig,
|
||||
)
|
||||
|
||||
prepared = prepare_testmodel_run(run_config=TestModelRunConfig())
|
||||
print(prepared.output_dir)
|
||||
print(prepared.t_eval)
|
||||
|
||||
result = run_prepared_testmodel(prepared)
|
||||
print(result.artifacts.primary_csv_path)
|
||||
print(result.used_modelica_reference)
|
||||
```
|
||||
|
||||
当前脚本会:
|
||||
|
||||
1. 构建 `TestModelSystem`
|
||||
2. 打印原始初值向量与约束一致后的初值向量
|
||||
3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s`
|
||||
4. 将结果写入 `app/data/simulation-runs/` 下本次运行专属的时间戳目录
|
||||
5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果
|
||||
|
||||
当前脚本默认不会把运行结果直接写到提交基线目录,而是会在
|
||||
`app/data/simulation-runs/` 下创建一个带时间戳的子目录,例如:
|
||||
|
||||
- `app/data/simulation-runs/testmodel_20260512_103000_123456/`
|
||||
|
||||
该目录里通常会包含:
|
||||
|
||||
- `testmodel_primary_series.csv`
|
||||
- `testmodel_tank_temperature.csv`
|
||||
- `testmodel_tank_temperature.svg`
|
||||
- `testmodel_run_report.txt`
|
||||
- `testmodel_modelica_comparison.csv`
|
||||
- `testmodel_modelica_comparison_summary.txt`
|
||||
|
||||
## 基线结果
|
||||
|
||||
当前基线对比摘要来自:
|
||||
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt)
|
||||
|
||||
当前四个主变量的最大误差为:
|
||||
|
||||
- `mytank.p`: `max_abs_error = 134.960857 Pa`, `max_rel_error = 0.006798%`
|
||||
- `mytank.T`: `max_abs_error = 0.035507 K`, `max_rel_error = 0.009016%`
|
||||
- `mycylinder.p`: `max_abs_error = 1391.986349 Pa`, `max_rel_error = 0.009447%`
|
||||
- `mycylinder.T`: `max_abs_error = 0.009069 K`, `max_rel_error = 0.003870%`
|
||||
|
||||
这说明在当前基线工况下,Python 版主变量已经能较好贴近 OpenModelica 结果。
|
||||
|
||||
## 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` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
|
||||
- 通用 XML 求解链路已经支持按实际流向传播和三通混合 stream 焓,但仍是正则化 MVP,不是严格的 Modelica `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]` 状态打包。
|
||||
- `systems/network.py`: 正常
|
||||
负责状态向量拼装和连接摘要,不参与物理求解。
|
||||
- `solvers/solver.py`: 正常
|
||||
已支持 SciPy、RK4 回退和零时长仿真。
|
||||
- `components/experimental/**/*.py`: 正常
|
||||
都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。
|
||||
- `examples/testmodel/system.py`: 是当前最重要的技术债集中区
|
||||
这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。
|
||||
- `examples/testmodel/run.py`: 正常
|
||||
已不是“最小打印脚本”,而是当前结果导出和对比入口。
|
||||
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
|
||||
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
|
||||
|
||||
## Testmodel 当前主技术债
|
||||
|
||||
目前最主要的技术债,可以直接理解成下面 4 件事:
|
||||
|
||||
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
|
||||
2. `examples/testmodel/system.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
|
||||
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
|
||||
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
|
||||
运行、支持范围与依赖见 [C 后端说明](../../native/README.md)。模型开发规则见 [组件规范](../../docs/standard/component-model-authoring-spec-v1.md)。历史性能、Amesim 差异和旧公式说明位于 `docs/other/` 及 Git 历史,不能作为当前运行入口。
|
||||
@@ -0,0 +1,38 @@
|
||||
"""One execution boundary shared by XML API and native validation tools."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from app.simulation.config import SolveIVPConfig
|
||||
|
||||
|
||||
DEFAULT_NUMERIC_ENGINE = "native"
|
||||
|
||||
|
||||
def numeric_engine_name(backend: str | None = None) -> str:
|
||||
configured = backend or os.environ.get("SIMULATION_NUMERIC_ENGINE", "")
|
||||
selected = configured.strip().lower() or DEFAULT_NUMERIC_ENGINE
|
||||
if selected == "native-c":
|
||||
return "native"
|
||||
if selected == "native":
|
||||
return selected
|
||||
if selected == "python":
|
||||
raise ValueError("The Python numerical backend has been removed. Use SIMULATION_NUMERIC_ENGINE=native.")
|
||||
raise ValueError(f"Unknown simulation engine: {selected}.")
|
||||
|
||||
|
||||
def simulation_config(simulation) -> SolveIVPConfig:
|
||||
# Match the validated native pipe/chamber accuracy. Per-state SI absolute
|
||||
# floors are emitted by native_codegen.tolerances; XML tolerance fields
|
||||
# remain a separate protocol change.
|
||||
return SolveIVPConfig(t_start=simulation.t_start, t_stop=simulation.t_stop,
|
||||
method=simulation.method, rtol=1e-8, max_step=simulation.max_step)
|
||||
|
||||
|
||||
def simulate_network(network, simulation, *, progress_callback=None,
|
||||
warning_callback=None, cancel_check=None, activity_tracker=None, backend=None, raw_series=False):
|
||||
numeric_engine_name(backend)
|
||||
config = simulation_config(simulation)
|
||||
from app.simulation.native_codegen.runner import simulate_native
|
||||
return simulate_native(network, config, sample_step=simulation.sample_step, progress_callback=progress_callback,
|
||||
warning_callback=warning_callback, cancel_check=cancel_check, activity_tracker=activity_tracker, raw_series=raw_series)
|
||||
@@ -1,13 +1,11 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
|
||||
from app.simulation.core.port_computation import ZERO_FLOW_SUPPLY
|
||||
|
||||
class AmesimPnpl01(AlgebraicComponent):
|
||||
"""AMESim PNPL01 zero pneumatic flow source.
|
||||
@@ -16,49 +14,19 @@ class AmesimPnpl01(AlgebraicComponent):
|
||||
solver: it does not prescribe pressure, and only constrains its port mass
|
||||
flow to zero.
|
||||
"""
|
||||
|
||||
MODEL_TYPE = "amesim_pnpl01"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
|
||||
MODEL_TYPE = 'amesim_pnpl01'
|
||||
MODEL_VERSION = '0.1.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', computation=ZERO_FLOW_SUPPLY),)
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="PNPL01 零气动流边界",
|
||||
library_id="amesim",
|
||||
category_id="boundary",
|
||||
symbol="generic",
|
||||
ports=(PortDisplaySpec("port_1", "left", order=10),),
|
||||
order=10,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='PNPL01 零气动流边界', library_id='amesim', category_id='boundary', symbol='amesim_pnpl01', ports=(PortDisplaySpec('port_1', 'left', order=10),), order=10)
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({})
|
||||
self.port_1 = self.register_declared_port("port_1")
|
||||
self.port_1 = self.register_declared_port('port_1')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimPnpl01:
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimPnpl01:
|
||||
return cls(name=name)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:zero_mass_flow",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(f"{self.name}.port_1.m_flow",),
|
||||
role="flow",
|
||||
value=self.port_1.m_flow,
|
||||
),
|
||||
)
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
if "port_1" in connected_h:
|
||||
self.port_1.h_outflow = connected_h["port_1"]
|
||||
EQUATIONS = ({'id': '__MODEL__:zero_mass_flow', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.m_flow'], 'role': 'flow'},)
|
||||
@@ -1,181 +1,59 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from math import isclose, sqrt
|
||||
|
||||
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 IdealGasMedium
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition
|
||||
from app.simulation.core.medium import GasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
|
||||
from app.simulation.core.port_computation import FLOW_SUPPLY
|
||||
_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,)),)
|
||||
_PNOR001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(id='flow_coefficient', label='流量系数', parameters=('cq', 'area', 'Cv', 'Kv'), order=10)
|
||||
_PNVO001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(id='flow_coefficient', label='流量系数', parameters=('cq', 'area0', 'Cv', 'Kv'), order=10)
|
||||
|
||||
class AmesimPnor001(AlgebraicComponent):
|
||||
"""AMESim PNOR001 constant-flow-coefficient pneumatic orifice.
|
||||
|
||||
This public component preserves the PNOR001 catalog/XML contract and uses a
|
||||
finite bidirectional compressible-orifice approximation. The Siemens
|
||||
`pn2rcqfix_` details remain a later calibration target.
|
||||
This public component preserves the PNOR001 catalog/XML contract and uses
|
||||
real-gas pressure-ratio flow with AMESim-style near-equal-pressure smoothing.
|
||||
"""
|
||||
MODEL_TYPE = 'amesim_pnor001'
|
||||
MODEL_VERSION = '0.3.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
|
||||
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area', 5e-06, label='孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='选择 Cq/面积方式时用于流量计算的有效孔口面积。', 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。'))
|
||||
RESULT_VARIABLES = (ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=20))
|
||||
DISPLAY = ComponentDisplaySpec(label='PNOR001 常系数气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnor001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10, parameter_groups=(_PNOR001_FLOW_COEFFICIENT_GROUP,))
|
||||
|
||||
MODEL_TYPE = "amesim_pnor001"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"cq",
|
||||
0.72,
|
||||
label="流量系数 Cq",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0e-10,
|
||||
maximum=1.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"area",
|
||||
5.0e-6,
|
||||
label="孔口面积",
|
||||
quantity="area",
|
||||
unit="m2",
|
||||
minimum=0.0,
|
||||
maximum=1.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"Cv",
|
||||
0.5,
|
||||
label="流量系数 Cv",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"Kv",
|
||||
0.4,
|
||||
label="流量系数 Kv",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="气体类型索引",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"flowset",
|
||||
1.0,
|
||||
label="流量系数设置",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=3.0,
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = (
|
||||
ResultVariableDefinition(
|
||||
"cm",
|
||||
label="质量流量参数",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
category="derived",
|
||||
order=10,
|
||||
),
|
||||
ResultVariableDefinition(
|
||||
"gasvel",
|
||||
label="缩流截面气体速度",
|
||||
quantity="velocity",
|
||||
unit="m/s",
|
||||
category="derived",
|
||||
order=20,
|
||||
),
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="PNOR001 常系数气动孔口",
|
||||
library_id="amesim",
|
||||
category_id="flow",
|
||||
symbol="orifice",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
),
|
||||
order=10,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
*,
|
||||
cq: float = 0.72,
|
||||
area: float = 5.0e-6,
|
||||
Cv: float = 0.5,
|
||||
Kv: float = 0.4,
|
||||
gi: float = 1.0,
|
||||
flowset: float = 1.0,
|
||||
) -> None:
|
||||
def __init__(self, name: str, medium: GasMedium, *, cq: float=0.72, area: float=5e-06, Cv: float=0.5, Kv: float=0.4, gi: float=1.0, flowset: float=1.0) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values(
|
||||
{
|
||||
"cq": cq,
|
||||
"area": area,
|
||||
"Cv": Cv,
|
||||
"Kv": Kv,
|
||||
"gi": gi,
|
||||
"flowset": flowset,
|
||||
}
|
||||
)
|
||||
self.set_parameter_values({'cq': cq, 'area': area, 'Cv': Cv, 'Kv': Kv, 'gi': gi, 'flowset': flowset})
|
||||
self.medium = medium
|
||||
self.cq = float(cq)
|
||||
self.area = float(area)
|
||||
self.Cv = float(Cv)
|
||||
self.Kv = float(Kv)
|
||||
self.gi = self._integer_parameter("gi", gi)
|
||||
self.flowset = self._integer_parameter("flowset", flowset)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.flowset = self._integer_parameter('flowset', flowset)
|
||||
if self.flowset not in {1, 2, 3}:
|
||||
raise ValueError("PNOR001 flowset must be 1, 2, or 3.")
|
||||
|
||||
initial_h = medium.specific_enthalpy(medium.T_ref)
|
||||
self.port_1 = self.register_declared_port("port_1")
|
||||
self.port_1.h_outflow = initial_h
|
||||
self.port_2 = self.register_declared_port("port_2")
|
||||
self.port_2.h_outflow = initial_h
|
||||
raise ValueError('PNOR001 flowset must be 1, 2, or 3.')
|
||||
self.port_1 = self.register_declared_port('port_1')
|
||||
self.port_2 = self.register_declared_port('port_2')
|
||||
|
||||
@staticmethod
|
||||
def _integer_parameter(name: str, value: float) -> int:
|
||||
rounded = round(value)
|
||||
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12):
|
||||
raise ValueError(f"PNOR001 parameter {name} must be an integer value.")
|
||||
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1e-12):
|
||||
raise ValueError(f'PNOR001 parameter {name} must be an integer value.')
|
||||
return int(rounded)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimPnor001:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
cq=parameters["cq"],
|
||||
area=parameters["area"],
|
||||
Cv=parameters["Cv"],
|
||||
Kv=parameters["Kv"],
|
||||
gi=parameters["gi"],
|
||||
flowset=parameters["flowset"],
|
||||
)
|
||||
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnor001:
|
||||
return cls(name=name, medium=medium, cq=parameters['cq'], area=parameters['area'], Cv=parameters['Cv'], Kv=parameters['Kv'], gi=parameters['gi'], flowset=parameters['flowset'])
|
||||
|
||||
@property
|
||||
def effective_cq(self) -> float:
|
||||
@@ -192,7 +70,7 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
@staticmethod
|
||||
def _area_from_cv(Cv: float, cq: float) -> float:
|
||||
water_density = 999.0
|
||||
reference_flow_m3_s = Cv * 6.30901964e-5
|
||||
reference_flow_m3_s = Cv * 6.30901964e-05
|
||||
reference_dp_pa = 6894.75729
|
||||
return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density))
|
||||
|
||||
@@ -202,293 +80,48 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
reference_flow_m3_s = Kv / 3600.0
|
||||
reference_dp_pa = 100000.0
|
||||
return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density))
|
||||
|
||||
def _upstream_temperature(self, port_name: str) -> float:
|
||||
port = self.get_port(port_name)
|
||||
if port.h_outflow > 0.0:
|
||||
return max(port.h_outflow / self.medium.cp_ref, 1.0)
|
||||
return self.medium.T_ref
|
||||
|
||||
def mass_flow(self, p_1: float, p_2: float) -> float:
|
||||
if p_1 == p_2 or self.effective_area == 0.0:
|
||||
return 0.0
|
||||
if p_1 > p_2:
|
||||
return self._one_way_mass_flow(
|
||||
upstream_pressure=p_1,
|
||||
downstream_pressure=p_2,
|
||||
upstream_temperature=self._upstream_temperature("port_1"),
|
||||
)
|
||||
return -self._one_way_mass_flow(
|
||||
upstream_pressure=p_2,
|
||||
downstream_pressure=p_1,
|
||||
upstream_temperature=self._upstream_temperature("port_2"),
|
||||
)
|
||||
|
||||
def _one_way_mass_flow(
|
||||
self,
|
||||
*,
|
||||
upstream_pressure: float,
|
||||
downstream_pressure: float,
|
||||
upstream_temperature: float,
|
||||
) -> float:
|
||||
p_up = max(upstream_pressure, 1.0)
|
||||
p_down = max(min(downstream_pressure, p_up), 0.0)
|
||||
T_up = max(upstream_temperature, 1.0)
|
||||
gamma = max(self.medium.gamma, 1.000001)
|
||||
pressure_ratio = max(p_down / p_up, 0.0)
|
||||
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
|
||||
if pressure_ratio <= critical_ratio:
|
||||
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * (
|
||||
2.0 / (gamma + 1.0)
|
||||
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
|
||||
else:
|
||||
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** (
|
||||
(gamma + 1.0) / gamma
|
||||
)
|
||||
flow_factor = sqrt(
|
||||
max(
|
||||
2.0
|
||||
* gamma
|
||||
* expansion
|
||||
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
|
||||
0.0,
|
||||
)
|
||||
)
|
||||
return self.effective_cq * self.effective_area * p_up * flow_factor
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
p_1 = max(self.port_1.p, 1.0)
|
||||
p_2 = max(self.port_2.p, 1.0)
|
||||
m_flow = abs(self.mass_flow(self.port_1.p, self.port_2.p))
|
||||
upstream_pressure = max(p_1, p_2)
|
||||
upstream_temperature = self._upstream_temperature(
|
||||
"port_1" if p_1 >= p_2 else "port_2"
|
||||
)
|
||||
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
|
||||
area = max(self.effective_area, 1.0e-18)
|
||||
return {
|
||||
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
|
||||
"gasvel": m_flow / (density * area),
|
||||
}
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:mass_flow_balance",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="sumToZero",
|
||||
variables=(
|
||||
f"{self.name}.port_1.m_flow",
|
||||
f"{self.name}.port_2.m_flow",
|
||||
),
|
||||
role="flow",
|
||||
value=self.port_1.m_flow + self.port_2.m_flow,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:pressure_flow_relation",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(
|
||||
f"{self.name}.port_1.p",
|
||||
f"{self.name}.port_2.p",
|
||||
f"{self.name}.port_1.m_flow",
|
||||
),
|
||||
role="flow",
|
||||
value=self.port_1.m_flow
|
||||
- self.mass_flow(self.port_1.p, self.port_2.p),
|
||||
),
|
||||
)
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
self.port_1.h_outflow = connected_h["port_2"]
|
||||
self.port_2.h_outflow = connected_h["port_1"]
|
||||
|
||||
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p', '__MODEL__.port_1.m_flow'], 'role': 'flow'})
|
||||
|
||||
class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
"""Fixed-opening public variant of AMESim PNVO001.
|
||||
|
||||
Full PNVO001 has a signal input port. The current public component library
|
||||
does not support signal simulation, so this model exposes the pneumatic
|
||||
ports and replaces the signal with a normalized `opening` parameter.
|
||||
Full PNVO001 has a signal input port. This optional variant exposes the
|
||||
pneumatic ports and replaces that signal with a normalized `opening`
|
||||
parameter; use AmesimPnvo001SignalOpening for a time-varying control signal.
|
||||
"""
|
||||
MODEL_TYPE = 'amesim_pnvo001_fixed'
|
||||
MODEL_VERSION = '0.2.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_3', computation=FLOW_SUPPLY))
|
||||
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, 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('opening', 1.0, label='固定开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='固定的归一化阀门开度;0 表示关闭,1 表示完全开启。'))
|
||||
RESULT_VARIABLES = (ResultVariableDefinition('xv', label='有效开度', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=30))
|
||||
DISPLAY = ComponentDisplaySpec(label='PNVO001 固定开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001_fixed', ports=(PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=30, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,))
|
||||
|
||||
MODEL_TYPE = "amesim_pnvo001_fixed"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"cq",
|
||||
0.72,
|
||||
label="流量系数 Cq",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0e-10,
|
||||
maximum=1.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"area0",
|
||||
5.0e-6,
|
||||
label="最大孔口面积",
|
||||
quantity="area",
|
||||
unit="m2",
|
||||
minimum=0.0,
|
||||
maximum=1.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"Cv",
|
||||
0.5,
|
||||
label="最大流量系数 Cv",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"Kv",
|
||||
0.4,
|
||||
label="最大流量系数 Kv",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="气体类型索引",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"flowset",
|
||||
1.0,
|
||||
label="流量系数设置",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=3.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"opening",
|
||||
1.0,
|
||||
label="固定开度",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=0.0,
|
||||
maximum=1.0,
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = (
|
||||
ResultVariableDefinition(
|
||||
"xv",
|
||||
label="有效开度",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
category="derived",
|
||||
order=10,
|
||||
),
|
||||
ResultVariableDefinition(
|
||||
"cm",
|
||||
label="质量流量参数",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
category="derived",
|
||||
order=20,
|
||||
),
|
||||
ResultVariableDefinition(
|
||||
"gasvel",
|
||||
label="缩流截面气体速度",
|
||||
quantity="velocity",
|
||||
unit="m/s",
|
||||
category="derived",
|
||||
order=30,
|
||||
),
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="PNVO001 固定开度气动孔口",
|
||||
library_id="amesim",
|
||||
category_id="flow",
|
||||
symbol="orifice",
|
||||
ports=(
|
||||
PortDisplaySpec("port_2", "left", order=10),
|
||||
PortDisplaySpec("port_3", "right", order=20),
|
||||
),
|
||||
order=30,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
*,
|
||||
cq: float = 0.72,
|
||||
area0: float = 5.0e-6,
|
||||
Cv: float = 0.5,
|
||||
Kv: float = 0.4,
|
||||
gi: float = 1.0,
|
||||
flowset: float = 1.0,
|
||||
opening: float = 1.0,
|
||||
) -> None:
|
||||
def __init__(self, name: str, medium: GasMedium, *, cq: float=0.72, area0: float=5e-06, Cv: float=0.5, Kv: float=0.4, gi: float=1.0, flowset: float=1.0, opening: float=1.0) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values(
|
||||
{
|
||||
"cq": cq,
|
||||
"area0": area0,
|
||||
"Cv": Cv,
|
||||
"Kv": Kv,
|
||||
"gi": gi,
|
||||
"flowset": flowset,
|
||||
"opening": opening,
|
||||
}
|
||||
)
|
||||
self.set_parameter_values({'cq': cq, 'area0': area0, 'Cv': Cv, 'Kv': Kv, 'gi': gi, 'flowset': flowset, 'opening': opening})
|
||||
self.medium = medium
|
||||
self.cq = float(cq)
|
||||
self.area0 = float(area0)
|
||||
self.Cv = float(Cv)
|
||||
self.Kv = float(Kv)
|
||||
self.gi = self._integer_parameter("gi", gi)
|
||||
self.flowset = self._integer_parameter("flowset", flowset)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.flowset = self._integer_parameter('flowset', flowset)
|
||||
if self.flowset not in {1, 2, 3}:
|
||||
raise ValueError("PNVO001 fixed-opening flowset must be 1, 2, or 3.")
|
||||
raise ValueError('PNVO001 fixed-opening flowset must be 1, 2, or 3.')
|
||||
self.opening = min(1.0, max(0.0, float(opening)))
|
||||
|
||||
initial_h = medium.specific_enthalpy(medium.T_ref)
|
||||
self.port_2 = self.register_declared_port("port_2")
|
||||
self.port_2.h_outflow = initial_h
|
||||
self.port_3 = self.register_declared_port("port_3")
|
||||
self.port_3.h_outflow = initial_h
|
||||
self.port_2 = self.register_declared_port('port_2')
|
||||
self.port_3 = self.register_declared_port('port_3')
|
||||
|
||||
@staticmethod
|
||||
def _integer_parameter(name: str, value: float) -> int:
|
||||
rounded = round(value)
|
||||
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12):
|
||||
raise ValueError(f"PNVO001 fixed-opening parameter {name} must be an integer value.")
|
||||
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1e-12):
|
||||
raise ValueError(f'PNVO001 fixed-opening parameter {name} must be an integer value.')
|
||||
return int(rounded)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimPnvo001FixedOpening:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
cq=parameters["cq"],
|
||||
area0=parameters["area0"],
|
||||
Cv=parameters["Cv"],
|
||||
Kv=parameters["Kv"],
|
||||
gi=parameters["gi"],
|
||||
flowset=parameters["flowset"],
|
||||
opening=parameters["opening"],
|
||||
)
|
||||
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnvo001FixedOpening:
|
||||
return cls(name=name, medium=medium, cq=parameters['cq'], area0=parameters['area0'], Cv=parameters['Cv'], Kv=parameters['Kv'], gi=parameters['gi'], flowset=parameters['flowset'], opening=parameters['opening'])
|
||||
|
||||
@property
|
||||
def effective_cq(self) -> float:
|
||||
@@ -505,197 +138,35 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
@property
|
||||
def effective_area(self) -> float:
|
||||
return self.opening * self.maximum_area
|
||||
|
||||
def _upstream_temperature(self, port_name: str) -> float:
|
||||
port = self.get_port(port_name)
|
||||
if port.h_outflow > 0.0:
|
||||
return max(port.h_outflow / self.medium.cp_ref, 1.0)
|
||||
return self.medium.T_ref
|
||||
|
||||
def mass_flow(self, p_2: float, p_3: float) -> float:
|
||||
if p_2 == p_3 or self.effective_area == 0.0:
|
||||
return 0.0
|
||||
if p_2 > p_3:
|
||||
return self._one_way_mass_flow(
|
||||
upstream_pressure=p_2,
|
||||
downstream_pressure=p_3,
|
||||
upstream_temperature=self._upstream_temperature("port_2"),
|
||||
)
|
||||
return -self._one_way_mass_flow(
|
||||
upstream_pressure=p_3,
|
||||
downstream_pressure=p_2,
|
||||
upstream_temperature=self._upstream_temperature("port_3"),
|
||||
)
|
||||
|
||||
def _one_way_mass_flow(
|
||||
self,
|
||||
*,
|
||||
upstream_pressure: float,
|
||||
downstream_pressure: float,
|
||||
upstream_temperature: float,
|
||||
) -> float:
|
||||
p_up = max(upstream_pressure, 1.0)
|
||||
p_down = max(min(downstream_pressure, p_up), 0.0)
|
||||
T_up = max(upstream_temperature, 1.0)
|
||||
gamma = max(self.medium.gamma, 1.000001)
|
||||
pressure_ratio = max(p_down / p_up, 0.0)
|
||||
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
|
||||
if pressure_ratio <= critical_ratio:
|
||||
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * (
|
||||
2.0 / (gamma + 1.0)
|
||||
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
|
||||
else:
|
||||
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** (
|
||||
(gamma + 1.0) / gamma
|
||||
)
|
||||
flow_factor = sqrt(
|
||||
max(
|
||||
2.0
|
||||
* gamma
|
||||
* expansion
|
||||
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
|
||||
0.0,
|
||||
)
|
||||
)
|
||||
return self.effective_cq * self.effective_area * p_up * flow_factor
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
p_2 = max(self.port_2.p, 1.0)
|
||||
p_3 = max(self.port_3.p, 1.0)
|
||||
m_flow = abs(self.mass_flow(self.port_2.p, self.port_3.p))
|
||||
upstream_pressure = max(p_2, p_3)
|
||||
upstream_temperature = self._upstream_temperature(
|
||||
"port_2" if p_2 >= p_3 else "port_3"
|
||||
)
|
||||
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
|
||||
area = max(self.effective_area, 1.0e-18)
|
||||
return {
|
||||
"xv": self.opening,
|
||||
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
|
||||
"gasvel": m_flow / (density * area),
|
||||
}
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:mass_flow_balance",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="sumToZero",
|
||||
variables=(
|
||||
f"{self.name}.port_2.m_flow",
|
||||
f"{self.name}.port_3.m_flow",
|
||||
),
|
||||
role="flow",
|
||||
value=self.port_2.m_flow + self.port_3.m_flow,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:pressure_flow_relation",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(
|
||||
f"{self.name}.port_2.p",
|
||||
f"{self.name}.port_3.p",
|
||||
f"{self.name}.port_2.m_flow",
|
||||
),
|
||||
role="flow",
|
||||
value=self.port_2.m_flow
|
||||
- self.mass_flow(self.port_2.p, self.port_3.p),
|
||||
),
|
||||
)
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
self.port_2.h_outflow = connected_h["port_3"]
|
||||
self.port_3.h_outflow = connected_h["port_2"]
|
||||
|
||||
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.p', '__MODEL__.port_3.p', '__MODEL__.port_2.m_flow'], 'role': 'flow'})
|
||||
|
||||
class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
||||
"""AMESim PNVO001 signal-controlled pneumatic orifice."""
|
||||
|
||||
MODEL_TYPE = "amesim_pnvo001"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.signal("res", nominal_role="input"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition("cq", 0.72, label="流量系数 Cq", quantity="dimensionless", unit="", minimum=1.0e-10, maximum=1.0),
|
||||
ParameterDefinition("area0", 5.0e-6, label="最大孔口面积", quantity="area", unit="m2", minimum=0.0, maximum=1.0),
|
||||
ParameterDefinition("Cv", 0.5, label="最大流量系数 Cv", quantity="dimensionless", unit="", minimum=0.0),
|
||||
ParameterDefinition("Kv", 0.4, label="最大流量系数 Kv", quantity="dimensionless", unit="", minimum=0.0),
|
||||
ParameterDefinition("gi", 1.0, label="气体类型索引", quantity="dimensionless", unit="", minimum=1.0, maximum=99.0),
|
||||
ParameterDefinition("flowset", 1.0, label="流量系数设置", quantity="dimensionless", unit="", minimum=1.0, maximum=3.0),
|
||||
ParameterDefinition("opening0", 1.0, label="初始开度", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
|
||||
)
|
||||
MODEL_TYPE = 'amesim_pnvo001'
|
||||
MODEL_VERSION = '0.2.0'
|
||||
PORTS = (PortDefinition.signal('res', nominal_role='input'), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_3', computation=FLOW_SUPPLY))
|
||||
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening0', 1.0, label='初始开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='信号尚未传播时使用的归一化初始开度;0 表示关闭,1 表示完全开启。'))
|
||||
RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="PNVO001 信号开度气动孔口",
|
||||
library_id="amesim",
|
||||
category_id="flow",
|
||||
symbol="orifice",
|
||||
ports=(
|
||||
PortDisplaySpec("res", "left", order=5),
|
||||
PortDisplaySpec("port_2", "left", order=10),
|
||||
PortDisplaySpec("port_3", "right", order=20),
|
||||
),
|
||||
order=35,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='PNVO001 信号开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001', ports=(PortDisplaySpec('res', 'left', order=5), PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=35, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
*,
|
||||
cq: float = 0.72,
|
||||
area0: float = 5.0e-6,
|
||||
Cv: float = 0.5,
|
||||
Kv: float = 0.4,
|
||||
gi: float = 1.0,
|
||||
flowset: float = 1.0,
|
||||
opening0: float = 1.0,
|
||||
) -> None:
|
||||
def __init__(self, name: str, medium: GasMedium, *, cq: float=0.72, area0: float=5e-06, Cv: float=0.5, Kv: float=0.4, gi: float=1.0, flowset: float=1.0, opening0: float=1.0) -> None:
|
||||
AlgebraicComponent.__init__(self, name=name)
|
||||
self.set_parameter_values(
|
||||
{
|
||||
"cq": cq,
|
||||
"area0": area0,
|
||||
"Cv": Cv,
|
||||
"Kv": Kv,
|
||||
"gi": gi,
|
||||
"flowset": flowset,
|
||||
"opening0": opening0,
|
||||
}
|
||||
)
|
||||
self.set_parameter_values({'cq': cq, 'area0': area0, 'Cv': Cv, 'Kv': Kv, 'gi': gi, 'flowset': flowset, 'opening0': opening0})
|
||||
self.medium = medium
|
||||
self.cq = float(cq)
|
||||
self.area0 = float(area0)
|
||||
self.Cv = float(Cv)
|
||||
self.Kv = float(Kv)
|
||||
self.gi = self._integer_parameter("gi", gi)
|
||||
self.flowset = self._integer_parameter("flowset", flowset)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.flowset = self._integer_parameter('flowset', flowset)
|
||||
if self.flowset not in {1, 2, 3}:
|
||||
raise ValueError("PNVO001 signal-opening flowset must be 1, 2, or 3.")
|
||||
raise ValueError('PNVO001 signal-opening flowset must be 1, 2, or 3.')
|
||||
self.opening0 = min(1.0, max(0.0, float(opening0)))
|
||||
self.res = self.register_declared_port("res")
|
||||
self.res.signal = self.opening0
|
||||
initial_h = medium.specific_enthalpy(medium.T_ref)
|
||||
self.port_2 = self.register_declared_port("port_2")
|
||||
self.port_2.h_outflow = initial_h
|
||||
self.port_3 = self.register_declared_port("port_3")
|
||||
self.port_3.h_outflow = initial_h
|
||||
self.res = self.register_declared_port('res')
|
||||
self.port_2 = self.register_declared_port('port_2')
|
||||
self.port_3 = self.register_declared_port('port_3')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimPnvo001SignalOpening":
|
||||
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnvo001SignalOpening':
|
||||
return cls(name=name, medium=medium, **dict(parameters))
|
||||
|
||||
@property
|
||||
def opening(self) -> float:
|
||||
return min(1.0, max(0.0, self.res.signal))
|
||||
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.p', '__MODEL__.port_3.p', '__MODEL__.port_2.m_flow'], 'role': 'flow'})
|
||||
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()
|
||||
@@ -1,149 +1,55 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition, PortState
|
||||
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.port_computation import NODE_BRANCH, NODE_REFERENCE
|
||||
|
||||
class _AmesimPneumaticNode(AlgebraicComponent):
|
||||
"""Shared implementation for AMESim pneumatic junction submodels."""
|
||||
"""Shared implementation for AMESim pneumatic junction submodels.
|
||||
|
||||
REFERENCE_PORT = "port_2"
|
||||
PN3NODE2/P4NODE2 use port 2 as their pressure and temperature reference.
|
||||
Non-reference outlet ports use that reference temperature. Signed branch
|
||||
enthalpy flows are summed and delivered to port 2 independently of its net
|
||||
mass flow, matching AMESim dh2 causality even at zero net mass flow.
|
||||
The finite h_outflow diagnostic cannot encode that zero-flow energy.
|
||||
Branch volumes and volume rates are also summed towards port 2.
|
||||
"""
|
||||
REFERENCE_PORT = 'port_2'
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({})
|
||||
self.temperature_reference_h = 0.0
|
||||
for definition in self.PORTS:
|
||||
setattr(self, definition.name, self.register_declared_port(definition.name))
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
reference = self.get_port(self.REFERENCE_PORT)
|
||||
residuals: list[EquationResidual] = []
|
||||
for definition in self.PORTS:
|
||||
if definition.name == self.REFERENCE_PORT:
|
||||
continue
|
||||
port = self.get_port(definition.name)
|
||||
residuals.append(
|
||||
EquationResidual(
|
||||
id=f"{self.name}:{definition.name}_pressure_reference",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="equal",
|
||||
variables=(
|
||||
f"{self.name}.{definition.name}.p",
|
||||
f"{self.name}.{self.REFERENCE_PORT}.p",
|
||||
),
|
||||
role="effort",
|
||||
value=port.p - reference.p,
|
||||
)
|
||||
)
|
||||
residuals.append(
|
||||
EquationResidual(
|
||||
id=f"{self.name}:mass_flow_balance",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="sumToZero",
|
||||
variables=tuple(
|
||||
f"{self.name}.{definition.name}.m_flow"
|
||||
for definition in self.PORTS
|
||||
),
|
||||
role="flow",
|
||||
value=sum(self.get_port(definition.name).m_flow for definition in self.PORTS),
|
||||
)
|
||||
)
|
||||
return tuple(residuals)
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
incoming = [
|
||||
(port.m_flow, connected_h[name])
|
||||
for name, port in self.ports.items()
|
||||
if port.m_flow > 1e-12
|
||||
]
|
||||
total_flow = sum(m_flow for m_flow, _ in incoming)
|
||||
if total_flow > 1e-12:
|
||||
mixed_h = sum(m_flow * h for m_flow, h in incoming) / total_flow
|
||||
else:
|
||||
mixed_h = connected_h.get(
|
||||
self.REFERENCE_PORT,
|
||||
sum(connected_h.values()) / len(connected_h) if connected_h else 0.0,
|
||||
)
|
||||
for port in self.ports.values():
|
||||
port.h_outflow = mixed_h
|
||||
|
||||
|
||||
class AmesimPn3Node2(_AmesimPneumaticNode):
|
||||
"""AMESim PN3NODE2 pneumatic three-port junction."""
|
||||
|
||||
MODEL_TYPE = "amesim_pn3node2"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||
)
|
||||
MODEL_TYPE = 'amesim_pn3node2'
|
||||
MODEL_VERSION = '0.3.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', computation=NODE_BRANCH), PortDefinition.pneumatic('port_2', computation=NODE_REFERENCE), PortDefinition.pneumatic('port_3', computation=NODE_BRANCH))
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="PN3NODE2 三端气动节点",
|
||||
library_id="amesim",
|
||||
category_id="junctions",
|
||||
symbol="tee",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
PortDisplaySpec("port_3", "right", order=30),
|
||||
),
|
||||
order=10,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='PN3NODE2 三端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_pn3node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30)), order=10)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimPn3Node2:
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimPn3Node2:
|
||||
return cls(name=name)
|
||||
|
||||
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:port_3_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow'], 'role': 'flow'})
|
||||
|
||||
class AmesimP4Node2(_AmesimPneumaticNode):
|
||||
"""AMESim P4NODE2 pneumatic four-port junction."""
|
||||
|
||||
MODEL_TYPE = "amesim_p4node2"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
|
||||
)
|
||||
MODEL_TYPE = 'amesim_p4node2'
|
||||
MODEL_VERSION = '0.3.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', computation=NODE_BRANCH), PortDefinition.pneumatic('port_2', computation=NODE_REFERENCE), PortDefinition.pneumatic('port_3', computation=NODE_BRANCH), PortDefinition.pneumatic('port_4', computation=NODE_BRANCH))
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="P4NODE2 四端气动节点",
|
||||
library_id="amesim",
|
||||
category_id="junctions",
|
||||
symbol="generic",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
PortDisplaySpec("port_3", "right", order=30),
|
||||
PortDisplaySpec("port_4", "right", order=40),
|
||||
),
|
||||
order=20,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='P4NODE2 四端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_p4node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimP4Node2:
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimP4Node2:
|
||||
return cls(name=name)
|
||||
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:port_3_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:port_4_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_4.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow', '__MODEL__.port_4.m_flow'], 'role': 'flow'})
|
||||
@@ -9,11 +9,12 @@ from app.simulation.core.catalog import (
|
||||
LIBRARY = ComponentLibrarySpec(
|
||||
id="amesim",
|
||||
label="AMESim 组件库",
|
||||
version="0.1.0",
|
||||
version="0.3.0",
|
||||
source_package="app.simulation.components.amesim",
|
||||
temporary=True,
|
||||
order=200,
|
||||
categories=(
|
||||
ComponentCategorySpec(id="media", label="介质物性", order=5),
|
||||
ComponentCategorySpec(id="storage", label="储能元件", order=10),
|
||||
ComponentCategorySpec(id="flow", label="流动元件", order=20),
|
||||
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
|
||||
@@ -22,6 +23,8 @@ LIBRARY = ComponentLibrarySpec(
|
||||
ComponentCategorySpec(id="mechanical", label="机械元件", order=60),
|
||||
),
|
||||
models=(
|
||||
"app.simulation.components.amesim.media.properties:AmesimIdealAirMediumDefinition",
|
||||
"app.simulation.components.amesim.media.properties:AmesimHeliumMediumDefinition",
|
||||
"app.simulation.components.amesim.boundary.sources:AmesimPnpl01",
|
||||
"app.simulation.components.amesim.signals.sources:AmesimStep0",
|
||||
"app.simulation.components.amesim.signals.sources:AmesimUd00",
|
||||
@@ -30,6 +33,7 @@ LIBRARY = ComponentLibrarySpec(
|
||||
"app.simulation.components.amesim.mechanical.translational:AmesimMecmas21",
|
||||
"app.simulation.components.amesim.mechanical.translational:AmesimLstp00a",
|
||||
"app.simulation.components.amesim.mechanical.translational:AmesimLmechn1",
|
||||
"app.simulation.components.amesim.mechanical.pistons:AmesimPnrp17",
|
||||
"app.simulation.components.amesim.storage.chambers:AmesimPnch023",
|
||||
"app.simulation.components.amesim.storage.chambers:AmesimPnch012",
|
||||
"app.simulation.components.amesim.flow.orifices:AmesimPnor001",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from math import 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.metadata import ParameterDefinition, ResultVariableDefinition
|
||||
from app.simulation.core.medium import GasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.port_computation import FLOW_SUPPLY
|
||||
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
|
||||
|
||||
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'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), 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)
|
||||
|
||||
@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
|
||||
EQUATIONS = ({'id': '__MODEL__:pneumatic_zero_mass_flow', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:port_2_port_5_x_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_2.x', '__MODEL__.port_5.x'], 'role': 'effort'}, {'id': '__MODEL__:port_2_port_5_v_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_2.v', '__MODEL__.port_5.v'], 'role': 'effort'}, {'id': '__MODEL__:port_3_port_4_x_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.x', '__MODEL__.port_4.x'], 'role': 'effort'}, {'id': '__MODEL__:port_3_port_4_v_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.v', '__MODEL__.port_4.v'], 'role': 'effort'}, {'id': '__MODEL__:piston_side_force_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.f', '__MODEL__.port_5.f', '__MODEL__.port_1.p'], 'role': 'flow'}, {'id': '__MODEL__:cylinder_side_force_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_3.f', '__MODEL__.port_4.f', '__MODEL__.port_1.p'], 'role': 'flow'})
|
||||
@@ -1,179 +1,69 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
||||
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.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
|
||||
_MECMAS21_FRICTION_ENABLED = ParameterCondition('useFriction', (2.0,))
|
||||
_MECMAS21_NON_RESTITUTION = ParameterCondition('stoptype', (1.0, 2.0, 4.0))
|
||||
_MECMAS21_LIMITS_ENABLED = ParameterCondition('stoptype', (1.0, 2.0, 3.0))
|
||||
_MECMAS21_ELASTIC_STOP = ParameterCondition('stoptype', (2.0,))
|
||||
_MECMAS21_RESTITUTION_STOP = ParameterCondition('stoptype', (3.0,))
|
||||
_MECMAS21_ADVANCED_FRICTION = ParameterCondition('frictionType', (2.0,))
|
||||
_MECMAS21_STRIBECK_ENABLED = ParameterCondition('strib', (2.0,))
|
||||
_LSTP00A_NUMERICAL_STIFFNESS = ParameterCondition('stiffmode', (1.0,))
|
||||
_LSTP00A_GEOMETRICAL_STIFFNESS = ParameterCondition('stiffmode', (2.0,))
|
||||
|
||||
class AmesimF000(AlgebraicComponent):
|
||||
"""AMESim F000 zero force source."""
|
||||
|
||||
MODEL_TYPE = "amesim_f000"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (PortDefinition.mechanical_translational("port_1"),)
|
||||
MODEL_TYPE = 'amesim_f000'
|
||||
MODEL_VERSION = '0.1.0'
|
||||
PORTS = (PortDefinition.mechanical_translational('port_1'),)
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="F000 零力源",
|
||||
library_id="amesim",
|
||||
category_id="mechanical",
|
||||
symbol="generic",
|
||||
ports=(PortDisplaySpec("port_1", "right", order=10),),
|
||||
order=10,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='F000 零力源', library_id='amesim', category_id='mechanical', symbol='amesim_f000', ports=(PortDisplaySpec('port_1', 'right', order=10),), order=10)
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({})
|
||||
self.port_1 = self.register_declared_port("port_1")
|
||||
self.port_1 = self.register_declared_port('port_1')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimF000":
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimF000':
|
||||
return cls(name=name)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:zero_force",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(f"{self.name}.port_1.f",),
|
||||
role="flow",
|
||||
value=self.port_1.f,
|
||||
),
|
||||
)
|
||||
|
||||
EQUATIONS = ({'id': '__MODEL__:zero_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.f'], 'role': 'flow'},)
|
||||
|
||||
class AmesimForc(AlgebraicComponent):
|
||||
"""AMESim FORC signal-to-force converter."""
|
||||
MODEL_TYPE = 'amesim_forc'
|
||||
MODEL_VERSION = '0.2.0'
|
||||
PORTS = (PortDefinition.signal('res', nominal_role='input'), PortDefinition.mechanical_translational('port_2'))
|
||||
PARAMETERS = (ParameterDefinition('direction', 1.0, label='力方向', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '正向'), ParameterOption(-1.0, '反向')), description='显式控制输入信号相对于机械端口正方向的力符号;图标旋转和镜像不会改变该参数。'),)
|
||||
RESULT_VARIABLES = (ResultVariableDefinition('force', '输出力', 'force', 'N', 'signal', 10),)
|
||||
DISPLAY = ComponentDisplaySpec(label='FORC 信号转力', library_id='amesim', category_id='mechanical', symbol='amesim_forc', ports=(PortDisplaySpec('res', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=20)
|
||||
|
||||
MODEL_TYPE = "amesim_forc"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.signal("res", nominal_role="input"),
|
||||
PortDefinition.mechanical_translational("port_2"),
|
||||
)
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = (
|
||||
ResultVariableDefinition("force", "输出力", "force", "N", "signal", 10),
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="FORC 信号转力",
|
||||
library_id="amesim",
|
||||
category_id="mechanical",
|
||||
symbol="signal",
|
||||
ports=(
|
||||
PortDisplaySpec("res", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
),
|
||||
order=20,
|
||||
)
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
def __init__(self, name: str, *, direction: float=1.0) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({})
|
||||
self.res = self.register_declared_port("res")
|
||||
self.port_2 = self.register_declared_port("port_2")
|
||||
self.set_parameter_values({'direction': direction})
|
||||
self.direction = float(direction)
|
||||
self.res = self.register_declared_port('res')
|
||||
self.port_2 = self.register_declared_port('port_2')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimForc":
|
||||
return cls(name=name)
|
||||
|
||||
@property
|
||||
def output_force(self) -> float:
|
||||
return float(self.res.signal)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:signal_force",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"),
|
||||
role="flow",
|
||||
value=self.port_2.f + self.output_force,
|
||||
),
|
||||
)
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
return {"force": self.output_force}
|
||||
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimForc':
|
||||
return cls(name=name, direction=parameters['direction'])
|
||||
EQUATIONS = ({'id': '__MODEL__:signal_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.f', '__MODEL__.res.signal'], 'role': 'flow'},)
|
||||
|
||||
class AmesimMecmas21(DynamicComponent):
|
||||
"""AMESim MECMAS21 first public one-dimensional translational mass."""
|
||||
|
||||
MODEL_TYPE = "amesim_mecmas21"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.mechanical_translational("port_1"),
|
||||
PortDefinition.mechanical_translational("port_2"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition("mass", 1.0, label="质量", quantity="mass", unit="kg", minimum=0.0, minimum_exclusive=True),
|
||||
ParameterDefinition("fstick", 0.0, label="静摩擦力", quantity="force", unit="N", minimum=0.0),
|
||||
ParameterDefinition("fcoul", 0.0, label="库仑摩擦力", quantity="force", unit="N", minimum=0.0),
|
||||
ParameterDefinition("rvisc", 0.0, label="黏性摩擦系数", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
|
||||
ParameterDefinition("wind", 0.0, label="风阻系数", quantity="windage", unit="N/(m/s)^2", minimum=0.0),
|
||||
ParameterDefinition("dvel", 1.0e-6, label="粘滞速度阈值", quantity="velocity", unit="m/s", minimum=0.0),
|
||||
ParameterDefinition("restdvel", 1.0e-6, label="恢复速度阈值", quantity="velocity", unit="m/s", minimum=0.0),
|
||||
ParameterDefinition("restcoeff", 0.65, label="恢复系数", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
|
||||
ParameterDefinition("astrib", 1.0e-3, label="Stribeck 常数", quantity="velocity", unit="m/s", minimum=0.0),
|
||||
ParameterDefinition("xmin", -1.0, label="下位移限位", quantity="length", unit="m"),
|
||||
ParameterDefinition("Kbmin", 1.0e9, label="下限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0),
|
||||
ParameterDefinition("Dbmin", 1.0e4, label="下限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
|
||||
ParameterDefinition("Pdmin", 1.0e-4, label="下限位满阻尼穿透", quantity="length", unit="m", minimum=0.0),
|
||||
ParameterDefinition("xmax", 0.8, label="上位移限位", quantity="length", unit="m"),
|
||||
ParameterDefinition("Kbmax", 1.0e9, label="上限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0),
|
||||
ParameterDefinition("Dbmax", 1.0e4, label="上限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
|
||||
ParameterDefinition("Pdmax", 1.0e-4, label="上限位满阻尼穿透", quantity="length", unit="m", minimum=0.0),
|
||||
ParameterDefinition("theta", 0.0, label="倾角", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("useFriction", 1.0, label="启用摩擦", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
|
||||
ParameterDefinition("stoptype", 4.0, label="限位类型", quantity="dimensionless", unit="", minimum=0.0),
|
||||
ParameterDefinition("discContactOption", 1.0, label="接触选项", quantity="dimensionless", unit="", minimum=0.0),
|
||||
ParameterDefinition("strib", 1.0, label="Stribeck 选项", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
|
||||
ParameterDefinition("frictionType", 1.0, label="摩擦类型", quantity="dimensionless", unit="", minimum=0.0),
|
||||
ParameterDefinition("v0", 0.0, label="初始速度", quantity="velocity", unit="m/s"),
|
||||
ParameterDefinition("x0", 0.0, label="初始位移", quantity="length", unit="m"),
|
||||
)
|
||||
RESULT_VARIABLES = (
|
||||
ResultVariableDefinition("a", "加速度", "acceleration", "m/s2", "state", 10),
|
||||
ResultVariableDefinition("v", "速度", "velocity", "m/s", "state", 20),
|
||||
ResultVariableDefinition("x", "位移", "length", "m", "state", 30),
|
||||
ResultVariableDefinition("Fvisc", "黏性摩擦力", "force", "N", "derived", 40),
|
||||
ResultVariableDefinition("Ffric", "干摩擦力", "force", "N", "derived", 50),
|
||||
ResultVariableDefinition("Fmin", "下限位力", "force", "N", "derived", 60),
|
||||
ResultVariableDefinition("Fmax", "上限位力", "force", "N", "derived", 70),
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="MECMAS21 一维质量",
|
||||
library_id="amesim",
|
||||
category_id="mechanical",
|
||||
symbol="generic",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
),
|
||||
order=30,
|
||||
)
|
||||
MODEL_TYPE = 'amesim_mecmas21'
|
||||
MODEL_VERSION = '0.2.0'
|
||||
PORTS = (PortDefinition.mechanical_translational('port_1'), PortDefinition.mechanical_translational('port_2'))
|
||||
PARAMETERS = (ParameterDefinition('mass', 1.0, label='质量', quantity='mass', unit='kg', minimum=0.0, minimum_exclusive=True, description='平动质量,必须大于零。'), ParameterDefinition('fstick', 0.0, label='静摩擦力', quantity='force', unit='N', minimum=0.0, description='静止时可抵消的外力阈值;超过阈值后进入滑动,停止后重新保持。恢复碰撞模式不使用干摩擦。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('fcoul', 0.0, label='库仑摩擦力', quantity='force', unit='N', minimum=0.0, description='滑动时的库仑摩擦力绝对值,方向与速度相反;启用干摩擦时不得大于 fstick。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('rvisc', 0.0, label='黏性摩擦系数', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='与速度成正比的黏性摩擦系数。', visible_when=(_MECMAS21_FRICTION_ENABLED,)), ParameterDefinition('wind', 0.0, label='风阻系数', quantity='windage', unit='N/(m/s)^2', minimum=0.0, description='与速度平方成正比、方向与速度相反的风阻系数。', visible_when=(_MECMAS21_FRICTION_ENABLED,)), ParameterDefinition('dvel', 1e-06, label='粘滞速度阈值', quantity='velocity', unit='m/s', minimum=0.0, description='高级摩擦模型的低速保持区间半宽;减速进入该区间时检查静摩擦保持条件。必须大于零。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION)), ParameterDefinition('restdvel', 1e-06, label='恢复速度阈值', quantity='velocity', unit='m/s', minimum=0.0, description='恢复碰撞低于该入射速度时按无回弹处理。', visible_when=(_MECMAS21_RESTITUTION_STOP,)), ParameterDefinition('restcoeff', 0.65, label='恢复系数', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='恢复碰撞后的速度与碰撞前速度绝对值之比。', visible_when=(_MECMAS21_RESTITUTION_STOP,)), ParameterDefinition('astrib', 0.001, label='Stribeck 常数', quantity='velocity', unit='m/s', minimum=0.0, description='高级 Stribeck 摩擦模型的速度常数,控制摩擦力从静摩擦阈值向库仑摩擦力的指数过渡;必须大于零。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION, _MECMAS21_STRIBECK_ENABLED)), ParameterDefinition('xmin', -1.0, label='下位移限位', quantity='length', unit='m', description='理想、弹性或恢复碰撞限位的下边界位置。', visible_when=(_MECMAS21_LIMITS_ENABLED,)), ParameterDefinition('Kbmin', 1000000000.0, label='下限位刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='弹性下限位的接触刚度,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Dbmin', 10000.0, label='下限位阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='弹性下限位的最大接触阻尼,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Pdmin', 0.0001, label='下限位满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='弹性下限位阻尼增至最大值约 63.2% 时的穿透量;阻尼随穿透量按指数规律趋近最大值。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('xmax', 0.8, label='上位移限位', quantity='length', unit='m', description='理想、弹性或恢复碰撞限位的上边界位置。', visible_when=(_MECMAS21_LIMITS_ENABLED,)), ParameterDefinition('Kbmax', 1000000000.0, label='上限位刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='弹性上限位的接触刚度,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Dbmax', 10000.0, label='上限位阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='弹性上限位的最大接触阻尼,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Pdmax', 0.0001, label='上限位满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='弹性上限位阻尼增至最大值约 63.2% 时的穿透量;阻尼随穿透量按指数规律趋近最大值。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('theta', 0.0, label='倾角(度)', quantity='dimensionless', unit='', description='源 AMESim 参数以度为单位:+90° 表示端口 1 位于最低端,-90° 表示端口 1 位于最高端。按标准重力加速度 9.80665 m/s² 计算沿运动方向的重力分量;正倾角产生正向加速度。'), ParameterDefinition('useFriction', 2.0, label='启用摩擦', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '否'), ParameterOption(2.0, '是')), description='AMELine truncated
|
||||
RESULT_VARIABLES = (ResultVariableDefinition('a', '加速度', 'acceleration', 'm/s2', 'state', 10), ResultVariableDefinition('v', '速度', 'velocity', 'm/s', 'state', 20), ResultVariableDefinition('x', '位移', 'length', 'm', 'state', 30), ResultVariableDefinition('Fvisc', '黏性摩擦力', 'force', 'N', 'derived', 40), ResultVariableDefinition('Ffric', '干摩擦力', 'force', 'N', 'derived', 50), ResultVariableDefinition('Fmin', '下限位力', 'force', 'N', 'derived', 60), ResultVariableDefinition('Fmax', '上限位力', 'force', 'N', 'derived', 70))
|
||||
DISPLAY = ComponentDisplaySpec(label='MECMAS21 一维质量', library_id='amesim', category_id='mechanical', symbol='amesim_mecmas21', ports=(PortDisplaySpec('port_2', 'left', order=10), PortDisplaySpec('port_1', 'right', order=20)), order=30, parameter_groups=(ParameterGroupDisplaySpec(id='friction', label='摩擦', parameters=('frictionType', 'strib', 'astrib', 'fstick', 'fcoul', 'rvisc', 'wind', 'dvel'), order=10), ParameterGroupDisplaySpec(id='endstops', label='限位', parameters=('discContactOption', 'xmax', 'Kbmax', 'Dbmax', 'Pdmax', 'xmin', 'Kbmin', 'Dbmin', 'Pdmin', 'restcoeff', 'restdvel'), order=20)))
|
||||
state_size = 2
|
||||
|
||||
def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None:
|
||||
@@ -182,325 +72,110 @@ class AmesimMecmas21(DynamicComponent):
|
||||
self.set_parameter_values(resolved)
|
||||
for name, value in resolved.items():
|
||||
setattr(self, name, float(value))
|
||||
self.use_friction = bool(int(self.useFriction))
|
||||
self.port_1 = self.register_declared_port("port_1")
|
||||
self.port_2 = self.register_declared_port("port_2")
|
||||
self.v = float(self.v0)
|
||||
self.x = float(self.x0)
|
||||
self.refresh_thermodynamic_ports()
|
||||
self.use_friction = int(self.useFriction) == 2
|
||||
self.port_1 = self.register_declared_port('port_1')
|
||||
self.port_2 = self.register_declared_port('port_2')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimMecmas21":
|
||||
for integer_name in ("useFriction", "stoptype", "discContactOption", "strib", "frictionType"):
|
||||
if not float(parameters[integer_name]).is_integer():
|
||||
raise ValueError(f"MECMAS21 {integer_name} must be an integer.")
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimMecmas21':
|
||||
definitions = {definition.name: definition for definition in cls.PARAMETERS}
|
||||
for integer_name in ('useFriction', 'stoptype', 'discContactOption', 'strib', 'frictionType'):
|
||||
value = float(parameters[integer_name])
|
||||
if not value.is_integer():
|
||||
raise ValueError(f'MECMAS21 {integer_name} must be an integer.')
|
||||
message = definitions[integer_name].validation_message(value)
|
||||
if message is not None:
|
||||
raise ValueError(f'MECMAS21 {integer_name} {message}.')
|
||||
return cls(name=name, medium=medium, **dict(parameters))
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return [self.v, self.x]
|
||||
|
||||
def set_state_vector(self, values: list[float]) -> None:
|
||||
if len(values) != 2:
|
||||
raise ValueError("MECMAS21 state vector requires [v, x].")
|
||||
self.v = float(values[0])
|
||||
self.x = float(values[1])
|
||||
self.refresh_thermodynamic_ports()
|
||||
|
||||
def refresh_thermodynamic_ports(self) -> None:
|
||||
for port in (self.port_1, self.port_2):
|
||||
port.x = self.x
|
||||
port.v = self.v
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
self._state_residual("port_1", "x", self.port_1.x - self.x),
|
||||
self._state_residual("port_1", "v", self.port_1.v - self.v),
|
||||
self._state_residual("port_2", "x", self.port_2.x - self.x),
|
||||
self._state_residual("port_2", "v", self.port_2.v - self.v),
|
||||
)
|
||||
|
||||
def _state_residual(self, port_name: str, variable: str, value: float) -> EquationResidual:
|
||||
return EquationResidual(
|
||||
id=f"{self.name}:{port_name}_{variable}_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.{port_name}.{variable}",),
|
||||
role="effort",
|
||||
value=value,
|
||||
)
|
||||
|
||||
def _viscous_friction_force(self) -> float:
|
||||
if not self.use_friction:
|
||||
return 0.0
|
||||
return -self.rvisc * self.v
|
||||
|
||||
def _windage_force(self) -> float:
|
||||
if not self.use_friction:
|
||||
return 0.0
|
||||
return -self.wind * self.v * abs(self.v)
|
||||
|
||||
def _dry_friction_force(self) -> float:
|
||||
if not self.use_friction:
|
||||
return 0.0
|
||||
if self.v > 0.0:
|
||||
return -self.fcoul
|
||||
if self.v < 0.0:
|
||||
return self.fcoul
|
||||
return 0.0
|
||||
|
||||
def _lower_limit_force(self) -> float:
|
||||
penetration = max(self.xmin - self.x, 0.0)
|
||||
if penetration <= 0.0:
|
||||
return 0.0
|
||||
return self.Kbmin * penetration + max(-self.Dbmin * self.v, 0.0)
|
||||
|
||||
def _upper_limit_force(self) -> float:
|
||||
penetration = max(self.x - self.xmax, 0.0)
|
||||
if penetration <= 0.0:
|
||||
return 0.0
|
||||
return self.Kbmax * penetration + max(self.Dbmax * self.v, 0.0)
|
||||
|
||||
def acceleration(self) -> float:
|
||||
return (
|
||||
self.port_1.f
|
||||
+ self.port_2.f
|
||||
+ self._viscous_friction_force()
|
||||
+ self._windage_force()
|
||||
+ self._dry_friction_force()
|
||||
+ self._lower_limit_force()
|
||||
- self._upper_limit_force()
|
||||
) / self.mass
|
||||
|
||||
def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]:
|
||||
return [self.acceleration(), self.v]
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
return {
|
||||
"a": self.acceleration(),
|
||||
"v": self.v,
|
||||
"x": self.x,
|
||||
"Fvisc": self._viscous_friction_force(),
|
||||
"Ffric": self._dry_friction_force(),
|
||||
"Fmin": self._lower_limit_force(),
|
||||
"Fmax": self._upper_limit_force(),
|
||||
}
|
||||
|
||||
EQUATIONS = ({'id': '__MODEL__:port_1_x_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.x'], 'role': 'effort'}, {'id': '__MODEL__:port_1_v_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.v'], 'role': 'effort'}, {'id': '__MODEL__:port_2_x_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.x'], 'role': 'effort'}, {'id': '__MODEL__:port_2_v_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.v'], 'role': 'effort'})
|
||||
|
||||
class AmesimLstp00a(AlgebraicComponent):
|
||||
"""AMESim LSTP00A first public elastic contact component."""
|
||||
|
||||
MODEL_TYPE = "amesim_lstp00a"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.mechanical_translational("port_1"),
|
||||
PortDefinition.mechanical_translational("port_2"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition("na", 10.0, label="有效圈数", quantity="dimensionless", unit="", minimum=0.0, minimum_exclusive=True),
|
||||
ParameterDefinition("gap0", 0.0, label="初始间隙", quantity="length", unit="m"),
|
||||
ParameterDefinition("kcont", 1.0e6, label="接触刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0),
|
||||
ParameterDefinition("G", 8.57e10, label="剪切模量", quantity="pressure", unit="Pa", minimum=0.0),
|
||||
ParameterDefinition("sdiam", 0.02, label="弹簧直径", quantity="length", unit="m", minimum=0.0),
|
||||
ParameterDefinition("wdiam", 0.002, label="线径", quantity="length", unit="m", minimum=0.0),
|
||||
ParameterDefinition("rcont", 0.0, label="接触阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
|
||||
ParameterDefinition("Pdis", 1.0e-7, label="满阻尼穿透", quantity="length", unit="m", minimum=0.0),
|
||||
ParameterDefinition("stiffmode", 1.0, label="刚度模式", quantity="dimensionless", unit="", minimum=0.0),
|
||||
ParameterDefinition("discContactOption", 1.0, label="接触选项", quantity="dimensionless", unit="", minimum=0.0),
|
||||
)
|
||||
RESULT_VARIABLES = (
|
||||
ResultVariableDefinition("gap", "间隙", "length", "m", "derived", 10),
|
||||
ResultVariableDefinition("penetration", "穿透", "length", "m", "derived", 20),
|
||||
ResultVariableDefinition("force", "接触力", "force", "N", "derived", 30),
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="LSTP00A 弹性接触",
|
||||
library_id="amesim",
|
||||
category_id="mechanical",
|
||||
symbol="generic",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
),
|
||||
order=40,
|
||||
)
|
||||
MODEL_TYPE = 'amesim_lstp00a'
|
||||
MODEL_VERSION = '0.2.0'
|
||||
PORTS = (PortDefinition.mechanical_translational('port_1'), PortDefinition.mechanical_translational('port_2'))
|
||||
PARAMETERS = (ParameterDefinition('na', 10.0, label='有效圈数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, description='几何刚度模式下使用的弹簧有效圈数。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('gap0', 0.0, label='初始间隙', quantity='length', unit='m', description='两个机械端口开始产生接触力前的初始间隙。'), ParameterDefinition('kcont', 1000000.0, label='接触刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='数值刚度模式下直接指定的接触刚度,执行仿真时必须大于零。', visible_when=(_LSTP00A_NUMERICAL_STIFFNESS,)), ParameterDefinition('G', 85700000000.0, label='剪切模量', quantity='pressure', unit='Pa', minimum=0.0, description='几何刚度模式下的材料剪切模量,允许为零;此时仅保留接触阻尼。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('sdiam', 0.02, label='弹簧直径', quantity='length', unit='m', minimum=0.0, description='几何刚度模式下的弹簧平均直径。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('wdiam', 0.002, label='线径', quantity='length', unit='m', minimum=0.0, description='几何刚度模式下的弹簧线径。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('rcont', 0.0, label='接触阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='接触穿透过程中使用的最大阻尼系数。'), ParameterDefinition('Pdis', 1e-07, label='满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='接触阻尼增至最大值约 63.2% 时的穿透量;阻尼随穿透量按指数规律趋近最大值。'), ParameterDefinition('stiffmode', 1.0, label='刚度模式', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '数值刚度'), ParameterOption(2.0, '几何参数')), description='AMESim 原始编码:1 直接使用接触刚度,2 使用弹簧几何参数。几何模式按 G×线径⁴/(8×平均直径³×有效圈数) 计算刚度;此时不使用 kcont。'), ParameterDefinition('discContactOption', 1.0, label='允许负接触力', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '允许负接触力'), ParameterOption(2.0, '不允许负接触力')), description='AMESim 原始编码:1 保留阻尼项可能产生的负接触力,2 将接触力限制为非负。'))
|
||||
RESULT_VARIABLES = (ResultVariableDefinition('gap', '间隙', 'length', 'm', 'derived', 10), ResultVariableDefinition('penetration', '穿透', 'length', 'm', 'derived', 20), ResultVariableDefinition('force', '接触力', 'force', 'N', 'derived', 30))
|
||||
DISPLAY = ComponentDisplaySpec(label='LSTP00A 弹性接触', library_id='amesim', category_id='mechanical', symbol='amesim_lstp00a', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=(ParameterGroupDisplaySpec(id='stiffness', label='刚度', parameters=('kcont', 'G', 'sdiam', 'wdiam', 'na'), order=10), ParameterGroupDisplaySpec(id='contact', label='接触', parameters=('gap0', 'rcont', 'Pdis'), order=20)))
|
||||
|
||||
def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None:
|
||||
super().__init__(name=name)
|
||||
resolved = {
|
||||
definition.name: parameters.get(definition.name, definition.default)
|
||||
for definition in self.PARAMETERS
|
||||
}
|
||||
resolved = {definition.name: parameters.get(definition.name, definition.default) for definition in self.PARAMETERS}
|
||||
self.set_parameter_values(resolved)
|
||||
for name, value in resolved.items():
|
||||
setattr(self, name, float(value))
|
||||
self.port_1 = self.register_declared_port("port_1")
|
||||
self.port_2 = self.register_declared_port("port_2")
|
||||
self.port_1 = self.register_declared_port('port_1')
|
||||
self.port_2 = self.register_declared_port('port_2')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimLstp00a":
|
||||
for integer_name in ("stiffmode", "discContactOption"):
|
||||
if not float(parameters[integer_name]).is_integer():
|
||||
raise ValueError(f"LSTP00A {integer_name} must be an integer.")
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimLstp00a':
|
||||
definitions = {definition.name: definition for definition in cls.PARAMETERS}
|
||||
for integer_name in ('stiffmode', 'discContactOption'):
|
||||
value = float(parameters[integer_name])
|
||||
if not value.is_integer():
|
||||
raise ValueError(f'LSTP00A {integer_name} must be an integer.')
|
||||
message = definitions[integer_name].validation_message(value)
|
||||
if message is not None:
|
||||
raise ValueError(f'LSTP00A {integer_name} {message}.')
|
||||
return cls(name=name, medium=medium, **dict(parameters))
|
||||
|
||||
@property
|
||||
def gap(self) -> float:
|
||||
return self.gap0 - (self.port_2.x - self.port_1.x)
|
||||
|
||||
@property
|
||||
def penetration(self) -> float:
|
||||
return max(-self.gap, 0.0)
|
||||
|
||||
@property
|
||||
def penetration_velocity(self) -> float:
|
||||
return self.port_2.v - self.port_1.v
|
||||
|
||||
@property
|
||||
def contact_force(self) -> float:
|
||||
if self.penetration <= 0.0:
|
||||
return 0.0
|
||||
return max(self.kcont * self.penetration + self.rcont * self.penetration_velocity, 0.0)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
force = self.contact_force
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:port_1_contact_force",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(f"{self.name}.port_1.f", f"{self.name}.port_1.x", f"{self.name}.port_2.x"),
|
||||
role="flow",
|
||||
value=self.port_1.f + force,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:port_2_contact_force",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(f"{self.name}.port_2.f", f"{self.name}.port_1.x", f"{self.name}.port_2.x"),
|
||||
role="flow",
|
||||
value=self.port_2.f - force,
|
||||
),
|
||||
)
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
return {
|
||||
"gap": self.gap,
|
||||
"penetration": self.penetration,
|
||||
"force": self.contact_force,
|
||||
}
|
||||
|
||||
EQUATIONS = ({'id': '__MODEL__:port_1_contact_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.f', '__MODEL__.port_1.x', '__MODEL__.port_1.v', '__MODEL__.port_2.x', '__MODEL__.port_2.v'], 'role': 'flow'}, {'id': '__MODEL__:port_2_contact_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.f', '__MODEL__.port_1.x', '__MODEL__.port_1.v', '__MODEL__.port_2.x', '__MODEL__.port_2.v'], 'role': 'flow'})
|
||||
|
||||
class AmesimLmechn1(AlgebraicComponent):
|
||||
"""AMESim LMECHN1 first public dynamic linear mechanical node."""
|
||||
MODEL_TYPE = 'amesim_lmechn1'
|
||||
MODEL_VERSION = '0.2.0'
|
||||
PORTS = tuple((PortDefinition.mechanical_translational(f'port_{index}') for index in range(1, 22)))
|
||||
PARAMETERS = (ParameterDefinition('v1', 2.0, label='右侧端口数', quantity='dimensionless', unit='', minimum=1.0, maximum=20.0, description='设置工作区中显示的右侧机械端口数量,最多 20 个。'), ParameterDefinition('sum', 1.0, label='节点求和模式', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '各端口力代数和为零(标准节点)'),)))
|
||||
RESULT_VARIABLES = (ResultVariableDefinition('tforce', '节点合力', 'force', 'N', 'derived', 10),)
|
||||
DISPLAY = ComponentDisplaySpec(label='LMECHN1 线性机械节点', library_id='amesim', category_id='mechanical', symbol='amesim_lmechn1', ports=tuple([PortDisplaySpec(f'port_{index}', 'right', order=index * 10) for index in range(1, 21)] + [PortDisplaySpec('port_21', 'left', order=210)]), order=50)
|
||||
|
||||
MODEL_TYPE = "amesim_lmechn1"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = tuple(
|
||||
PortDefinition.mechanical_translational(f"port_{index}")
|
||||
for index in range(1, 10)
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition("v1", 8.0, label="右侧端口数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0),
|
||||
ParameterDefinition("sum", 1.0, label="节点求和模式", quantity="dimensionless", unit="", minimum=0.0),
|
||||
)
|
||||
RESULT_VARIABLES = (
|
||||
ResultVariableDefinition("tforce", "节点合力", "force", "N", "derived", 10),
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="LMECHN1 线性机械节点",
|
||||
library_id="amesim",
|
||||
category_id="mechanical",
|
||||
symbol="junction",
|
||||
ports=tuple(
|
||||
[PortDisplaySpec(f"port_{index}", "left", order=index * 10) for index in range(1, 9)]
|
||||
+ [PortDisplaySpec("port_9", "right", order=90)]
|
||||
),
|
||||
order=50,
|
||||
)
|
||||
|
||||
def __init__(self, name: str, medium: IdealGasMedium, *, v1: float = 8.0, sum: float = 1.0) -> None:
|
||||
def __init__(self, name: str, medium: IdealGasMedium, *, v1: float=2.0, sum: float=1.0) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({"v1": v1, "sum": sum})
|
||||
self.set_parameter_values({'v1': v1, 'sum': sum})
|
||||
self.v1 = int(v1)
|
||||
self.sum = int(sum)
|
||||
for definition in self.PORTS:
|
||||
setattr(self, definition.name, self.register_declared_port(definition.name))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimLmechn1":
|
||||
for integer_name in ("v1", "sum"):
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimLmechn1':
|
||||
for integer_name in ('v1', 'sum'):
|
||||
if not float(parameters[integer_name]).is_integer():
|
||||
raise ValueError(f"LMECHN1 {integer_name} must be an integer.")
|
||||
return cls(name=name, medium=medium, v1=parameters["v1"], sum=parameters["sum"])
|
||||
raise ValueError(f'LMECHN1 {integer_name} must be an integer.')
|
||||
return cls(name=name, medium=medium, v1=parameters['v1'], sum=parameters['sum'])
|
||||
|
||||
@classmethod
|
||||
def active_port_definitions_for_parameters(cls, parameters: Mapping[str, float]) -> tuple[PortDefinition, ...]:
|
||||
right_port_count = float(parameters['v1'])
|
||||
if not right_port_count.is_integer():
|
||||
raise ValueError('LMECHN1 v1 must be an integer.')
|
||||
count = int(right_port_count)
|
||||
if count < 1 or count > 20:
|
||||
raise ValueError('LMECHN1 v1 must be between 1 and 20.')
|
||||
return cls.PORTS[:count + 1]
|
||||
|
||||
@property
|
||||
def active_port_definitions(self) -> tuple[PortDefinition, ...]:
|
||||
return self.PORTS[:self.v1 + 1]
|
||||
|
||||
@property
|
||||
def active_ports(self) -> tuple[str, ...]:
|
||||
return tuple(f"port_{index}" for index in range(1, self.v1 + 1)) + ("port_9",)
|
||||
return tuple((definition.name for definition in self.active_port_definitions))
|
||||
|
||||
@property
|
||||
def total_force(self) -> float:
|
||||
return sum(self.get_port(port_name).f for port_name in self.active_ports)
|
||||
def reference_port_name(self) -> str:
|
||||
return f'port_{self.v1 + 1}'
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
reference = self.port_9
|
||||
residuals: list[EquationResidual] = []
|
||||
for port_name in self.active_ports[:-1]:
|
||||
port = self.get_port(port_name)
|
||||
residuals.append(
|
||||
EquationResidual(
|
||||
id=f"{self.name}:{port_name}_x_equal",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="equal",
|
||||
variables=(f"{self.name}.{port_name}.x", f"{self.name}.port_9.x"),
|
||||
role="effort",
|
||||
value=port.x - reference.x,
|
||||
)
|
||||
)
|
||||
residuals.append(
|
||||
EquationResidual(
|
||||
id=f"{self.name}:{port_name}_v_equal",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="equal",
|
||||
variables=(f"{self.name}.{port_name}.v", f"{self.name}.port_9.v"),
|
||||
role="effort",
|
||||
value=port.v - reference.v,
|
||||
)
|
||||
)
|
||||
residuals.append(
|
||||
EquationResidual(
|
||||
id=f"{self.name}:force_balance",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="sumToZero",
|
||||
variables=tuple(f"{self.name}.{port_name}.f" for port_name in self.active_ports),
|
||||
role="flow",
|
||||
value=self.total_force,
|
||||
)
|
||||
)
|
||||
return tuple(residuals)
|
||||
@property
|
||||
def required_connection_ports(self) -> tuple[str, ...]:
|
||||
return self.active_ports
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
return {"tforce": self.total_force}
|
||||
def equation_definitions(self):
|
||||
from app.simulation.core.equations import EquationDefinition
|
||||
result = []
|
||||
reference = self.reference_port_name
|
||||
for name in self.active_ports[:-1]:
|
||||
for field in ('x', 'v'):
|
||||
result.append(EquationDefinition(id=f'{self.name}:{name}_{field}_equal', owner='component', owner_id=self.name, relation='equal', variables=(f'{self.name}.{name}.{field}', f'{self.name}.{reference}.{field}'), role='effort'))
|
||||
result.append(EquationDefinition(id=f'{self.name}:force_balance', owner='component', owner_id=self.name, relation='sumToZero', variables=tuple((f'{self.name}.{name}.f' for name in self.active_ports)), role='flow'))
|
||||
return tuple(result)
|
||||
@@ -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,57 @@
|
||||
"""Gas identities and constants passed to the native compiler."""
|
||||
from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
from app.simulation.core.medium import GasMedium, IdealGasMedium
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimIdealAirMedium(IdealGasMedium):
|
||||
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-05
|
||||
viscosity_T_ref: float = 293.15
|
||||
sutherland_constant: float = 110.4
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
"""Helium identity; reference constants below describe the lower range.
|
||||
|
||||
The native evaluator uses Amesim's piecewise NASA heat capacity/enthalpy
|
||||
(6000 K transition) and viscosity (1000/5000 K transitions), together
|
||||
with PR departure properties. ``cp_ref`` is not a global constant Cp.
|
||||
"""
|
||||
SUBSTANCE_ID: ClassVar[str] = 'helium'
|
||||
PROPERTY_METHOD_ID: ClassVar[str] = 'peng_robinson'
|
||||
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 = 8.31446261815324 / 0.004002602
|
||||
cp_ref: float = 2.5 * (8.31446261815324 / 0.004002602)
|
||||
T_ref: float = 293.15
|
||||
cp_slope: float = 0.0
|
||||
viscosity_ref: float = 1.96e-05
|
||||
viscosity_T_ref: float = 293.15
|
||||
sutherland_constant: float = 79.4
|
||||
|
||||
@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"],
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""External Amesim encodings, separate from the saved public model contract.
|
||||
|
||||
Apply these mappings only when reading Amesim parameters. A browser JSON/XML
|
||||
already uses public values: applying the mapping again changes its meaning.
|
||||
Reviewed against the installed Amesim 2404 submodel parameter declarations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
AMESIM_CHOICE_VALUES = {
|
||||
("amesim_ud00", "nstages"): {i: i for i in range(1, 9)},
|
||||
("amesim_ud00", "iscyclic"): {1: 0, 2: 1},
|
||||
("amesim_lstp00a", "stiffmode"): {1: 1, 2: 2},
|
||||
("amesim_lstp00a", "discContactOption"): {1: 1, 2: 2},
|
||||
("amesim_mecmas21", "useFriction"): {1: 1, 2: 2},
|
||||
("amesim_mecmas21", "stoptype"): {1: 1, 2: 2, 3: 3, 4: 4},
|
||||
("amesim_mecmas21", "discContactOption"): {1: 1, 2: 2},
|
||||
("amesim_mecmas21", "strib"): {1: 1, 2: 2},
|
||||
("amesim_mecmas21", "frictionType"): {1: 1, 2: 2},
|
||||
("amesim_lmechn1", "sum"): {1: 1},
|
||||
**{(model, "mode"): {1: 1, 2: 2} for model in
|
||||
("amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003")},
|
||||
**{(model, "flowset"): {1: 1, 2: 2, 3: 3} for model in
|
||||
("amesim_pnor001", "amesim_pnvo001", "amesim_pnvo001_fixed")},
|
||||
}
|
||||
|
||||
|
||||
def amesim_choice_to_public(model_type: str, parameter: str, value: float) -> float:
|
||||
mapping = AMESIM_CHOICE_VALUES.get((model_type, parameter))
|
||||
if mapping is None:
|
||||
return value
|
||||
if value not in mapping:
|
||||
raise ValueError(f"{model_type}.{parameter}: unknown Amesim encoding {value}; "
|
||||
f"expected one of {tuple(mapping)}")
|
||||
return float(mapping[value])
|
||||
|
||||
|
||||
def contact_stiffness(component) -> float:
|
||||
"""SI constant lowering; the contact force is evaluated in C."""
|
||||
if int(component.stiffmode) == 1:
|
||||
if component.kcont <= 0:
|
||||
raise ValueError(f"{component.name}: LSTP00A 数值刚度模式要求 kcont > 0")
|
||||
return component.kcont
|
||||
if component.G < 0:
|
||||
raise ValueError(f"{component.name}: LSTP00A 几何刚度模式要求 G >= 0")
|
||||
for name in ("sdiam", "wdiam", "na"):
|
||||
if getattr(component, name) <= 0:
|
||||
raise ValueError(f"{component.name}: LSTP00A 几何刚度模式要求 {name} > 0")
|
||||
return component.G * component.wdiam**4 / (8 * component.sdiam**3 * component.na)
|
||||
|
||||
|
||||
def validate_numerical_semantics(component) -> None:
|
||||
"""Reject requested behavior that cannot yet be faithfully executed.
|
||||
|
||||
Keep this at numerical compilation, so incomplete/future configurations
|
||||
can still be edited and saved. Disabled friction settings have no effect.
|
||||
"""
|
||||
if component.model_type == "amesim_mecmas21" and component.use_friction and int(component.stoptype) != 3:
|
||||
for name in ('dvel', 'astrib'):
|
||||
if getattr(component, name) <= 0:
|
||||
raise ValueError(f"{component.name}: MECMAS21 启用摩擦时 {name} 必须大于零(Amesim 参数约束)")
|
||||
if component.fcoul > component.fstick:
|
||||
raise ValueError(f"{component.name}: MECMAS21 库仑摩擦 fcoul 不能大于静摩擦 fstick(Amesim 参数约束)")
|
||||
if component.model_type == 'amesim_mecmas21':
|
||||
if int(component.stoptype) in (1, 2, 3) and component.xmin > component.xmax:
|
||||
raise ValueError(f'{component.name}: MECMAS21 限位要求 xmin <= xmax')
|
||||
if int(component.stoptype) == 2:
|
||||
for name in ('Kbmin', 'Kbmax', 'Dbmin', 'Dbmax'):
|
||||
if getattr(component, name) <= 0:
|
||||
raise ValueError(f'{component.name}: MECMAS21 弹性限位要求 {name} > 0')
|
||||
if component.model_type == 'amesim_mecmas21' and int(component.stoptype) == 3 and component.restdvel <= 0:
|
||||
raise ValueError(f'{component.name}: MECMAS21 恢复碰撞模式要求 restdvel > 0(Amesim 参数约束)')
|
||||
if component.model_type == "amesim_lstp00a":
|
||||
contact_stiffness(component)
|
||||
@@ -1,204 +1,79 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
def _ud00_stage_parameters(index: int) -> tuple[ParameterDefinition, ...]:
|
||||
visible_when = () if index == 1 else (ParameterCondition('nstages', tuple((float(stage_count) for stage_count in range(index, 9)))),)
|
||||
return (ParameterDefinition(f'start{index}', 0.0 if index == 1 else 1.0, label=f'第 {index} 段起点', quantity='dimensionless', unit='', description=f'第 {index} 段开始时的输出值。', visible_when=visible_when), ParameterDefinition(f'end{index}', 1.0, label=f'第 {index} 段终点', quantity='dimensionless', unit='', description=f'第 {index} 段结束时的输出值。', visible_when=visible_when), ParameterDefinition(f't{index}', 1.0 if index == 1 else 0.0, label=f'第 {index} 段时长', quantity='time', unit='s', minimum=0.0, description=f'第 {index} 段的持续时间。', visible_when=visible_when))
|
||||
|
||||
_UD00_STAGE_PARAMETERS = tuple((parameter for stage_index in range(1, 9) for parameter in _ud00_stage_parameters(stage_index)))
|
||||
|
||||
class AmesimStep0(AlgebraicComponent):
|
||||
"""AMESim STEP0 scalar step signal source."""
|
||||
MODEL_TYPE = 'amesim_step0'
|
||||
MODEL_VERSION = '0.1.0'
|
||||
PORTS = (PortDefinition.signal('out', nominal_role='output'),)
|
||||
PARAMETERS = (ParameterDefinition('initial', 0.0, label='初始值', quantity='dimensionless', unit=''), ParameterDefinition('final', 1.0, label='阶跃后值', quantity='dimensionless', unit=''), ParameterDefinition('time', 0.0, label='阶跃时间', quantity='time', unit='s'))
|
||||
RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),)
|
||||
DISPLAY = ComponentDisplaySpec(label='STEP0 阶跃信号', library_id='amesim', category_id='signals', symbol='amesim_step0', ports=(PortDisplaySpec('out', 'right', order=10),), order=10)
|
||||
|
||||
MODEL_TYPE = "amesim_step0"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (PortDefinition.signal("out", nominal_role="output"),)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition("initial", 0.0, label="初始值", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("final", 1.0, label="阶跃后值", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("time", 0.0, label="阶跃时间", quantity="time", unit="s"),
|
||||
)
|
||||
RESULT_VARIABLES = (
|
||||
ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10),
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="STEP0 阶跃信号",
|
||||
library_id="amesim",
|
||||
category_id="signals",
|
||||
symbol="signal",
|
||||
ports=(PortDisplaySpec("out", "right", order=10),),
|
||||
order=10,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
*,
|
||||
initial: float = 0.0,
|
||||
final: float = 1.0,
|
||||
time: float = 0.0,
|
||||
) -> None:
|
||||
def __init__(self, name: str, medium: IdealGasMedium, *, initial: float=0.0, final: float=1.0, time: float=0.0) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({"initial": initial, "final": final, "time": time})
|
||||
self.set_parameter_values({'initial': initial, 'final': final, 'time': time})
|
||||
self.initial = float(initial)
|
||||
self.final = float(final)
|
||||
self.time = float(time)
|
||||
self.out = self.register_declared_port("out")
|
||||
self.out.signal = self.output_at(0.0)
|
||||
self.out = self.register_declared_port('out')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimStep0":
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
initial=parameters["initial"],
|
||||
final=parameters["final"],
|
||||
time=parameters["time"],
|
||||
)
|
||||
|
||||
def output_at(self, time: float) -> float:
|
||||
return self.final if time >= self.time else self.initial
|
||||
|
||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
||||
return {"out": self.output_at(time)}
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
return {"y": self.out.signal}
|
||||
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimStep0':
|
||||
return cls(name=name, medium=medium, initial=parameters['initial'], final=parameters['final'], time=parameters['time'])
|
||||
EQUATIONS = ()
|
||||
|
||||
class AmesimUd00(AlgebraicComponent):
|
||||
"""AMESim UD00 piecewise-linear scalar signal source."""
|
||||
MODEL_TYPE = 'amesim_ud00'
|
||||
MODEL_VERSION = '0.2.0'
|
||||
PORTS = (PortDefinition.signal('out', nominal_role='output'),)
|
||||
PARAMETERS = (ParameterDefinition('tstart', 0.0, label='启动时间', quantity='time', unit='s', description='分段信号开始输出第一段之前的等待时间。'), *_UD00_STAGE_PARAMETERS, ParameterDefinition('nstages', 1.0, label='段数', quantity='dimensionless', unit='', minimum=1.0, maximum=8.0, editor='choice', options=tuple((ParameterOption(float(stage_count), str(stage_count)) for stage_count in range(1, 9))), description='参与输出计算的有效线性分段数量。'), ParameterDefinition('iscyclic', 0.0, label='循环', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, editor='choice', options=(ParameterOption(0.0, '否'), ParameterOption(1.0, '是')), description='当前公共协议编码:0 表示单次输出,1 表示循环输出。'))
|
||||
RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),)
|
||||
DISPLAY = ComponentDisplaySpec(label='UD00 分段线性信号', library_id='amesim', category_id='signals', symbol='amesim_ud00', ports=(PortDisplaySpec('out', 'right', order=10),), order=20, parameter_groups=(ParameterGroupDisplaySpec(id='stages', label='分段参数', parameters=tuple((parameter.name for parameter in _UD00_STAGE_PARAMETERS)), order=10),))
|
||||
|
||||
MODEL_TYPE = "amesim_ud00"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (PortDefinition.signal("out", nominal_role="output"),)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition("tstart", 0.0, label="启动时间", quantity="time", unit="s"),
|
||||
ParameterDefinition("start1", 0.0, label="第 1 段起点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("end1", 1.0, label="第 1 段终点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("t1", 1.0, label="第 1 段时长", quantity="time", unit="s", minimum=0.0),
|
||||
ParameterDefinition("start2", 1.0, label="第 2 段起点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("end2", 1.0, label="第 2 段终点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("t2", 0.0, label="第 2 段时长", quantity="time", unit="s", minimum=0.0),
|
||||
ParameterDefinition("start3", 1.0, label="第 3 段起点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("end3", 1.0, label="第 3 段终点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("t3", 0.0, label="第 3 段时长", quantity="time", unit="s", minimum=0.0),
|
||||
ParameterDefinition("start4", 1.0, label="第 4 段起点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("end4", 1.0, label="第 4 段终点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("t4", 0.0, label="第 4 段时长", quantity="time", unit="s", minimum=0.0),
|
||||
ParameterDefinition("start5", 1.0, label="第 5 段起点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("end5", 1.0, label="第 5 段终点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("t5", 0.0, label="第 5 段时长", quantity="time", unit="s", minimum=0.0),
|
||||
ParameterDefinition("start6", 1.0, label="第 6 段起点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("end6", 1.0, label="第 6 段终点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("t6", 0.0, label="第 6 段时长", quantity="time", unit="s", minimum=0.0),
|
||||
ParameterDefinition("start7", 1.0, label="第 7 段起点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("end7", 1.0, label="第 7 段终点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("t7", 0.0, label="第 7 段时长", quantity="time", unit="s", minimum=0.0),
|
||||
ParameterDefinition("start8", 1.0, label="第 8 段起点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("end8", 1.0, label="第 8 段终点", quantity="dimensionless", unit=""),
|
||||
ParameterDefinition("t8", 0.0, label="第 8 段时长", quantity="time", unit="s", minimum=0.0),
|
||||
ParameterDefinition("nstages", 1.0, label="段数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0),
|
||||
ParameterDefinition("iscyclic", 0.0, label="循环", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
|
||||
)
|
||||
RESULT_VARIABLES = (
|
||||
ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10),
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="UD00 分段线性信号",
|
||||
library_id="amesim",
|
||||
category_id="signals",
|
||||
symbol="signal",
|
||||
ports=(PortDisplaySpec("out", "right", order=10),),
|
||||
order=20,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
*,
|
||||
tstart: float = 0.0,
|
||||
starts: tuple[float, ...] = (0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0),
|
||||
ends: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0),
|
||||
durations: tuple[float, ...] = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
||||
nstages: int = 1,
|
||||
iscyclic: bool = False,
|
||||
) -> None:
|
||||
def __init__(self, name: str, medium: IdealGasMedium, *, tstart: float=0.0, starts: tuple[float, ...]=(0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), ends: tuple[float, ...]=(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), durations: tuple[float, ...]=(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), nstages: int=1, iscyclic: bool=False) -> None:
|
||||
super().__init__(name=name)
|
||||
if len(starts) != 8 or len(ends) != 8 or len(durations) != 8:
|
||||
raise ValueError("UD00 requires exactly eight start, end, and duration values.")
|
||||
raise ValueError('UD00 requires exactly eight start, end, and duration values.')
|
||||
if nstages < 1 or nstages > 8:
|
||||
raise ValueError("UD00 nstages must be between 1 and 8.")
|
||||
raise ValueError('UD00 nstages must be between 1 and 8.')
|
||||
self.tstart = float(tstart)
|
||||
self.starts = tuple(float(value) for value in starts)
|
||||
self.ends = tuple(float(value) for value in ends)
|
||||
self.durations = tuple(float(value) for value in durations)
|
||||
self.starts = tuple((float(value) for value in starts))
|
||||
self.ends = tuple((float(value) for value in ends))
|
||||
self.durations = tuple((float(value) for value in durations))
|
||||
self.nstages = int(nstages)
|
||||
self.iscyclic = bool(iscyclic)
|
||||
values: dict[str, float] = {"tstart": self.tstart, "nstages": float(self.nstages), "iscyclic": float(int(self.iscyclic))}
|
||||
values: dict[str, float] = {'tstart': self.tstart, 'nstages': float(self.nstages), 'iscyclic': float(int(self.iscyclic))}
|
||||
for index in range(1, 9):
|
||||
values[f"start{index}"] = self.starts[index - 1]
|
||||
values[f"end{index}"] = self.ends[index - 1]
|
||||
values[f"t{index}"] = self.durations[index - 1]
|
||||
values[f'start{index}'] = self.starts[index - 1]
|
||||
values[f'end{index}'] = self.ends[index - 1]
|
||||
values[f't{index}'] = self.durations[index - 1]
|
||||
self.set_parameter_values(values)
|
||||
self.out = self.register_declared_port("out")
|
||||
self.out.signal = self.output_at(0.0)
|
||||
self.out = self.register_declared_port('out')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimUd00":
|
||||
nstages = parameters["nstages"]
|
||||
iscyclic = parameters["iscyclic"]
|
||||
if not float(nstages).is_integer():
|
||||
raise ValueError("UD00 nstages must be an integer.")
|
||||
if not float(iscyclic).is_integer():
|
||||
raise ValueError("UD00 iscyclic must be 0 or 1.")
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
tstart=parameters["tstart"],
|
||||
starts=tuple(parameters[f"start{index}"] for index in range(1, 9)),
|
||||
ends=tuple(parameters[f"end{index}"] for index in range(1, 9)),
|
||||
durations=tuple(parameters[f"t{index}"] for index in range(1, 9)),
|
||||
nstages=int(nstages),
|
||||
iscyclic=bool(int(iscyclic)),
|
||||
)
|
||||
|
||||
def output_at(self, time: float) -> float:
|
||||
elapsed = max(float(time) - self.tstart, 0.0)
|
||||
active_durations = self.durations[: self.nstages]
|
||||
total_duration = sum(active_durations)
|
||||
if self.iscyclic and total_duration > 0.0:
|
||||
elapsed = elapsed % total_duration
|
||||
|
||||
stage_start_time = 0.0
|
||||
for index, duration in enumerate(active_durations):
|
||||
stage_end_time = stage_start_time + duration
|
||||
if elapsed < stage_end_time or index == self.nstages - 1:
|
||||
if duration <= 0.0:
|
||||
return self.ends[index]
|
||||
fraction = (elapsed - stage_start_time) / duration
|
||||
return self.starts[index] + fraction * (self.ends[index] - self.starts[index])
|
||||
stage_start_time = stage_end_time
|
||||
return self.ends[self.nstages - 1]
|
||||
|
||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
||||
return {"out": self.output_at(time)}
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
return {"y": self.out.signal}
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimUd00':
|
||||
nstages = parameters['nstages']
|
||||
iscyclic = parameters['iscyclic']
|
||||
definitions = {definition.name: definition for definition in cls.PARAMETERS}
|
||||
for parameter_name, value in (('nstages', nstages), ('iscyclic', iscyclic)):
|
||||
numeric_value = float(value)
|
||||
if not numeric_value.is_integer():
|
||||
raise ValueError(f'UD00 {parameter_name} must be an integer.')
|
||||
message = definitions[parameter_name].validation_message(numeric_value)
|
||||
if message is not None:
|
||||
raise ValueError(f'UD00 {parameter_name} {message}.')
|
||||
return cls(name=name, medium=medium, tstart=parameters['tstart'], starts=tuple((parameters[f'start{index}'] for index in range(1, 9))), ends=tuple((parameters[f'end{index}'] for index in range(1, 9))), durations=tuple((parameters[f't{index}'] for index in range(1, 9))), nstages=int(nstages), iscyclic=bool(int(iscyclic)))
|
||||
EQUATIONS = ()
|
||||
@@ -1,20 +1,13 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from math import isclose
|
||||
|
||||
from app.simulation.components.amesim.gases import AMESIM_GAS_INDEX_PARAMETER, normalize_amesim_gas_index
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
ResultVariableDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
from app.simulation.core.medium import GasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
|
||||
|
||||
class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
|
||||
@@ -24,498 +17,66 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
framework's mass/internal-energy volume state and keeps the AMESim
|
||||
heat-transfer contract `kth * sth * (extemp - T)`.
|
||||
"""
|
||||
|
||||
MODEL_TYPE = "amesim_pnch023"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"cvol",
|
||||
0.057,
|
||||
label="气室容积",
|
||||
quantity="volume",
|
||||
unit="m3",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"kth",
|
||||
0.0,
|
||||
label="换热系数",
|
||||
quantity="heat_transfer_coefficient",
|
||||
unit="W/(m2*K)",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"sth",
|
||||
0.1,
|
||||
label="换热面积",
|
||||
quantity="area",
|
||||
unit="m2",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"extemp",
|
||||
293.15,
|
||||
label="外部温度",
|
||||
quantity="temperature",
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="气体类型索引",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"p0",
|
||||
100000.0,
|
||||
label="初始压力",
|
||||
quantity="pressure",
|
||||
unit="Pa",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"T0",
|
||||
293.15,
|
||||
label="初始温度",
|
||||
quantity="temperature",
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
)
|
||||
MODEL_TYPE = 'amesim_pnch023'
|
||||
MODEL_VERSION = '0.1.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', computation=THERMODYNAMIC_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
|
||||
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol', 0.057, label='气室容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='气室内部用于储存气体的固定有效容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'))
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="PNCH023 固定容积气室",
|
||||
library_id="amesim",
|
||||
category_id="storage",
|
||||
symbol="tank",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
),
|
||||
order=10,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='PNCH023 固定容积气室', library_id='amesim', category_id='storage', symbol='amesim_pnch023', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
*,
|
||||
cvol: float = 0.057,
|
||||
kth: float = 0.0,
|
||||
sth: float = 0.1,
|
||||
extemp: float = 293.15,
|
||||
gi: float = 1.0,
|
||||
p0: float = 100000.0,
|
||||
T0: float = 293.15,
|
||||
) -> None:
|
||||
def __init__(self, name: str, medium: GasMedium, *, cvol: float=0.057, kth: float=0.0, sth: float=0.1, extemp: float=293.15, gi: float=1.0, p0: float=100000.0, T0: float=293.15) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values(
|
||||
{
|
||||
"cvol": cvol,
|
||||
"kth": kth,
|
||||
"sth": sth,
|
||||
"extemp": extemp,
|
||||
"gi": gi,
|
||||
"p0": p0,
|
||||
"T0": T0,
|
||||
}
|
||||
)
|
||||
self.set_parameter_values({'cvol': cvol, 'kth': kth, 'sth': sth, 'extemp': extemp, 'gi': gi, 'p0': p0, 'T0': T0})
|
||||
self.medium = medium
|
||||
self.cvol = float(cvol)
|
||||
self.kth = float(kth)
|
||||
self.sth = float(sth)
|
||||
self.extemp = float(extemp)
|
||||
self.gi = self._integer_parameter("gi", gi)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.p0 = float(p0)
|
||||
self.T0 = float(T0)
|
||||
m0 = self.p0 * self.cvol / (medium.R_gas * self.T0)
|
||||
U0 = m0 * medium.specific_internal_energy(self.T0)
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
initial_h = medium.specific_enthalpy(self.T0)
|
||||
self.port_1 = self.register_declared_port("port_1")
|
||||
self.port_1.p = self.p0
|
||||
self.port_1.h_outflow = initial_h
|
||||
self.port_2 = self.register_declared_port("port_2")
|
||||
self.port_2.p = self.p0
|
||||
self.port_2.h_outflow = initial_h
|
||||
|
||||
@staticmethod
|
||||
def _integer_parameter(name: str, value: float) -> int:
|
||||
rounded = round(value)
|
||||
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12):
|
||||
raise ValueError(f"PNCH023 parameter {name} must be an integer value.")
|
||||
return int(rounded)
|
||||
self.port_1 = self.register_declared_port('port_1')
|
||||
self.port_2 = self.register_declared_port('port_2')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> AmesimPnch023:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
cvol=parameters["cvol"],
|
||||
kth=parameters["kth"],
|
||||
sth=parameters["sth"],
|
||||
extemp=parameters["extemp"],
|
||||
gi=parameters["gi"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return self.state.as_vector()
|
||||
|
||||
def set_state_vector(self, values: list[float]) -> None:
|
||||
self.state = VolumeState.from_vector(values)
|
||||
|
||||
def properties(self) -> ThermodynamicProperties:
|
||||
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.cvol)
|
||||
self.port_1.p = props.p
|
||||
self.port_1.h_outflow = props.h
|
||||
self.port_2.p = props.p
|
||||
self.port_2.h_outflow = props.h
|
||||
return props
|
||||
|
||||
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||
return self.properties()
|
||||
|
||||
def thermal_energy_flow_w(self, temperature: float) -> float:
|
||||
return self.kth * self.sth * (self.extemp - temperature)
|
||||
|
||||
def state_derivative_from_ports(
|
||||
self,
|
||||
connected_h: Mapping[str, float],
|
||||
) -> list[float]:
|
||||
props = self.properties()
|
||||
inlet_h_1 = self.connection_inlet_enthalpy(
|
||||
port_m_flow=self.port_1.m_flow,
|
||||
connected_h=connected_h["port_1"],
|
||||
internal_h=props.h,
|
||||
)
|
||||
inlet_h_2 = self.connection_inlet_enthalpy(
|
||||
port_m_flow=self.port_2.m_flow,
|
||||
connected_h=connected_h["port_2"],
|
||||
internal_h=props.h,
|
||||
)
|
||||
derivative = VolumeState(
|
||||
m=self.port_1.m_flow + self.port_2.m_flow,
|
||||
U=(
|
||||
self.port_1.m_flow * inlet_h_1
|
||||
+ self.port_2.m_flow * inlet_h_2
|
||||
+ self.thermal_energy_flow_w(props.T)
|
||||
),
|
||||
)
|
||||
return derivative.as_vector()
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
pressure = self.medium.properties_from_mU(
|
||||
self.state.m,
|
||||
self.state.U,
|
||||
self.cvol,
|
||||
).p
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:port_1_pressure_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_1.p", f"{self.name}.state"),
|
||||
role="effort",
|
||||
value=self.port_1.p - pressure,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:port_2_pressure_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_2.p", f"{self.name}.state"),
|
||||
role="effort",
|
||||
value=self.port_2.p - pressure,
|
||||
),
|
||||
)
|
||||
|
||||
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnch023:
|
||||
return cls(name=name, medium=medium, cvol=parameters['cvol'], kth=parameters['kth'], sth=parameters['sth'], extemp=parameters['extemp'], gi=parameters['gi'], p0=parameters['p0'], T0=parameters['T0'])
|
||||
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'})
|
||||
|
||||
class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
"""AMESim PNCH012 variable-volume pneumatic chamber.
|
||||
|
||||
AMESim supplies four external volume and volume-rate inputs through the
|
||||
chamber ports. The current public System XML contract has pneumatic ports
|
||||
only, so this first public model exposes those external volume inputs as SI
|
||||
parameters. This represents fixed or prescribed-volume PNCH012 cases and is
|
||||
not yet the full mechanical-coupled submodel.
|
||||
chamber ports. The SI vol1..4 parameters specify initial external volumes;
|
||||
dvol1..4 prescribe constant rates integrated from the simulation start.
|
||||
Connected moving boundaries supply their live geometry and rate directly,
|
||||
including through reference nodes, without integrating that geometry again.
|
||||
"""
|
||||
MODEL_TYPE = 'amesim_pnch012'
|
||||
MODEL_VERSION = '0.1.0'
|
||||
PORTS = tuple(PortDefinition.pneumatic(f'port_{i}', computation=THERMODYNAMIC_SUPPLY) for i in range(1, 5))
|
||||
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol0', 0.015, label='死容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='变容气室在所有外部容积为零时仍保留的基础容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'), ParameterDefinition('vol1', 0.0, label='端口 1 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol2', 0.0, label='端口 2 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol3', 0.0, label='端口 3 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol4', 0.0, label='端口 4 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('dvol1', 0.0, label='端口 1 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol2', 0.0, label='端口 2 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol3', 0.0, label='端口 3 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol4', 0.0, label='端口 4 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'))
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('vol', '气室总容积', 'volume', 'm3', 'derived', 100), ResultVariableDefinition('dvol', '总容积变化率', 'volume_flow', 'm3/s', 'derived', 110))
|
||||
DISPLAY = ComponentDisplaySpec(label='PNCH012 变容气室', library_id='amesim', category_id='storage', symbol='amesim_pnch012', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20)
|
||||
|
||||
MODEL_TYPE = "amesim_pnch012"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"cvol0",
|
||||
0.015,
|
||||
label="死容积",
|
||||
quantity="volume",
|
||||
unit="m3",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"kth",
|
||||
0.0,
|
||||
label="换热系数",
|
||||
quantity="heat_transfer_coefficient",
|
||||
unit="W/(m2*K)",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"sth",
|
||||
0.1,
|
||||
label="换热面积",
|
||||
quantity="area",
|
||||
unit="m2",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"extemp",
|
||||
293.15,
|
||||
label="外部温度",
|
||||
quantity="temperature",
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"gi",
|
||||
1.0,
|
||||
label="气体类型索引",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
minimum=1.0,
|
||||
maximum=99.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"p0",
|
||||
100000.0,
|
||||
label="初始压力",
|
||||
quantity="pressure",
|
||||
unit="Pa",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"T0",
|
||||
293.15,
|
||||
label="初始温度",
|
||||
quantity="temperature",
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition("vol1", 0.0, label="端口 1 外部容积", quantity="volume", unit="m3"),
|
||||
ParameterDefinition("vol2", 0.0, label="端口 2 外部容积", quantity="volume", unit="m3"),
|
||||
ParameterDefinition("vol3", 0.0, label="端口 3 外部容积", quantity="volume", unit="m3"),
|
||||
ParameterDefinition("vol4", 0.0, label="端口 4 外部容积", quantity="volume", unit="m3"),
|
||||
ParameterDefinition("dvol1", 0.0, label="端口 1 容积变化率", quantity="volume_flow", unit="m3/s"),
|
||||
ParameterDefinition("dvol2", 0.0, label="端口 2 容积变化率", quantity="volume_flow", unit="m3/s"),
|
||||
ParameterDefinition("dvol3", 0.0, label="端口 3 容积变化率", quantity="volume_flow", unit="m3/s"),
|
||||
ParameterDefinition("dvol4", 0.0, label="端口 4 容积变化率", quantity="volume_flow", unit="m3/s"),
|
||||
)
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (
|
||||
ResultVariableDefinition("vol", "气室总容积", "volume", "m3", "derived", 100),
|
||||
ResultVariableDefinition("dvol", "总容积变化率", "volume_flow", "m3/s", "derived", 110),
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="PNCH012 变容气室",
|
||||
library_id="amesim",
|
||||
category_id="storage",
|
||||
symbol="tank",
|
||||
ports=(
|
||||
PortDisplaySpec("port_1", "left", order=10),
|
||||
PortDisplaySpec("port_2", "right", order=20),
|
||||
PortDisplaySpec("port_3", "left", order=30),
|
||||
PortDisplaySpec("port_4", "right", order=40),
|
||||
),
|
||||
order=20,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
*,
|
||||
cvol0: float = 0.015,
|
||||
kth: float = 0.0,
|
||||
sth: float = 0.1,
|
||||
extemp: float = 293.15,
|
||||
gi: float = 1.0,
|
||||
p0: float = 100000.0,
|
||||
T0: float = 293.15,
|
||||
vol1: float = 0.0,
|
||||
vol2: float = 0.0,
|
||||
vol3: float = 0.0,
|
||||
vol4: float = 0.0,
|
||||
dvol1: float = 0.0,
|
||||
dvol2: float = 0.0,
|
||||
dvol3: float = 0.0,
|
||||
dvol4: float = 0.0,
|
||||
) -> None:
|
||||
def __init__(self, name: str, medium: GasMedium, *, cvol0: float=0.015, kth: float=0.0, sth: float=0.1, extemp: float=293.15, gi: float=1.0, p0: float=100000.0, T0: float=293.15, vol1: float=0.0, vol2: float=0.0, vol3: float=0.0, vol4: float=0.0, dvol1: float=0.0, dvol2: float=0.0, dvol3: float=0.0, dvol4: float=0.0) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values(
|
||||
{
|
||||
"cvol0": cvol0,
|
||||
"kth": kth,
|
||||
"sth": sth,
|
||||
"extemp": extemp,
|
||||
"gi": gi,
|
||||
"p0": p0,
|
||||
"T0": T0,
|
||||
"vol1": vol1,
|
||||
"vol2": vol2,
|
||||
"vol3": vol3,
|
||||
"vol4": vol4,
|
||||
"dvol1": dvol1,
|
||||
"dvol2": dvol2,
|
||||
"dvol3": dvol3,
|
||||
"dvol4": dvol4,
|
||||
}
|
||||
)
|
||||
self.set_parameter_values({'cvol0': cvol0, 'kth': kth, 'sth': sth, 'extemp': extemp, 'gi': gi, 'p0': p0, 'T0': T0, 'vol1': vol1, 'vol2': vol2, 'vol3': vol3, 'vol4': vol4, 'dvol1': dvol1, 'dvol2': dvol2, 'dvol3': dvol3, 'dvol4': dvol4})
|
||||
self.medium = medium
|
||||
self.cvol0 = float(cvol0)
|
||||
self.kth = float(kth)
|
||||
self.sth = float(sth)
|
||||
self.extemp = float(extemp)
|
||||
self.gi = AmesimPnch023._integer_parameter("gi", gi)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.p0 = float(p0)
|
||||
self.T0 = float(T0)
|
||||
self.external_volumes = {
|
||||
"port_1": float(vol1),
|
||||
"port_2": float(vol2),
|
||||
"port_3": float(vol3),
|
||||
"port_4": float(vol4),
|
||||
}
|
||||
self.external_volume_rates = {
|
||||
"port_1": float(dvol1),
|
||||
"port_2": float(dvol2),
|
||||
"port_3": float(dvol3),
|
||||
"port_4": float(dvol4),
|
||||
}
|
||||
if self.total_volume() <= 0.0:
|
||||
raise ValueError("PNCH012 total volume must be positive.")
|
||||
m0 = self.p0 * self.total_volume() / (medium.R_gas * self.T0)
|
||||
U0 = m0 * medium.specific_internal_energy(self.T0)
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
initial_h = medium.specific_enthalpy(self.T0)
|
||||
for port_name in ("port_1", "port_2", "port_3", "port_4"):
|
||||
self.external_volumes = {'port_1': float(vol1), 'port_2': float(vol2), 'port_3': float(vol3), 'port_4': float(vol4)}
|
||||
self.external_volume_rates = {'port_1': float(dvol1), 'port_2': float(dvol2), 'port_3': float(dvol3), 'port_4': float(dvol4)}
|
||||
for port_name in ('port_1', 'port_2', 'port_3', 'port_4'):
|
||||
port = self.register_declared_port(port_name)
|
||||
port.p = self.p0
|
||||
port.h_outflow = initial_h
|
||||
setattr(self, port_name, port)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> "AmesimPnch012":
|
||||
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnch012':
|
||||
return cls(name=name, medium=medium, **dict(parameters))
|
||||
|
||||
def total_volume(self) -> float:
|
||||
minimum_volume = self.cvol0 / 100.0
|
||||
return max(self.cvol0 + sum(self.external_volumes.values()), minimum_volume)
|
||||
|
||||
def total_volume_rate(self) -> float:
|
||||
if self.total_volume() <= self.cvol0 / 100.0:
|
||||
return 0.0
|
||||
return sum(self.external_volume_rates.values())
|
||||
|
||||
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.total_volume())
|
||||
for port_name in ("port_1", "port_2", "port_3", "port_4"):
|
||||
port = self.get_port(port_name)
|
||||
port.p = props.p
|
||||
port.h_outflow = props.h
|
||||
return props
|
||||
|
||||
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||
return self.properties()
|
||||
|
||||
def thermal_energy_flow_w(self, temperature: float) -> float:
|
||||
return self.kth * self.sth * (self.extemp - temperature)
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
props = self.properties()
|
||||
return {
|
||||
"m": self.state.m,
|
||||
"U": self.state.U,
|
||||
"p": props.p,
|
||||
"T": props.T,
|
||||
"rho": props.rho,
|
||||
"u": props.u,
|
||||
"h": props.h,
|
||||
"vol": self.total_volume(),
|
||||
"dvol": self.total_volume_rate(),
|
||||
}
|
||||
|
||||
def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]:
|
||||
props = self.properties()
|
||||
mass_derivative = 0.0
|
||||
energy_derivative = 0.0
|
||||
for port_name in ("port_1", "port_2", "port_3", "port_4"):
|
||||
port = self.get_port(port_name)
|
||||
inlet_h = self.connection_inlet_enthalpy(
|
||||
port_m_flow=port.m_flow,
|
||||
connected_h=connected_h[port_name],
|
||||
internal_h=props.h,
|
||||
)
|
||||
mass_derivative += port.m_flow
|
||||
energy_derivative += port.m_flow * inlet_h
|
||||
energy_derivative += self.thermal_energy_flow_w(props.T)
|
||||
energy_derivative -= props.p * self.total_volume_rate()
|
||||
return VolumeState(m=mass_derivative, U=energy_derivative).as_vector()
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
pressure = self.medium.properties_from_mU(
|
||||
self.state.m,
|
||||
self.state.U,
|
||||
self.total_volume(),
|
||||
).p
|
||||
return tuple(
|
||||
EquationResidual(
|
||||
id=f"{self.name}:{port_name}_pressure_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.{port_name}.p", f"{self.name}.state"),
|
||||
role="effort",
|
||||
value=self.get_port(port_name).p - pressure,
|
||||
)
|
||||
for port_name in ("port_1", "port_2", "port_3", "port_4")
|
||||
)
|
||||
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_3_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_3.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_4_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_4.p', '__MODEL__.state'], 'role': 'effort'})
|
||||
@@ -1,282 +0,0 @@
|
||||
# 元件建模规范与示例
|
||||
|
||||
规范的权威版本位于
|
||||
[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。
|
||||
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
|
||||
修改中同步,不能让示例形成另一套规则。
|
||||
|
||||
本文档是 `app/simulation/components` 下新增元件的最小开发规范。当前
|
||||
`experimental` 是用于验证规范的临时组件库;后续正式模型应建立独立组件库,
|
||||
不要继续堆放在 `experimental` 中。
|
||||
|
||||
目标是让元件的端口、输入参数和可展示结果都由元件类显式声明,避免 XML
|
||||
校验、求解器和前端分别维护同一份含义。
|
||||
|
||||
## 一、元件类必须声明的内容
|
||||
|
||||
每个对外注册的元件类至少需要声明以下六个类属性:
|
||||
|
||||
```python
|
||||
MODEL_TYPE = "example_component"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (...)
|
||||
PARAMETERS = (...)
|
||||
RESULT_VARIABLES = (...)
|
||||
DISPLAY = ...
|
||||
```
|
||||
|
||||
- `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。
|
||||
- `MODEL_VERSION`:模型契约版本,采用 `主版本.次版本.修订版本`。
|
||||
- `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。
|
||||
- `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。
|
||||
- `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。
|
||||
- `DISPLAY`:组件库名称、分类、图标、排序和端口画布位置,不参与物理求解。
|
||||
|
||||
元件构造函数还必须:
|
||||
|
||||
1. 调用 `super().__init__(name)`。
|
||||
2. 使用 `set_parameter_values()` 保存规范化后的输入参数。
|
||||
3. 使用 `register_declared_port()` 创建已声明端口。
|
||||
4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。
|
||||
5. 实现统一的类方法 `create()`,接收规范化后的 SI 参数。
|
||||
|
||||
## 二、输入参数与结果变量
|
||||
|
||||
输入参数和仿真结果必须分开声明:
|
||||
|
||||
- 输入参数描述一次仿真开始前由用户配置的量,例如 `volume`、`p0`、`T0`。
|
||||
- 结果变量描述随时间变化、允许绘图的量,例如 `p`、`T`、`m`、`m_flow`。
|
||||
- 求解器缓存、中间残差和调试字段不得自动暴露为结果变量。
|
||||
- 参数名和结果变量名使用稳定的英文机器标识;`label` 专门用于界面显示。
|
||||
|
||||
参数定义示例:
|
||||
|
||||
```python
|
||||
ParameterDefinition(
|
||||
name="volume",
|
||||
label="容积",
|
||||
quantity="volume",
|
||||
unit="m3",
|
||||
default=0.1,
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
)
|
||||
```
|
||||
|
||||
结果变量定义示例:
|
||||
|
||||
```python
|
||||
ResultVariableDefinition(
|
||||
name="p",
|
||||
label="压力",
|
||||
quantity="pressure",
|
||||
unit="Pa",
|
||||
category="thermodynamic",
|
||||
order=30,
|
||||
)
|
||||
```
|
||||
|
||||
## 三、命名和单位约定
|
||||
|
||||
- 模型类型、参数、端口和变量名使用 `snake_case`,已有热力学惯例 `T`、`U` 可以保留。
|
||||
- 输入参数保存和计算统一使用 SI 基准值;界面单位换算不能改变后端存储值。
|
||||
- 无量纲参数的 `unit` 使用空字符串。
|
||||
- `quantity` 表示稳定的物理量类型,例如 `pressure`、`temperature`、`mass_flow`,不能使用界面文案代替。
|
||||
- 正质量流量统一定义为流入元件,即 `positiveFlowDirection="intoComponent"`。
|
||||
- 端口变量 `p`、`m_flow`、`h_outflow` 的连接规则由 `PortDefinition.pneumatic()` 统一提供。
|
||||
|
||||
## 四、完整示例:单端口储气容腔
|
||||
|
||||
下面的示例展示一个可直接接入当前框架的动态元件。真实新增元件时应放入独立的 `.py` 文件,并补充对应测试。
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
class ExampleVolume(ThermodynamicVolumeComponent):
|
||||
MODEL_TYPE = "example_volume"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
name="volume",
|
||||
label="容积",
|
||||
quantity="volume",
|
||||
unit="m3",
|
||||
default=0.1,
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
name="p0",
|
||||
label="初始压力",
|
||||
quantity="pressure",
|
||||
unit="Pa",
|
||||
default=100000.0,
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
name="T0",
|
||||
label="初始温度",
|
||||
quantity="temperature",
|
||||
unit="K",
|
||||
default=300.0,
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="示例容腔",
|
||||
library_id="experimental",
|
||||
category_id="storage",
|
||||
symbol="generic",
|
||||
ports=(PortDisplaySpec("port_a", "left"),),
|
||||
order=90,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
volume: float = 0.1,
|
||||
p0: float = 100000.0,
|
||||
T0: float = 300.0,
|
||||
) -> None:
|
||||
super().__init__(name)
|
||||
self.set_parameter_values(
|
||||
{"volume": volume, "p0": p0, "T0": T0}
|
||||
)
|
||||
self.medium = medium
|
||||
self.V = volume
|
||||
initial_mass = p0 * volume / (medium.R_gas * T0)
|
||||
initial_energy = initial_mass * medium.specific_internal_energy(T0)
|
||||
self.state = VolumeState(m=initial_mass, U=initial_energy)
|
||||
self.port_a = self.register_declared_port("port_a")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> ExampleVolume:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
volume=parameters["volume"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return self.state.as_vector()
|
||||
|
||||
def set_state_vector(self, values: list[float]) -> None:
|
||||
self.state = VolumeState.from_vector(values)
|
||||
|
||||
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||
properties = self.medium.properties_from_mU(
|
||||
self.state.m, self.state.U, self.V
|
||||
)
|
||||
self.port_a.p = properties.p
|
||||
self.port_a.h_outflow = properties.h
|
||||
return properties
|
||||
|
||||
def state_derivative_from_ports(
|
||||
self,
|
||||
connected_h: Mapping[str, float],
|
||||
) -> list[float]:
|
||||
properties = self.refresh_thermodynamic_ports()
|
||||
inlet_h = self.connection_inlet_enthalpy(
|
||||
port_m_flow=self.port_a.m_flow,
|
||||
connected_h=connected_h["port_a"],
|
||||
internal_h=properties.h,
|
||||
)
|
||||
return [self.port_a.m_flow, self.port_a.m_flow * inlet_h]
|
||||
|
||||
def pressure_flow_equation_residuals(
|
||||
self,
|
||||
) -> tuple[EquationResidual, ...]:
|
||||
pressure = self.medium.properties_from_mU(
|
||||
self.state.m, self.state.U, self.V
|
||||
).p
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:port_a_pressure_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_a.p", f"{self.name}.state"),
|
||||
role="effort",
|
||||
value=self.port_a.p - pressure,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
模型文件不再直接修改全局注册表。完成模型类后,只把类路径加入所属库
|
||||
`library.py` 的 `models` 清单:
|
||||
|
||||
```python
|
||||
models=(
|
||||
# ...已有模型
|
||||
"app.simulation.components.experimental.storage.example_volume:ExampleVolume",
|
||||
)
|
||||
```
|
||||
|
||||
后端会受控导入清单中的类,校验版本、分类、端口、参数、单位、显示信息和默认实例,
|
||||
再自动建立注册表。校验通过后,`GET /api/components/catalog` 会输出该元件,
|
||||
前端刷新时即可加载。
|
||||
当前 `experimental` 仅用于规范验证;正式模型应先建立新的库声明,再把
|
||||
`library_id` 指向正式库。
|
||||
|
||||
完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "example_volume_1.port_a.m_flow",
|
||||
"componentId": "example_volume_1",
|
||||
"componentType": "example_volume",
|
||||
"scope": "port",
|
||||
"portName": "port_a",
|
||||
"name": "m_flow",
|
||||
"label": "质量流量",
|
||||
"quantity": "mass_flow",
|
||||
"unit": "kg/s",
|
||||
"category": "flow",
|
||||
"order": 20
|
||||
}
|
||||
```
|
||||
|
||||
## 五、新增元件检查清单
|
||||
|
||||
1. `MODEL_TYPE` 是否唯一,并与 XML 的模型类型一致。
|
||||
2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。
|
||||
3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。
|
||||
4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。
|
||||
5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。
|
||||
6. 是否只暴露有工程意义的结果,而非内部计算变量。
|
||||
7. `MODEL_VERSION` 和 `DISPLAY` 是否完整,显示端口是否与物理端口完全一致。
|
||||
8. 是否实现统一的 `create()`,并能用默认参数创建模型。
|
||||
9. 模型类路径是否只加入所属库的 `library.py` 清单。
|
||||
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
|
||||
|
||||
组件库、分类和自动发现的完整规则参见
|
||||
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
|
||||
@@ -1,12 +1,3 @@
|
||||
"""Temporary component library used to validate the model authoring contract."""
|
||||
|
||||
from app.simulation.components.experimental.library import LIBRARY
|
||||
|
||||
|
||||
# Compatibility aliases for code written before the v1 library manifest.
|
||||
LIBRARY_ID = LIBRARY.id
|
||||
LIBRARY_LABEL = LIBRARY.label
|
||||
LIBRARY_VERSION = LIBRARY.version
|
||||
LIBRARY_ORDER = LIBRARY.order
|
||||
LIBRARY_SOURCE_PACKAGE = LIBRARY.source_package
|
||||
LIBRARY_TEMPORARY = LIBRARY.temporary
|
||||
@@ -1,118 +1,35 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
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
|
||||
|
||||
from app.simulation.core.port_computation import FLOW_SUPPLY
|
||||
|
||||
class Orifice(AlgebraicComponent):
|
||||
"""Python port of ModelicaModels.Myorifice."""
|
||||
|
||||
MODEL_TYPE = "orifice"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"K",
|
||||
1e-5,
|
||||
label="流量系数",
|
||||
quantity="flow_coefficient",
|
||||
unit="kg/(s*Pa^0.5)",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"opening",
|
||||
1.0,
|
||||
label="开度",
|
||||
minimum=0.0,
|
||||
maximum=1.0,
|
||||
),
|
||||
)
|
||||
MODEL_TYPE = 'orifice'
|
||||
MODEL_VERSION = '1.0.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=FLOW_SUPPLY))
|
||||
PARAMETERS = (ParameterDefinition('K', 1e-05, label='流量系数', quantity='flow_coefficient', unit='kg/(s*Pa^0.5)', minimum=0.0), ParameterDefinition('opening', 1.0, label='开度', minimum=0.0, maximum=1.0))
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="孔板/阀门",
|
||||
library_id="experimental",
|
||||
category_id="flow",
|
||||
symbol="orifice",
|
||||
ports=(
|
||||
PortDisplaySpec("port_a", "left", order=10),
|
||||
PortDisplaySpec("port_b", "right", order=20),
|
||||
),
|
||||
order=40,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='孔板/阀门', library_id='experimental', category_id='flow', symbol='orifice', ports=(PortDisplaySpec('port_a', 'left', order=10), PortDisplaySpec('port_b', 'right', order=20)), order=40)
|
||||
|
||||
def __init__(self, name: str, opening: float = 1.0, K: float = 1e-5) -> None:
|
||||
def __init__(self, name: str, opening: float=1.0, K: float=1e-05) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({"K": K, "opening": opening})
|
||||
self.set_parameter_values({'K': K, 'opening': opening})
|
||||
self.opening = opening
|
||||
self.K = K
|
||||
self.port_a = self.register_declared_port("port_a")
|
||||
self.port_b = self.register_declared_port("port_b")
|
||||
self.port_a = self.register_declared_port('port_a')
|
||||
self.port_b = self.register_declared_port('port_b')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Orifice:
|
||||
return cls(
|
||||
name=name,
|
||||
opening=parameters["opening"],
|
||||
K=parameters["K"],
|
||||
)
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Orifice:
|
||||
return cls(name=name, opening=parameters['opening'], K=parameters['K'])
|
||||
|
||||
@property
|
||||
def K_eff(self) -> float:
|
||||
return self.K * max(self.opening, 0.001)
|
||||
|
||||
def mass_flow(self, p_a: float, p_b: float) -> float:
|
||||
dp = p_a - p_b
|
||||
if dp == 0.0:
|
||||
return 0.0
|
||||
return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:mass_flow_balance",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="sumToZero",
|
||||
variables=(
|
||||
f"{self.name}.port_a.m_flow",
|
||||
f"{self.name}.port_b.m_flow",
|
||||
),
|
||||
role="flow",
|
||||
value=self.port_a.m_flow + self.port_b.m_flow,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:pressure_flow_relation",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(
|
||||
f"{self.name}.port_a.p",
|
||||
f"{self.name}.port_b.p",
|
||||
f"{self.name}.port_a.m_flow",
|
||||
),
|
||||
role="flow",
|
||||
value=self.port_a.m_flow
|
||||
- self.mass_flow(self.port_a.p, self.port_b.p),
|
||||
),
|
||||
)
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
self.port_a.h_outflow = connected_h["port_b"]
|
||||
self.port_b.h_outflow = connected_h["port_a"]
|
||||
|
||||
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_a.m_flow', '__MODEL__.port_b.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_a.p', '__MODEL__.port_b.p', '__MODEL__.port_a.m_flow'], 'role': 'flow'})
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Compatibility import for the TestModel-only dynamic pipe.
|
||||
|
||||
The public ``pipe`` catalog model is ``ResistivePipe``. New code should import
|
||||
this legacy dynamic model from ``app.simulation.examples.testmodel.dynamic_pipe``.
|
||||
"""
|
||||
|
||||
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
|
||||
|
||||
|
||||
__all__ = ("Pipe",)
|
||||
@@ -1,102 +1,26 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from math import pi
|
||||
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import ParameterDefinition
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
|
||||
from app.simulation.core.port_computation import FLOW_SUPPLY
|
||||
|
||||
class ResistivePipe(AlgebraicComponent):
|
||||
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
|
||||
|
||||
MODEL_TYPE = "pipe"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"length",
|
||||
5.0,
|
||||
label="长度",
|
||||
quantity="length",
|
||||
unit="m",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"diameter",
|
||||
0.02,
|
||||
label="直径",
|
||||
quantity="length",
|
||||
unit="m",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"lambda_darcy",
|
||||
0.02,
|
||||
label="摩阻系数",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"p0",
|
||||
1e5,
|
||||
label="初始压力",
|
||||
quantity="pressure",
|
||||
unit="Pa",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"T0",
|
||||
300.0,
|
||||
label="初始温度",
|
||||
quantity="temperature",
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
)
|
||||
MODEL_TYPE = 'pipe'
|
||||
MODEL_VERSION = '1.0.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=FLOW_SUPPLY))
|
||||
PARAMETERS = (ParameterDefinition('length', 5.0, label='长度', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True), ParameterDefinition('diameter', 0.02, label='直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True), ParameterDefinition('lambda_darcy', 0.02, label='摩阻系数', minimum=0.0), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="管段",
|
||||
library_id="experimental",
|
||||
category_id="flow",
|
||||
symbol="pipe",
|
||||
ports=(
|
||||
PortDisplaySpec("port_a", "left", order=10),
|
||||
PortDisplaySpec("port_b", "right", order=20),
|
||||
),
|
||||
order=30,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='管段', library_id='experimental', category_id='flow', symbol='pipe', ports=(PortDisplaySpec('port_a', 'left', order=10), PortDisplaySpec('port_b', 'right', order=20)), order=30)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
L: float = 5.0,
|
||||
D: float = 0.02,
|
||||
lambda_darcy: float = 0.02,
|
||||
p0: float = 1e5,
|
||||
T0: float = 300.0,
|
||||
) -> None:
|
||||
def __init__(self, name: str, medium: IdealGasMedium, L: float=5.0, D: float=0.02, lambda_darcy: float=0.02, p0: float=100000.0, T0: float=300.0) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values(
|
||||
{
|
||||
"length": L,
|
||||
"diameter": D,
|
||||
"lambda_darcy": lambda_darcy,
|
||||
"p0": p0,
|
||||
"T0": T0,
|
||||
}
|
||||
)
|
||||
self.set_parameter_values({'length': L, 'diameter': D, 'lambda_darcy': lambda_darcy, 'p0': p0, 'T0': T0})
|
||||
self.medium = medium
|
||||
self.L = L
|
||||
self.D = D
|
||||
@@ -104,82 +28,10 @@ class ResistivePipe(AlgebraicComponent):
|
||||
self.p0 = p0
|
||||
self.T0 = T0
|
||||
self.area = pi * D * D / 4.0
|
||||
initial_h = medium.specific_enthalpy(T0)
|
||||
|
||||
self.port_a = self.register_declared_port("port_a")
|
||||
self.port_a.p = p0
|
||||
self.port_a.h_outflow = initial_h
|
||||
|
||||
self.port_b = self.register_declared_port("port_b")
|
||||
self.port_b.p = p0
|
||||
self.port_b.h_outflow = initial_h
|
||||
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],
|
||||
) -> ResistivePipe:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
L=parameters["length"],
|
||||
D=parameters["diameter"],
|
||||
lambda_darcy=parameters["lambda_darcy"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def pressure_drop(self, m_flow_a: float, p_a: float, p_b: float) -> float:
|
||||
average_pressure = max(0.5 * (p_a + p_b), 1.0)
|
||||
density = max(self.medium.density(average_pressure, self.T0), 1e-12)
|
||||
resistance = self.lambda_darcy * (self.L / self.D)
|
||||
return (
|
||||
resistance
|
||||
* m_flow_a
|
||||
* abs(m_flow_a)
|
||||
/ (2.0 * density * self.area * self.area)
|
||||
)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:mass_flow_balance",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="sumToZero",
|
||||
variables=(
|
||||
f"{self.name}.port_a.m_flow",
|
||||
f"{self.name}.port_b.m_flow",
|
||||
),
|
||||
role="flow",
|
||||
value=self.port_a.m_flow + self.port_b.m_flow,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:darcy_pressure_loss",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(
|
||||
f"{self.name}.port_a.p",
|
||||
f"{self.name}.port_b.p",
|
||||
f"{self.name}.port_a.m_flow",
|
||||
),
|
||||
role="effort",
|
||||
value=(
|
||||
self.port_a.p
|
||||
- self.port_b.p
|
||||
- self.pressure_drop(
|
||||
self.port_a.m_flow,
|
||||
self.port_a.p,
|
||||
self.port_b.p,
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
self.port_a.h_outflow = connected_h["port_b"]
|
||||
self.port_b.h_outflow = connected_h["port_a"]
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> ResistivePipe:
|
||||
return cls(name=name, medium=medium, L=parameters['length'], D=parameters['diameter'], lambda_darcy=parameters['lambda_darcy'], p0=parameters['p0'], T0=parameters['T0'])
|
||||
EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_a.m_flow', '__MODEL__.port_b.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:darcy_pressure_loss', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_a.p', '__MODEL__.port_b.p', '__MODEL__.port_a.m_flow'], 'role': 'effort'})
|
||||
@@ -1,266 +1,28 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.core.base import AlgebraicComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
|
||||
|
||||
class Tee(AlgebraicComponent):
|
||||
"""Python port of ModelicaModels.Mytee."""
|
||||
|
||||
MODEL_TYPE = "tee"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"),
|
||||
)
|
||||
MODEL_TYPE = 'tee'
|
||||
MODEL_VERSION = '1.0.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_in', nominal_role='bidirectional'), PortDefinition.pneumatic('port_out1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_out2', nominal_role='bidirectional'))
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = ()
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="三通",
|
||||
library_id="experimental",
|
||||
category_id="junctions",
|
||||
symbol="tee",
|
||||
ports=(
|
||||
PortDisplaySpec("port_in", "left", order=10),
|
||||
PortDisplaySpec("port_out1", "right", order=20),
|
||||
PortDisplaySpec("port_out2", "right", order=30),
|
||||
),
|
||||
order=50,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='三通', library_id='experimental', category_id='junctions', symbol='tee', ports=(PortDisplaySpec('port_in', 'left', order=10), PortDisplaySpec('port_out1', 'right', order=20), PortDisplaySpec('port_out2', 'right', order=30)), order=50)
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({})
|
||||
self.port_in = self.register_declared_port("port_in")
|
||||
self.port_out1 = self.register_declared_port("port_out1")
|
||||
self.port_out2 = self.register_declared_port("port_out2")
|
||||
self.port_in = self.register_declared_port('port_in')
|
||||
self.port_out1 = self.register_declared_port('port_out1')
|
||||
self.port_out2 = self.register_declared_port('port_out2')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Tee:
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Tee:
|
||||
return cls(name=name)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:common_pressure_out1",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="equal",
|
||||
variables=(f"{self.name}.port_in.p", f"{self.name}.port_out1.p"),
|
||||
role="effort",
|
||||
value=self.port_in.p - self.port_out1.p,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:common_pressure_out2",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="equal",
|
||||
variables=(f"{self.name}.port_in.p", f"{self.name}.port_out2.p"),
|
||||
role="effort",
|
||||
value=self.port_in.p - self.port_out2.p,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:mass_flow_balance",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="sumToZero",
|
||||
variables=(
|
||||
f"{self.name}.port_in.m_flow",
|
||||
f"{self.name}.port_out1.m_flow",
|
||||
f"{self.name}.port_out2.m_flow",
|
||||
),
|
||||
role="flow",
|
||||
value=(
|
||||
self.port_in.m_flow
|
||||
+ self.port_out1.m_flow
|
||||
+ self.port_out2.m_flow
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
incoming = [
|
||||
(port.m_flow, connected_h[name])
|
||||
for name, port in self.ports.items()
|
||||
if port.m_flow > 1e-12
|
||||
]
|
||||
total_flow = sum(m_flow for m_flow, _ in incoming)
|
||||
if total_flow > 1e-12:
|
||||
mixed_h = sum(
|
||||
m_flow * enthalpy for m_flow, enthalpy in incoming
|
||||
) / total_flow
|
||||
else:
|
||||
values = list(connected_h.values())
|
||||
mixed_h = sum(values) / len(values) if values else 0.0
|
||||
for port in self.ports.values():
|
||||
port.h_outflow = mixed_h
|
||||
|
||||
def mixed_inlet_enthalpy(
|
||||
self,
|
||||
branch1_m_flow: float,
|
||||
branch1_h: float,
|
||||
branch2_m_flow: float,
|
||||
branch2_h: float,
|
||||
fallback_h: float = 0.0,
|
||||
) -> float:
|
||||
positive_1 = max(branch1_m_flow, 0.0)
|
||||
positive_2 = max(branch2_m_flow, 0.0)
|
||||
total = positive_1 + positive_2
|
||||
if total <= 1e-9:
|
||||
return fallback_h
|
||||
return (positive_1 * branch1_h + positive_2 * branch2_h) / total
|
||||
|
||||
def inlet_stream_enthalpy(
|
||||
self,
|
||||
branch1_m_flow: float,
|
||||
branch1_h: float,
|
||||
branch2_m_flow: float,
|
||||
branch2_h: float,
|
||||
fallback_h: float,
|
||||
) -> float:
|
||||
"""Approximate `inStream(port_in.h_outflow)` for the current tee topology."""
|
||||
|
||||
return self.mixed_inlet_enthalpy(
|
||||
branch1_m_flow,
|
||||
branch1_h,
|
||||
branch2_m_flow,
|
||||
branch2_h,
|
||||
fallback_h=fallback_h,
|
||||
)
|
||||
|
||||
def branch_actual_stream_enthalpy(
|
||||
self,
|
||||
branch_m_flow: float,
|
||||
branch_h: float,
|
||||
inlet_h: float,
|
||||
) -> float:
|
||||
"""Approximate `actualStream(branch.h_outflow)` for a tee branch port."""
|
||||
|
||||
return inlet_h if branch_m_flow > 0.0 else branch_h
|
||||
|
||||
@staticmethod
|
||||
def _solve_linear_2x2(
|
||||
a11: float,
|
||||
a12: float,
|
||||
a21: float,
|
||||
a22: float,
|
||||
b1: float,
|
||||
b2: float,
|
||||
) -> tuple[float, float] | None:
|
||||
determinant = a11 * a22 - a12 * a21
|
||||
if abs(determinant) <= 1e-12:
|
||||
return None
|
||||
x1 = (b1 * a22 - b2 * a12) / determinant
|
||||
x2 = (a11 * b2 - a21 * b1) / determinant
|
||||
return x1, x2
|
||||
|
||||
def solve_branch_outlet_flows_from_energy_balance(
|
||||
self,
|
||||
*,
|
||||
ratio_branch1: float,
|
||||
ratio_branch2: float,
|
||||
inlet_h_branch1: float,
|
||||
inlet_h_branch2: float,
|
||||
branch1_h: float,
|
||||
branch2_h: float,
|
||||
inlet_h: float,
|
||||
q_in_branch1: float,
|
||||
q_in_branch2: float,
|
||||
tolerance: float = 1e-12,
|
||||
) -> tuple[float, float]:
|
||||
"""Solve branch outlet flows for the current three-port downstream tee use-case."""
|
||||
|
||||
rhs_branch1 = q_in_branch1 * inlet_h_branch1
|
||||
rhs_branch2 = q_in_branch2 * inlet_h_branch2
|
||||
|
||||
def solve_both_forward() -> tuple[float, float] | None:
|
||||
return self._solve_linear_2x2(
|
||||
(1.0 + ratio_branch1) * branch1_h,
|
||||
ratio_branch1 * branch2_h,
|
||||
ratio_branch2 * branch1_h,
|
||||
(1.0 + ratio_branch2) * branch2_h,
|
||||
rhs_branch1,
|
||||
rhs_branch2,
|
||||
)
|
||||
|
||||
def solve_one_reverse(
|
||||
*,
|
||||
branch1_reverse: bool,
|
||||
) -> tuple[float, float] | None:
|
||||
if branch1_reverse:
|
||||
return self._solve_linear_2x2(
|
||||
inlet_h * (1.0 + ratio_branch1),
|
||||
ratio_branch1 * inlet_h,
|
||||
ratio_branch2 * inlet_h,
|
||||
branch2_h + ratio_branch2 * inlet_h,
|
||||
rhs_branch1,
|
||||
rhs_branch2,
|
||||
)
|
||||
|
||||
return self._solve_linear_2x2(
|
||||
branch1_h + ratio_branch1 * inlet_h,
|
||||
ratio_branch1 * inlet_h,
|
||||
ratio_branch2 * inlet_h,
|
||||
inlet_h * (1.0 + ratio_branch2),
|
||||
rhs_branch1,
|
||||
rhs_branch2,
|
||||
)
|
||||
|
||||
def solve_both_reverse() -> tuple[float, float] | None:
|
||||
return self._solve_linear_2x2(
|
||||
inlet_h * (1.0 + ratio_branch1),
|
||||
ratio_branch1 * inlet_h,
|
||||
ratio_branch2 * inlet_h,
|
||||
inlet_h * (1.0 + ratio_branch2),
|
||||
rhs_branch1,
|
||||
rhs_branch2,
|
||||
)
|
||||
|
||||
candidate_solvers = (
|
||||
(
|
||||
solve_both_forward,
|
||||
lambda q1, q2: q1 >= -tolerance and q2 >= -tolerance,
|
||||
),
|
||||
(
|
||||
lambda: solve_one_reverse(branch1_reverse=True),
|
||||
lambda q1, q2: q1 < -tolerance and q2 >= -tolerance and q1 + q2 > tolerance,
|
||||
),
|
||||
(
|
||||
lambda: solve_one_reverse(branch1_reverse=True),
|
||||
lambda q1, q2: q1 < -tolerance and q2 >= -tolerance and q1 + q2 <= tolerance,
|
||||
),
|
||||
(
|
||||
lambda: solve_one_reverse(branch1_reverse=False),
|
||||
lambda q1, q2: q2 < -tolerance and q1 >= -tolerance and q1 + q2 > tolerance,
|
||||
),
|
||||
(
|
||||
lambda: solve_one_reverse(branch1_reverse=False),
|
||||
lambda q1, q2: q2 < -tolerance and q1 >= -tolerance and q1 + q2 <= tolerance,
|
||||
),
|
||||
(
|
||||
solve_both_reverse,
|
||||
lambda q1, q2: q1 < -tolerance and q2 < -tolerance,
|
||||
),
|
||||
)
|
||||
|
||||
for solver, predicate in candidate_solvers:
|
||||
candidate = solver()
|
||||
if candidate is None:
|
||||
continue
|
||||
q_out_branch1, q_out_branch2 = candidate
|
||||
if predicate(q_out_branch1, q_out_branch2):
|
||||
return q_out_branch1, q_out_branch2
|
||||
|
||||
return solve_both_forward() or (0.0, 0.0)
|
||||
EQUATIONS = ({'id': '__MODEL__:common_pressure_out1', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_in.p', '__MODEL__.port_out1.p'], 'role': 'effort'}, {'id': '__MODEL__:common_pressure_out2', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_in.p', '__MODEL__.port_out2.p'], 'role': 'effort'}, {'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_in.m_flow', '__MODEL__.port_out1.m_flow', '__MODEL__.port_out2.m_flow'], 'role': 'flow'})
|
||||
@@ -1,155 +1,30 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
|
||||
|
||||
class Cylinder(ThermodynamicVolumeComponent):
|
||||
"""Python port of ModelicaModels.Mycylinder."""
|
||||
|
||||
MODEL_TYPE = "cylinder"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"volume",
|
||||
0.01,
|
||||
label="容积",
|
||||
quantity="volume",
|
||||
unit="m3",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"p0",
|
||||
35e6,
|
||||
label="初始压力",
|
||||
quantity="pressure",
|
||||
unit="Pa",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"T0",
|
||||
300.0,
|
||||
label="初始温度",
|
||||
quantity="temperature",
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
)
|
||||
MODEL_TYPE = 'cylinder'
|
||||
MODEL_VERSION = '1.0.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=THERMODYNAMIC_SUPPLY),)
|
||||
PARAMETERS = (ParameterDefinition('volume', 0.01, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 35000000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="气瓶",
|
||||
library_id="experimental",
|
||||
category_id="storage",
|
||||
symbol="cylinder",
|
||||
ports=(PortDisplaySpec("port_b", "right"),),
|
||||
order=10,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='气瓶', library_id='experimental', category_id='storage', symbol='cylinder', ports=(PortDisplaySpec('port_b', 'right'),), order=10)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
V: float = 0.01,
|
||||
p0: float = 35e6,
|
||||
T0: float = 300.0,
|
||||
) -> None:
|
||||
def __init__(self, name: str, medium: IdealGasMedium, V: float=0.01, p0: float=35000000.0, T0: float=300.0) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({"volume": V, "p0": p0, "T0": T0})
|
||||
self.set_parameter_values({'volume': V, 'p0': p0, 'T0': T0})
|
||||
self.medium = medium
|
||||
self.V = V
|
||||
m0 = p0 * V / (medium.R_gas * T0)
|
||||
U0 = m0 * medium.specific_internal_energy(T0)
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
self.port_b = self.register_declared_port("port_b")
|
||||
self.port_b = self.register_declared_port('port_b')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Cylinder:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
V=parameters["volume"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return self.state.as_vector()
|
||||
|
||||
def set_state_vector(self, values: list[float]) -> None:
|
||||
self.state = VolumeState.from_vector(values)
|
||||
|
||||
def properties(self) -> ThermodynamicProperties:
|
||||
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
|
||||
self.port_b.p = props.p
|
||||
self.port_b.h_outflow = props.h
|
||||
return props
|
||||
|
||||
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||
return self.properties()
|
||||
|
||||
def state_derivative_from_ports(
|
||||
self,
|
||||
connected_h: Mapping[str, float],
|
||||
) -> list[float]:
|
||||
properties = self.properties()
|
||||
derivative = self.derivatives_from_connection(
|
||||
connected_h=connected_h["port_b"],
|
||||
port_m_flow=self.port_b.m_flow,
|
||||
internal_h=properties.h,
|
||||
)
|
||||
return derivative.as_vector()
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
pressure = self.medium.properties_from_mU(
|
||||
self.state.m,
|
||||
self.state.U,
|
||||
self.V,
|
||||
).p
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:port_b_pressure_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_b.p", f"{self.name}.state"),
|
||||
role="effort",
|
||||
value=self.port_b.p - pressure,
|
||||
),
|
||||
)
|
||||
|
||||
def derivatives_from_connection(
|
||||
self,
|
||||
*,
|
||||
connected_h: float,
|
||||
port_m_flow: float,
|
||||
internal_h: float,
|
||||
) -> VolumeState:
|
||||
inlet_h = self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_m_flow,
|
||||
connected_h=connected_h,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
return self.derivatives(inlet_h, port_m_flow)
|
||||
|
||||
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
|
||||
return VolumeState(m=m_flow, U=m_flow * inlet_h)
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Cylinder:
|
||||
return cls(name=name, medium=medium, V=parameters['volume'], p0=parameters['p0'], T0=parameters['T0'])
|
||||
EQUATIONS = ({'id': '__MODEL__:port_b_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_b.p', '__MODEL__.state'], 'role': 'effort'},)
|
||||
@@ -1,155 +1,30 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
|
||||
|
||||
class Tank(ThermodynamicVolumeComponent):
|
||||
"""Python port of ModelicaModels.Mytank."""
|
||||
|
||||
MODEL_TYPE = "tank"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"volume",
|
||||
0.1,
|
||||
label="容积",
|
||||
quantity="volume",
|
||||
unit="m3",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"p0",
|
||||
1e5,
|
||||
label="初始压力",
|
||||
quantity="pressure",
|
||||
unit="Pa",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"T0",
|
||||
300.0,
|
||||
label="初始温度",
|
||||
quantity="temperature",
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
)
|
||||
MODEL_TYPE = 'tank'
|
||||
MODEL_VERSION = '1.0.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=THERMODYNAMIC_SUPPLY),)
|
||||
PARAMETERS = (ParameterDefinition('volume', 0.1, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
DISPLAY = ComponentDisplaySpec(
|
||||
label="贮箱",
|
||||
library_id="experimental",
|
||||
category_id="storage",
|
||||
symbol="tank",
|
||||
ports=(PortDisplaySpec("port_a", "left"),),
|
||||
order=20,
|
||||
)
|
||||
DISPLAY = ComponentDisplaySpec(label='贮箱', library_id='experimental', category_id='storage', symbol='tank', ports=(PortDisplaySpec('port_a', 'left'),), order=20)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
V: float = 0.1,
|
||||
p0: float = 1e5,
|
||||
T0: float = 300.0,
|
||||
) -> None:
|
||||
def __init__(self, name: str, medium: IdealGasMedium, V: float=0.1, p0: float=100000.0, T0: float=300.0) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({"volume": V, "p0": p0, "T0": T0})
|
||||
self.set_parameter_values({'volume': V, 'p0': p0, 'T0': T0})
|
||||
self.medium = medium
|
||||
self.V = V
|
||||
m0 = p0 * V / (medium.R_gas * T0)
|
||||
U0 = m0 * medium.specific_internal_energy(T0)
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
self.port_a = self.register_declared_port("port_a")
|
||||
self.port_a = self.register_declared_port('port_a')
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Tank:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
V=parameters["volume"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return self.state.as_vector()
|
||||
|
||||
def set_state_vector(self, values: list[float]) -> None:
|
||||
self.state = VolumeState.from_vector(values)
|
||||
|
||||
def properties(self) -> ThermodynamicProperties:
|
||||
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
|
||||
self.port_a.p = props.p
|
||||
self.port_a.h_outflow = props.h
|
||||
return props
|
||||
|
||||
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||
return self.properties()
|
||||
|
||||
def state_derivative_from_ports(
|
||||
self,
|
||||
connected_h: Mapping[str, float],
|
||||
) -> list[float]:
|
||||
properties = self.properties()
|
||||
derivative = self.derivatives_from_connection(
|
||||
connected_h=connected_h["port_a"],
|
||||
port_m_flow=self.port_a.m_flow,
|
||||
internal_h=properties.h,
|
||||
)
|
||||
return derivative.as_vector()
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
pressure = self.medium.properties_from_mU(
|
||||
self.state.m,
|
||||
self.state.U,
|
||||
self.V,
|
||||
).p
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:port_a_pressure_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_a.p", f"{self.name}.state"),
|
||||
role="effort",
|
||||
value=self.port_a.p - pressure,
|
||||
),
|
||||
)
|
||||
|
||||
def derivatives_from_connection(
|
||||
self,
|
||||
*,
|
||||
connected_h: float,
|
||||
port_m_flow: float,
|
||||
internal_h: float,
|
||||
) -> VolumeState:
|
||||
inlet_h = self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_m_flow,
|
||||
connected_h=connected_h,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
return self.derivatives(inlet_h, port_m_flow)
|
||||
|
||||
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
|
||||
return VolumeState(m=m_flow, U=m_flow * inlet_h)
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Tank:
|
||||
return cls(name=name, medium=medium, V=parameters['volume'], p0=parameters['p0'], T0=parameters['T0'])
|
||||
EQUATIONS = ({'id': '__MODEL__:port_a_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_a.p', '__MODEL__.state'], 'role': 'effort'},)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Simulation options and progress data; no numerical solver implementation."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SolverActivitySnapshot:
|
||||
"""Low-cost, additive view of work inside an integration task.
|
||||
|
||||
``accepted_time`` deliberately changes only after an accepted solver step.
|
||||
Trial evaluations may continue to advance ``activity_sequence`` and
|
||||
``current_trial_time`` while that public progress value stays fixed.
|
||||
"""
|
||||
|
||||
activity_sequence: int
|
||||
activity_kind: str
|
||||
current_trial_time: float | None
|
||||
rhs_call_count: int
|
||||
accepted_step_sequence: int
|
||||
accepted_time: float | None
|
||||
solver_step_sequence: int
|
||||
jacobian_evaluation_count: int
|
||||
thermofluid_closure_count: int
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"activitySequence": self.activity_sequence,
|
||||
"activityKind": self.activity_kind,
|
||||
"currentTrialTime": self.current_trial_time,
|
||||
"rhsCallCount": self.rhs_call_count,
|
||||
"acceptedStepSequence": self.accepted_step_sequence,
|
||||
"acceptedTime": self.accepted_time,
|
||||
"solverStepSequence": self.solver_step_sequence,
|
||||
"jacobianEvaluationCount": self.jacobian_evaluation_count,
|
||||
"thermofluidClosureCount": self.thermofluid_closure_count,
|
||||
}
|
||||
|
||||
class SolverActivityTracker:
|
||||
"""Single-writer activity telemetry for a solver worker.
|
||||
|
||||
The solver thread is the only writer and the stream thread only snapshots
|
||||
scalar attributes. The sequence is published last, so a reader never
|
||||
treats partially published fields as a newer completed activity update.
|
||||
The tracker receives aggregate counters from the independent C worker.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_accepted_step_sequence",
|
||||
"_accepted_time",
|
||||
"_activity_kind",
|
||||
"_activity_sequence",
|
||||
"_current_trial_time",
|
||||
"_jacobian_evaluation_count",
|
||||
"_rhs_call_count",
|
||||
"_solver_step_sequence",
|
||||
"_thermofluid_closure_count",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._activity_sequence = 0
|
||||
self._activity_kind = "idle"
|
||||
self._current_trial_time: float | None = None
|
||||
self._rhs_call_count = 0
|
||||
self._accepted_step_sequence = 0
|
||||
self._accepted_time: float | None = None
|
||||
self._solver_step_sequence = 0
|
||||
self._jacobian_evaluation_count = 0
|
||||
self._thermofluid_closure_count = 0
|
||||
|
||||
def _publish(self, kind: str, time: float | None = None) -> None:
|
||||
self._activity_kind = kind
|
||||
if time is not None:
|
||||
self._current_trial_time = float(time)
|
||||
self._activity_sequence += 1
|
||||
|
||||
def start_integration(self, time: float) -> None:
|
||||
self._accepted_time = float(time)
|
||||
self._publish("solver_initialization", time)
|
||||
|
||||
def record_phase(self, kind: str, time: float | None = None) -> None:
|
||||
self._publish(kind, time)
|
||||
|
||||
def record_solver_step(self, time: float) -> None:
|
||||
self._solver_step_sequence += 1
|
||||
self._publish("solver_step", time)
|
||||
|
||||
def record_rhs(self, time: float) -> None:
|
||||
self._rhs_call_count += 1
|
||||
self._publish("rhs", time)
|
||||
|
||||
def record_native_progress(self, time: float, rhs_count: int, accepted_count: int) -> None:
|
||||
"""Publish aggregate counters from an isolated C worker without per-RHS callbacks."""
|
||||
self._rhs_call_count = max(self._rhs_call_count, rhs_count)
|
||||
self._accepted_step_sequence = max(self._accepted_step_sequence, accepted_count)
|
||||
self._accepted_time = max(self._accepted_time or time, time)
|
||||
self._publish("native_solver", time)
|
||||
|
||||
def record_jacobian(self, time: float) -> None:
|
||||
self._jacobian_evaluation_count += 1
|
||||
self._publish("jacobian", time)
|
||||
|
||||
def record_thermofluid_closure(self, time: float) -> None:
|
||||
self._thermofluid_closure_count += 1
|
||||
self._publish("thermofluid_closure", time)
|
||||
|
||||
def record_accepted_step(self, time: float) -> None:
|
||||
accepted_time = float(time)
|
||||
if (
|
||||
self._accepted_time is not None
|
||||
and accepted_time <= self._accepted_time
|
||||
):
|
||||
return
|
||||
self._accepted_step_sequence += 1
|
||||
self._accepted_time = accepted_time
|
||||
self._publish("accepted_step", accepted_time)
|
||||
|
||||
def snapshot(self) -> SolverActivitySnapshot:
|
||||
# ``activity_sequence`` is read last because writers publish it last.
|
||||
activity_kind = self._activity_kind
|
||||
current_trial_time = self._current_trial_time
|
||||
rhs_call_count = self._rhs_call_count
|
||||
accepted_step_sequence = self._accepted_step_sequence
|
||||
accepted_time = self._accepted_time
|
||||
solver_step_sequence = self._solver_step_sequence
|
||||
jacobian_evaluation_count = self._jacobian_evaluation_count
|
||||
thermofluid_closure_count = self._thermofluid_closure_count
|
||||
activity_sequence = self._activity_sequence
|
||||
return SolverActivitySnapshot(
|
||||
activity_sequence=activity_sequence,
|
||||
activity_kind=activity_kind,
|
||||
current_trial_time=current_trial_time,
|
||||
rhs_call_count=rhs_call_count,
|
||||
accepted_step_sequence=accepted_step_sequence,
|
||||
accepted_time=accepted_time,
|
||||
solver_step_sequence=solver_step_sequence,
|
||||
jacobian_evaluation_count=jacobian_evaluation_count,
|
||||
thermofluid_closure_count=thermofluid_closure_count,
|
||||
)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SolveIVPConfig:
|
||||
t_start: float = 0.0
|
||||
t_stop: float = 20.0
|
||||
method: str = "BDF"
|
||||
rtol: float = 1e-6
|
||||
atol: float | Sequence[float] = 1e-8
|
||||
max_step: float = 1e-3
|
||||
first_step: float | None = None
|
||||
+42
-193
@@ -1,22 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from abc import ABC
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
ResultVariableDefinition,
|
||||
ResultVariableMetadata,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.equations import EquationDefinition
|
||||
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, ResultVariableMetadata, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
from app.simulation.core.ports import PortDefinition, PortState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
|
||||
from app.simulation.core.medium import GasMedium
|
||||
|
||||
class Component(ABC):
|
||||
MODEL_TYPE: ClassVar[str | None] = None
|
||||
@@ -38,55 +29,53 @@ class Component(ABC):
|
||||
|
||||
@property
|
||||
def port_definitions(self) -> tuple[PortDefinition, ...]:
|
||||
return tuple(
|
||||
port.definition
|
||||
for port in self._ports.values()
|
||||
if port.definition is not None
|
||||
)
|
||||
return tuple((port.definition for port in self._ports.values() if port.definition is not None))
|
||||
|
||||
@classmethod
|
||||
def active_port_definitions_for_parameters(cls, parameters: Mapping[str, float]) -> tuple[PortDefinition, ...]:
|
||||
"""Declared ports enabled by one normalized parameter set."""
|
||||
return cls.PORTS
|
||||
|
||||
@property
|
||||
def active_port_definitions(self) -> tuple[PortDefinition, ...]:
|
||||
"""Instance ports that participate in execution and result reporting."""
|
||||
return self.port_definitions
|
||||
|
||||
@property
|
||||
def required_connection_ports(self) -> tuple[str, ...]:
|
||||
"""Physical ports that must have an external connection before simulation."""
|
||||
return tuple((definition.name for definition in self.active_port_definitions if definition.kind == 'physical'))
|
||||
|
||||
def register_port(self, port: PortState) -> PortState:
|
||||
definition = port.definition
|
||||
if definition is None:
|
||||
raise ValueError(f"Component {self.name} cannot register an undefined port.")
|
||||
raise ValueError(f'Component {self.name} cannot register an undefined port.')
|
||||
if definition.name in self._ports:
|
||||
raise ValueError(f"Duplicate port {self.name}.{definition.name}.")
|
||||
raise ValueError(f'Duplicate port {self.name}.{definition.name}.')
|
||||
self._ports[definition.name] = port
|
||||
return port
|
||||
|
||||
def register_declared_port(self, name: str) -> PortState:
|
||||
try:
|
||||
definition = next(item for item in self.PORTS if item.name == name)
|
||||
definition = next((item for item in self.PORTS if item.name == name))
|
||||
except StopIteration as exc:
|
||||
raise ValueError(
|
||||
f"Component model {self.model_type} does not declare port {name}."
|
||||
) from exc
|
||||
raise ValueError(f'Component model {self.model_type} does not declare port {name}.') from exc
|
||||
return self.register_port(PortState(definition=definition))
|
||||
|
||||
def set_parameter_values(self, values: Mapping[str, float]) -> None:
|
||||
definitions = {definition.name: definition for definition in self.PARAMETERS}
|
||||
unknown = sorted(set(values) - set(definitions))
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"Component {self.name} contains unsupported parameters: "
|
||||
+ ", ".join(unknown)
|
||||
+ "."
|
||||
)
|
||||
raise ValueError(f'Component {self.name} contains unsupported parameters: ' + ', '.join(unknown) + '.')
|
||||
missing = sorted(set(definitions) - set(values))
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Component {self.name} is missing parameters: "
|
||||
+ ", ".join(missing)
|
||||
+ "."
|
||||
)
|
||||
|
||||
raise ValueError(f'Component {self.name} is missing parameters: ' + ', '.join(missing) + '.')
|
||||
resolved: dict[str, float] = {}
|
||||
for name, definition in definitions.items():
|
||||
value = float(values[name])
|
||||
message = definition.validation_message(value)
|
||||
if message is not None:
|
||||
raise ValueError(
|
||||
f"Parameter '{name}' on component '{self.name}' {message}."
|
||||
)
|
||||
raise ValueError(f"Parameter '{name}' on component '{self.name}' {message}.")
|
||||
resolved[name] = value
|
||||
self._parameter_values = resolved
|
||||
|
||||
@@ -98,180 +87,40 @@ class Component(ABC):
|
||||
try:
|
||||
return self._ports[name]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Component {self.name} has no port named {name}.") from exc
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
return {}
|
||||
|
||||
def result_values(self) -> dict[str, float]:
|
||||
component_values = dict(self.component_result_values())
|
||||
declared = {definition.name: definition for definition in self.RESULT_VARIABLES}
|
||||
unknown = sorted(set(component_values) - set(declared))
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"Component {self.name} returned undeclared result variables: "
|
||||
+ ", ".join(unknown)
|
||||
+ "."
|
||||
)
|
||||
|
||||
values: dict[str, float] = {}
|
||||
for name, definition in declared.items():
|
||||
if not definition.visible:
|
||||
continue
|
||||
if name not in component_values:
|
||||
raise ValueError(
|
||||
f"Component {self.name} did not provide declared result variable {name}."
|
||||
)
|
||||
values[name] = float(component_values[name])
|
||||
|
||||
for port_definition in self.port_definitions:
|
||||
port = self.get_port(port_definition.name)
|
||||
for variable in port_definition.variables:
|
||||
if not variable.result_visible:
|
||||
continue
|
||||
values[f"{port_definition.name}.{variable.name}"] = float(
|
||||
getattr(port, variable.name)
|
||||
)
|
||||
return values
|
||||
raise ValueError(f'Component {self.name} has no port named {name}.') from exc
|
||||
|
||||
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
|
||||
metadata = [
|
||||
ResultVariableMetadata(
|
||||
key=f"{self.name}.{definition.name}",
|
||||
component_id=self.name,
|
||||
component_type=self.model_type,
|
||||
scope="component",
|
||||
name=definition.name,
|
||||
label=definition.label,
|
||||
quantity=definition.quantity,
|
||||
unit=definition.unit,
|
||||
category=definition.category,
|
||||
order=definition.order,
|
||||
)
|
||||
for definition in self.RESULT_VARIABLES
|
||||
if definition.visible
|
||||
]
|
||||
for port_definition in self.port_definitions:
|
||||
metadata = [ResultVariableMetadata(key=f'{self.name}.{definition.name}', component_id=self.name, component_type=self.model_type, scope='component', name=definition.name, label=definition.label, quantity=definition.quantity, unit=definition.unit, category=definition.category, order=definition.order) for definition in self.RESULT_VARIABLES if definition.visible]
|
||||
for port_definition in self.active_port_definitions:
|
||||
for variable in port_definition.variables:
|
||||
if not variable.result_visible:
|
||||
continue
|
||||
metadata.append(
|
||||
ResultVariableMetadata(
|
||||
key=f"{self.name}.{port_definition.name}.{variable.name}",
|
||||
component_id=self.name,
|
||||
component_type=self.model_type,
|
||||
scope="port",
|
||||
port_name=port_definition.name,
|
||||
name=variable.name,
|
||||
label=variable.label or variable.name,
|
||||
quantity=variable.quantity or variable.name,
|
||||
unit=variable.unit,
|
||||
category=variable.role,
|
||||
order=variable.order,
|
||||
)
|
||||
)
|
||||
metadata.append(ResultVariableMetadata(key=f'{self.name}.{port_definition.name}.{variable.name}', component_id=self.name, component_type=self.model_type, scope='port', port_name=port_definition.name, name=variable.name, label=variable.label or variable.name, quantity=variable.quantity or variable.name, unit=variable.unit, category=variable.role, order=variable.order))
|
||||
return tuple(metadata)
|
||||
|
||||
def parameter_interface_dicts(self) -> list[dict[str, object]]:
|
||||
return [
|
||||
definition.as_interface_dict(
|
||||
value=self._parameter_values.get(definition.name)
|
||||
)
|
||||
for definition in self.PARAMETERS
|
||||
]
|
||||
return [definition.as_interface_dict(value=self._parameter_values.get(definition.name)) for definition in self.PARAMETERS]
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Component:
|
||||
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> Component:
|
||||
"""Create a catalog model from normalized SI parameters."""
|
||||
raise NotImplementedError(f'Component model {cls.__name__} must implement create().')
|
||||
EQUATIONS = ()
|
||||
|
||||
raise NotImplementedError(
|
||||
f"Component model {cls.__name__} must implement create()."
|
||||
)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
"""Return algebraic residuals after the network assigns port states."""
|
||||
|
||||
return ()
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
"""Update connector outflow properties from current flow directions."""
|
||||
|
||||
return None
|
||||
def equation_definitions(self):
|
||||
|
||||
def bind(value):
|
||||
if isinstance(value, str):
|
||||
return value.replace('__MODEL__', self.name)
|
||||
return tuple((bind(v) for v in value))
|
||||
return tuple((EquationDefinition(id=bind(e['id']), owner=e['owner'], owner_id=self.name, relation=e['relation'], variables=bind(e['variables']), role=e['role']) for e in self.EQUATIONS))
|
||||
|
||||
class DynamicComponent(Component):
|
||||
state_size = 2
|
||||
|
||||
@staticmethod
|
||||
def actual_stream_enthalpy(
|
||||
port_m_flow: float,
|
||||
connected_h: float,
|
||||
internal_h: float,
|
||||
) -> float:
|
||||
"""Approximate `actualStream(port.h_outflow)` for a mixed control volume port."""
|
||||
|
||||
return connected_h if port_m_flow > 0.0 else internal_h
|
||||
|
||||
def connection_inlet_enthalpy(
|
||||
self,
|
||||
port_m_flow: float,
|
||||
connected_h: float,
|
||||
internal_h: float,
|
||||
) -> float:
|
||||
"""Resolve the enthalpy convected into this control volume through one port."""
|
||||
|
||||
return self.actual_stream_enthalpy(
|
||||
port_m_flow=port_m_flow,
|
||||
connected_h=connected_h,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def get_state_vector(self) -> list[float]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def set_state_vector(self, values: list[float]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def refresh_thermodynamic_ports(self) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
def state_derivative_from_ports(
|
||||
self,
|
||||
connected_h: Mapping[str, float],
|
||||
) -> list[float]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ThermodynamicVolumeComponent(DynamicComponent):
|
||||
"""Two-state gas volume exposing the shared thermodynamic result contract."""
|
||||
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
|
||||
def component_result_values(self) -> Mapping[str, float]:
|
||||
state = self.get_state_vector()
|
||||
if len(state) < 2:
|
||||
raise ValueError(
|
||||
f"Thermodynamic component {self.name} must expose mass and energy states."
|
||||
)
|
||||
properties = self.refresh_thermodynamic_ports()
|
||||
return {
|
||||
"m": float(state[0]),
|
||||
"U": float(state[1]),
|
||||
"p": float(properties.p),
|
||||
"T": float(properties.T),
|
||||
"rho": float(properties.rho),
|
||||
"u": float(properties.u),
|
||||
"h": float(properties.h),
|
||||
}
|
||||
|
||||
|
||||
class AlgebraicComponent(Component):
|
||||
"""Stateless element described by algebraic constraints only."""
|
||||
@@ -5,6 +5,7 @@ from typing import Literal
|
||||
|
||||
|
||||
PortDisplaySide = Literal["left", "right"]
|
||||
ComponentCatalogRole = Literal["amesimGasMediumDefinition"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -32,6 +33,26 @@ class PortDisplaySpec:
|
||||
order: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterGroupDisplaySpec:
|
||||
"""Ordered, collapsible presentation group for component parameters."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
parameters: tuple[str, ...]
|
||||
order: int = 0
|
||||
default_expanded: bool = False
|
||||
|
||||
def as_catalog_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"parameters": list(self.parameters),
|
||||
"order": self.order,
|
||||
"defaultExpanded": self.default_expanded,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComponentDisplaySpec:
|
||||
"""Frontend metadata co-located with a component implementation."""
|
||||
@@ -42,6 +63,8 @@ class ComponentDisplaySpec:
|
||||
symbol: str
|
||||
ports: tuple[PortDisplaySpec, ...]
|
||||
order: int = 0
|
||||
role: ComponentCatalogRole | None = None
|
||||
parameter_groups: tuple[ParameterGroupDisplaySpec, ...] = ()
|
||||
|
||||
@property
|
||||
def port_by_name(self) -> dict[str, PortDisplaySpec]:
|
||||
|
||||
@@ -10,16 +10,15 @@ EquationOwner = Literal["connection", "component"]
|
||||
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EquationResidual:
|
||||
"""One executable scalar equation in the pressure-flow subsystem."""
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EquationDefinition:
|
||||
"""One declarative equation in the compiled model interface."""
|
||||
|
||||
id: str
|
||||
owner: EquationOwner
|
||||
owner_id: str
|
||||
relation: EquationRelation
|
||||
variables: tuple[str, ...]
|
||||
value: float
|
||||
role: VariableRole | None = None
|
||||
|
||||
def as_definition_dict(self) -> dict[str, object]:
|
||||
@@ -33,4 +32,4 @@ class EquationResidual:
|
||||
}
|
||||
|
||||
def as_interface_dict(self) -> dict[str, object]:
|
||||
return {**self.as_definition_dict(), "residual": self.value}
|
||||
return self.as_definition_dict()
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class RecoverableTrialStateError(ValueError):
|
||||
"""A physical-domain failure caused by an integrator trial state."""
|
||||
@@ -1,96 +1,16 @@
|
||||
"""Compile-time gas property constants. No Python property evaluator."""
|
||||
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"
|
||||
name: str = 'SimpleAirApprox'
|
||||
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-05
|
||||
viscosity_T_ref: float = 293.15
|
||||
sutherland_constant: float = 110.4
|
||||
|
||||
@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)
|
||||
GasMedium = IdealGasMedium
|
||||
@@ -6,6 +6,11 @@ from typing import Literal
|
||||
|
||||
|
||||
ResultVariableScope = Literal["component", "port"]
|
||||
ParameterEditor = Literal[
|
||||
"amesimGasReference",
|
||||
"amesimGasPropertyModel",
|
||||
"choice",
|
||||
]
|
||||
|
||||
|
||||
SI_UNIT_BY_QUANTITY: dict[str, str] = {
|
||||
@@ -34,9 +39,41 @@ SI_UNIT_BY_QUANTITY: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterOption:
|
||||
"""One numeric choice exposed by a catalog-backed parameter editor."""
|
||||
|
||||
value: float
|
||||
label: str
|
||||
|
||||
def as_interface_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"value": self.value,
|
||||
"label": self.label,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterCondition:
|
||||
"""Match when the controlling parameter equals any declared value."""
|
||||
|
||||
parameter: str
|
||||
values: tuple[float, ...]
|
||||
|
||||
def as_interface_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"parameter": self.parameter,
|
||||
"values": list(self.values),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterDefinition:
|
||||
"""User-configurable model input expressed in the backend SI contract."""
|
||||
"""User-configurable model input expressed in the backend SI contract.
|
||||
|
||||
Every ``visible_when`` condition must match for the catalog parameter to
|
||||
be visible; each individual condition matches any one of its ``values``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
default: float
|
||||
@@ -46,6 +83,10 @@ class ParameterDefinition:
|
||||
minimum: float | None = None
|
||||
maximum: float | None = None
|
||||
minimum_exclusive: bool = False
|
||||
editor: ParameterEditor | None = None
|
||||
options: tuple[ParameterOption, ...] = ()
|
||||
description: str = ""
|
||||
visible_when: tuple[ParameterCondition, ...] = ()
|
||||
|
||||
def validation_message(self, value: float) -> str | None:
|
||||
if not isfinite(value):
|
||||
@@ -57,6 +98,11 @@ class ParameterDefinition:
|
||||
return f"must be at least {self.minimum:g}"
|
||||
if self.maximum is not None and value > self.maximum:
|
||||
return f"must be at most {self.maximum:g}"
|
||||
if self.options and value not in {
|
||||
float(option.value) for option in self.options
|
||||
}:
|
||||
available = ", ".join(f"{option.value:g}" for option in self.options)
|
||||
return f"must be one of {available}"
|
||||
return None
|
||||
|
||||
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
|
||||
@@ -72,6 +118,18 @@ class ParameterDefinition:
|
||||
payload["minimum"] = self.minimum
|
||||
if self.maximum is not None:
|
||||
payload["maximum"] = self.maximum
|
||||
if self.editor is not None:
|
||||
payload["editor"] = self.editor
|
||||
if self.options:
|
||||
payload["options"] = [
|
||||
option.as_interface_dict() for option in self.options
|
||||
]
|
||||
if self.description:
|
||||
payload["description"] = self.description
|
||||
if self.visible_when:
|
||||
payload["visibleWhen"] = [
|
||||
condition.as_interface_dict() for condition in self.visible_when
|
||||
]
|
||||
if value is not None:
|
||||
payload["value"] = value
|
||||
return payload
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import acos, cos, isfinite, log, pi, sqrt
|
||||
|
||||
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PengRobinsonFluid:
|
||||
"""Pure-fluid Peng-Robinson equation-of-state helper.
|
||||
|
||||
The class covers the equation-of-state layer plus the enthalpy departure
|
||||
needed to compare AMESim pneumatic ``pn2hpti`` reference enthalpy flows.
|
||||
"""
|
||||
|
||||
name: str
|
||||
molar_mass: float
|
||||
critical_temperature: float
|
||||
critical_pressure: float
|
||||
acentric_factor: float
|
||||
|
||||
@property
|
||||
def specific_gas_constant(self) -> float:
|
||||
return UNIVERSAL_GAS_CONSTANT / self.molar_mass
|
||||
|
||||
@property
|
||||
def a_parameter(self) -> float:
|
||||
return (
|
||||
0.45724
|
||||
* UNIVERSAL_GAS_CONSTANT
|
||||
* UNIVERSAL_GAS_CONSTANT
|
||||
* self.critical_temperature
|
||||
* self.critical_temperature
|
||||
/ self.critical_pressure
|
||||
)
|
||||
|
||||
@property
|
||||
def b_parameter(self) -> float:
|
||||
return 0.07780 * UNIVERSAL_GAS_CONSTANT * self.critical_temperature / self.critical_pressure
|
||||
|
||||
@property
|
||||
def kappa(self) -> float:
|
||||
omega = self.acentric_factor
|
||||
return 0.37464 + 1.54226 * omega - 0.26992 * omega * omega
|
||||
|
||||
def alpha(self, temperature: float) -> float:
|
||||
self._validate_temperature(temperature)
|
||||
reduced_temperature = temperature / self.critical_temperature
|
||||
return (1.0 + self.kappa * (1.0 - sqrt(reduced_temperature))) ** 2.0
|
||||
|
||||
def alpha_temperature_derivative(self, temperature: float) -> float:
|
||||
self._validate_temperature(temperature)
|
||||
reduced_temperature = temperature / self.critical_temperature
|
||||
sqrt_reduced_temperature = sqrt(reduced_temperature)
|
||||
alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature)
|
||||
return -(
|
||||
alpha_base
|
||||
* self.kappa
|
||||
/ (self.critical_temperature * sqrt_reduced_temperature)
|
||||
)
|
||||
|
||||
def attractive_parameter(self, temperature: float) -> float:
|
||||
return self.a_parameter * self.alpha(temperature)
|
||||
|
||||
def attractive_parameter_temperature_derivative(self, temperature: float) -> float:
|
||||
return self.a_parameter * self.alpha_temperature_derivative(temperature)
|
||||
|
||||
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
|
||||
self._validate_temperature(temperature)
|
||||
if molar_volume <= self.b_parameter:
|
||||
raise ValueError("Molar volume must be larger than Peng-Robinson b parameter.")
|
||||
a_alpha = self.attractive_parameter(temperature)
|
||||
b = self.b_parameter
|
||||
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
|
||||
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
|
||||
return repulsive - attractive
|
||||
|
||||
def pressure_from_density(self, temperature: float, density: float) -> float:
|
||||
if density <= 0.0:
|
||||
raise ValueError("Density must be positive.")
|
||||
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
|
||||
|
||||
def reduced_parameters(self, pressure: float, temperature: float) -> tuple[float, float]:
|
||||
self._validate_pressure_temperature(pressure, temperature)
|
||||
a_alpha = self.attractive_parameter(temperature)
|
||||
b = self.b_parameter
|
||||
A = a_alpha * pressure / (UNIVERSAL_GAS_CONSTANT * UNIVERSAL_GAS_CONSTANT * temperature * temperature)
|
||||
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
|
||||
return A, B
|
||||
|
||||
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
|
||||
A, B = self.reduced_parameters(pressure, temperature)
|
||||
coefficients = (
|
||||
-(1.0 - B),
|
||||
A - 3.0 * B * B - 2.0 * B,
|
||||
-(A * B - B * B - B * B * B),
|
||||
)
|
||||
roots = _real_cubic_roots(*coefficients)
|
||||
physical_roots = tuple(sorted(root for root in roots if root > B and isfinite(root)))
|
||||
if not physical_roots:
|
||||
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
|
||||
return physical_roots
|
||||
|
||||
def compressibility_factor(
|
||||
self,
|
||||
pressure: float,
|
||||
temperature: float,
|
||||
phase: str = "vapor",
|
||||
) -> float:
|
||||
roots = self.compressibility_roots(pressure, temperature)
|
||||
if phase == "vapor":
|
||||
return roots[-1]
|
||||
if phase == "liquid":
|
||||
return roots[0]
|
||||
if phase == "stable-single-root":
|
||||
return roots[-1]
|
||||
raise ValueError(f"Unsupported phase selector: {phase!r}")
|
||||
|
||||
def molar_volume(
|
||||
self,
|
||||
pressure: float,
|
||||
temperature: float,
|
||||
phase: str = "vapor",
|
||||
) -> float:
|
||||
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
||||
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
|
||||
|
||||
def density(
|
||||
self,
|
||||
pressure: float,
|
||||
temperature: float,
|
||||
phase: str = "vapor",
|
||||
) -> float:
|
||||
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
|
||||
|
||||
def residual_specific_enthalpy(
|
||||
self,
|
||||
pressure: float,
|
||||
temperature: float,
|
||||
phase: str = "vapor",
|
||||
) -> float:
|
||||
"""Return Peng-Robinson enthalpy departure from ideal gas, J/kg."""
|
||||
self._validate_pressure_temperature(pressure, temperature)
|
||||
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
||||
_, B = self.reduced_parameters(pressure, temperature)
|
||||
b = self.b_parameter
|
||||
attractive = self.attractive_parameter(temperature)
|
||||
d_attractive_d_temperature = (
|
||||
self.attractive_parameter_temperature_derivative(temperature)
|
||||
)
|
||||
log_argument = (z + (1.0 + sqrt(2.0)) * B) / (
|
||||
z + (1.0 - sqrt(2.0)) * B
|
||||
)
|
||||
residual_molar_enthalpy = (
|
||||
UNIVERSAL_GAS_CONSTANT * temperature * (z - 1.0)
|
||||
+ (
|
||||
temperature * d_attractive_d_temperature
|
||||
- attractive
|
||||
)
|
||||
* log(log_argument)
|
||||
/ (2.0 * sqrt(2.0) * b)
|
||||
)
|
||||
return residual_molar_enthalpy / self.molar_mass
|
||||
|
||||
@staticmethod
|
||||
def _validate_temperature(temperature: float) -> None:
|
||||
if temperature <= 0.0:
|
||||
raise ValueError("Temperature must be positive.")
|
||||
|
||||
@classmethod
|
||||
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
|
||||
if pressure <= 0.0:
|
||||
raise ValueError("Pressure must be positive.")
|
||||
cls._validate_temperature(temperature)
|
||||
|
||||
HELIUM_PR = PengRobinsonFluid(
|
||||
name="helium",
|
||||
molar_mass=0.004002602,
|
||||
critical_temperature=5.1953,
|
||||
critical_pressure=227_460.0,
|
||||
acentric_factor=-0.385,
|
||||
)
|
||||
|
||||
NITROGEN_PR = PengRobinsonFluid(
|
||||
name="nitrogen",
|
||||
molar_mass=0.0280134,
|
||||
critical_temperature=126.192,
|
||||
critical_pressure=3.3958e6,
|
||||
acentric_factor=0.0372,
|
||||
)
|
||||
|
||||
AIR_PR = PengRobinsonFluid(
|
||||
name="air",
|
||||
molar_mass=0.02896513,
|
||||
critical_temperature=132.5306,
|
||||
critical_pressure=3.786e6,
|
||||
acentric_factor=0.0335,
|
||||
)
|
||||
|
||||
|
||||
def _real_cubic_roots(a: float, b: float, c: float) -> tuple[float, ...]:
|
||||
"""Return real roots for x**3 + a*x**2 + b*x + c = 0."""
|
||||
|
||||
depressed_p = b - a * a / 3.0
|
||||
depressed_q = 2.0 * a * a * a / 27.0 - a * b / 3.0 + c
|
||||
discriminant = (depressed_q / 2.0) ** 2.0 + (depressed_p / 3.0) ** 3.0
|
||||
offset = -a / 3.0
|
||||
tolerance = 1e-14
|
||||
|
||||
if discriminant > tolerance:
|
||||
sqrt_discriminant = sqrt(discriminant)
|
||||
u = _real_cube_root(-depressed_q / 2.0 + sqrt_discriminant)
|
||||
v = _real_cube_root(-depressed_q / 2.0 - sqrt_discriminant)
|
||||
return (u + v + offset,)
|
||||
|
||||
if abs(discriminant) <= tolerance:
|
||||
u = _real_cube_root(-depressed_q / 2.0)
|
||||
return tuple(sorted({2.0 * u + offset, -u + offset}))
|
||||
|
||||
if depressed_p >= 0.0:
|
||||
raise ValueError("Unexpected cubic state with three real roots and non-negative p.")
|
||||
radius = 2.0 * sqrt(-depressed_p / 3.0)
|
||||
argument = (3.0 * depressed_q / (2.0 * depressed_p)) * sqrt(-3.0 / depressed_p)
|
||||
argument = max(-1.0, min(1.0, argument))
|
||||
theta = acos(argument) / 3.0
|
||||
roots = [
|
||||
radius * cos(theta - 2.0 * pi * index / 3.0) + offset
|
||||
for index in range(3)
|
||||
]
|
||||
return tuple(sorted(roots))
|
||||
|
||||
|
||||
def _real_cube_root(value: float) -> float:
|
||||
if value == 0.0:
|
||||
return 0.0
|
||||
return (1.0 if value > 0.0 else -1.0) * abs(value) ** (1.0 / 3.0)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Compile-time variable supply contracts, separate from physical flow direction.
|
||||
|
||||
Equation ports may participate in a simultaneous solve. Fixed ports (for example
|
||||
an Amesim node's reference/branch ports) require complementary local supplies.
|
||||
These declarations do not add numerical state or Python evaluation callbacks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal, Mapping
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ports import PortDefinition
|
||||
|
||||
|
||||
VARIABLE_LABELS = {
|
||||
"p": "压力", "T": "温度", "m_flow": "质量流率", "H_flow": "能量流率",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortComputation:
|
||||
inputs: tuple[str, ...] = ()
|
||||
outputs: tuple[str, ...] = ()
|
||||
mode: Literal["equation", "fixed"] = "equation"
|
||||
# Output p/T aliases an input on another port of the same component.
|
||||
reference_port: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.mode not in {"equation", "fixed"}:
|
||||
raise ValueError(f"Unknown port computation mode: {self.mode}")
|
||||
members = (*self.inputs, *self.outputs)
|
||||
if len(set(members)) != len(members) or set(members) - VARIABLE_LABELS.keys():
|
||||
raise ValueError("Port computation variables must be unique, supported quantities.")
|
||||
if self.reference_port and not {"p", "T"}.issubset(self.outputs):
|
||||
raise ValueError("A reference alias must supply pressure and temperature.")
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"inputs": list(self.inputs),
|
||||
"outputs": list(self.outputs),
|
||||
**({"referencePort": self.reference_port} if self.reference_port else {}),
|
||||
}
|
||||
|
||||
|
||||
THERMODYNAMIC_SUPPLY = PortComputation(inputs=("m_flow", "H_flow"), outputs=("p", "T"))
|
||||
FLOW_SUPPLY = PortComputation(inputs=("p", "T"), outputs=("m_flow", "H_flow"))
|
||||
ZERO_FLOW_SUPPLY = PortComputation(outputs=("m_flow", "H_flow"))
|
||||
IMPLICIT_PNEUMATIC = PortComputation()
|
||||
NODE_REFERENCE = PortComputation(inputs=("p", "T"), outputs=("m_flow", "H_flow"), mode="fixed")
|
||||
NODE_BRANCH = PortComputation(inputs=("m_flow", "H_flow"), outputs=("p", "T"),
|
||||
mode="fixed", reference_port="port_2")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortSupplyIssue:
|
||||
code: str
|
||||
message: str
|
||||
endpoint: tuple[str, str] | None = None
|
||||
|
||||
|
||||
class PortSupplyError(ValueError):
|
||||
def __init__(self, issue: PortSupplyIssue):
|
||||
self.issue = issue
|
||||
super().__init__(f"{issue.code}: {issue.message}")
|
||||
|
||||
|
||||
def port_supply_issue(first: PortDefinition, second: PortDefinition,
|
||||
first_label: str | None = None, second_label: str | None = None
|
||||
) -> PortSupplyIssue | None:
|
||||
"""Check fixed causality; ordinary equation-to-equation links stay legal."""
|
||||
if first.kind != second.kind or first.domain != second.domain:
|
||||
return None # Domain/type checks own their existing, more specific errors.
|
||||
a, b = first.computation, second.computation
|
||||
if not any(item and item.mode == "fixed" for item in (a, b)):
|
||||
return None
|
||||
for consumer, supplier, consumer_label, supplier_label in (
|
||||
(a, b, first_label or first.name, second_label or second.name),
|
||||
(b, a, second_label or second.name, first_label or first.name),
|
||||
):
|
||||
if consumer is None:
|
||||
continue
|
||||
missing = [name for name in consumer.inputs
|
||||
if supplier is None or name not in supplier.outputs]
|
||||
if missing:
|
||||
quantities = "、".join(VARIABLE_LABELS[name] for name in missing)
|
||||
return PortSupplyIssue(
|
||||
"CONNECTION_VARIABLE_SUPPLY_MISSING",
|
||||
f"{consumer_label} 需要对端提供{quantities},但 {supplier_label} 未提供;"
|
||||
"请检查参考口与支路口的连接。气体流向反转不会改变这一供需关系。",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def reference_supply_issues(
|
||||
ports: Mapping[tuple[str, str], PortDefinition],
|
||||
adjacency: Mapping[tuple[str, str], tuple[str, str]],
|
||||
) -> list[PortSupplyIssue]:
|
||||
"""Follow declared aliases to reject a reference ring without an origin.
|
||||
|
||||
This is a supply check, not a whole-system execution scheduler. Reference
|
||||
chains are iterative to support deep networks without Python recursion.
|
||||
"""
|
||||
issues = []
|
||||
resolved: dict[str, set[tuple[str, str]]] = {'p': set(), 'T': set()}
|
||||
for endpoint, port in ports.items():
|
||||
contract = port.computation
|
||||
if not contract or contract.mode != "fixed" or not {"p", "T"}.issubset(contract.inputs):
|
||||
continue
|
||||
if endpoint not in adjacency:
|
||||
continue # Existing unconnected-port checks handle incomplete drawings.
|
||||
for variable in ("p", "T"):
|
||||
current = endpoint
|
||||
visited: set[tuple[str, str]] = set()
|
||||
chain: list[str] = []
|
||||
while True:
|
||||
if current in resolved[variable]:
|
||||
resolved[variable].update(visited)
|
||||
break
|
||||
if current in visited:
|
||||
issues.append(PortSupplyIssue(
|
||||
"REFERENCE_SUPPLY_CYCLE",
|
||||
f"{'.'.join(endpoint)} 的{VARIABLE_LABELS[variable]}参考形成循环,"
|
||||
f"没有实际提供者:{' → '.join(chain)} → {'.'.join(current)}。",
|
||||
endpoint,
|
||||
))
|
||||
break
|
||||
visited.add(current)
|
||||
chain.append('.'.join(current))
|
||||
supplier = adjacency.get(current)
|
||||
if supplier is None:
|
||||
issues.append(PortSupplyIssue(
|
||||
"REFERENCE_SUPPLY_UNCONNECTED",
|
||||
f"{'.'.join(endpoint)} 的{VARIABLE_LABELS[variable]}参考链在 "
|
||||
f"{'.'.join(current)} 中断:该参考输入尚未连接。", endpoint,
|
||||
))
|
||||
break
|
||||
supplied = ports.get(supplier)
|
||||
supply = supplied.computation if supplied else None
|
||||
if supply is None or variable not in supply.outputs:
|
||||
# Direct errors are already reported per connection. An
|
||||
# indirect failure is explained at that failing connection.
|
||||
break
|
||||
if supply.reference_port is None:
|
||||
resolved[variable].update(visited)
|
||||
break
|
||||
chain.append('.'.join(supplier))
|
||||
current = supplier[0], supply.reference_port
|
||||
if current not in ports:
|
||||
raise ValueError(f"Invalid reference port declaration: {current}")
|
||||
return issues
|
||||
Loaded 100 of 760 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user